events: rework metrics endpoint (#14934)

* rework event volume

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate more

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate more

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate more

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* the rest of the owl

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* client-side data padding

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* I love deleting code

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix clamping

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* chunk it

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add event-to-color map

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* sync colours

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* switch colours

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* heatmap?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Revert "heatmap?"

This reverts commit c1f549a18b.

* Revert "Revert "heatmap?""

This reverts commit 6d6175b96b.

* Revert "Revert "Revert "heatmap?"""

This reverts commit 3717903f12.

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2025-06-10 02:36:09 +02:00
committed by GitHub
parent 856ac052e7
commit 734db4dee6
23 changed files with 461 additions and 743 deletions

View File

@ -13,7 +13,7 @@ import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import { EventActions } from "@goauthentik/api";
import { EventActions, EventsEventsVolumeListRequest } from "@goauthentik/api";
@customElement("ak-admin-dashboard-users")
export class DashboardUserPage extends AKElement {
@ -46,9 +46,9 @@ export class DashboardUserPage extends AKElement {
<ak-aggregate-card header=${msg("Users created per day in the last month")}>
<ak-charts-admin-model-per-day
.query=${{
context__model__app: "authentik_core",
context__model__model_name: "user",
}}
contextModelApp: "authentik_core",
contextModelName: "user",
} as EventsEventsVolumeListRequest}
label=${msg("Users created")}
>
</ak-charts-admin-model-per-day>

View File

@ -1,68 +1,51 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart";
import { ChartData } from "chart.js";
import { ChartData, ChartDataset } from "chart.js";
import { msg } from "@lit/localize";
import { customElement } from "lit/decorators.js";
import { AdminApi, LoginMetrics } from "@goauthentik/api";
import { EventActions, EventVolume, EventsApi } from "@goauthentik/api";
@customElement("ak-charts-admin-login-authorization")
export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
async apiRequest(): Promise<LoginMetrics> {
return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve();
export class AdminLoginAuthorizeChart extends EventChart {
async apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
actions: [
EventActions.AuthorizeApplication,
EventActions.Login,
EventActions.LoginFailed,
],
});
}
getChartData(data: LoginMetrics): ChartData {
return {
datasets: [
{
label: msg("Authorizations"),
backgroundColor: new RGBAColor(43, 154, 243, 0.5).toString(),
borderColor: new RGBAColor(43, 154, 243, 1).toString(),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
data: data.authorizations.map((cord) => {
return {
x: cord.xCord,
y: cord.yCord,
};
}),
},
{
label: msg("Failed Logins"),
backgroundColor: new RGBAColor(201, 24, 11, 0.5).toString(),
borderColor: new RGBAColor(201, 24, 11, 1).toString(),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
data: data.loginsFailed.map((cord) => {
return {
x: cord.xCord,
y: cord.yCord,
};
}),
},
{
label: msg("Successful Logins"),
backgroundColor: new RGBAColor(62, 134, 53, 0.5).toString(),
borderColor: new RGBAColor(62, 134, 53, 1).toString(),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
data: data.logins.map((cord) => {
return {
x: cord.xCord,
y: cord.yCord,
};
}),
},
],
};
getChartData(data: EventVolume[]): ChartData {
const optsMap = new Map<EventActions, Partial<ChartDataset>>();
optsMap.set(EventActions.AuthorizeApplication, {
label: msg("Authorizations"),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
});
optsMap.set(EventActions.Login, {
label: msg("Successful Logins"),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
});
optsMap.set(EventActions.LoginFailed, {
label: msg("Failed Logins"),
spanGaps: true,
fill: "origin",
cubicInterpolationMode: "monotone",
tension: 0.4,
});
return this.eventVolume(data, {
optsMap: optsMap,
padToDays: 7,
});
}
}

View File

@ -1,14 +1,19 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData, Tick } from "chart.js";
import { ChartData } from "chart.js";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { customElement, property } from "lit/decorators.js";
import { Coordinate, EventActions, EventsApi } from "@goauthentik/api";
import {
EventActions,
EventVolume,
EventsApi,
EventsEventsVolumeListRequest,
} from "@goauthentik/api";
@customElement("ak-charts-admin-model-per-day")
export class AdminModelPerDay extends AKChart<Coordinate[]> {
export class AdminModelPerDay extends EventChart {
@property()
action: EventActions = EventActions.ModelCreated;
@ -16,39 +21,29 @@ export class AdminModelPerDay extends AKChart<Coordinate[]> {
label?: string;
@property({ attribute: false })
query?: { [key: string]: unknown } | undefined;
query?: EventsEventsVolumeListRequest;
async apiRequest(): Promise<Coordinate[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsPerMonthList({
async apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
action: this.action,
query: JSON.stringify(this.query || {}),
historyDays: 30,
...this.query,
});
}
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
const valueStamp = ticks[index];
const delta = Date.now() - valueStamp.value;
const ago = Math.round(delta / 1000 / 3600 / 24);
return msg(str`${ago} days ago`);
}
getChartData(data: Coordinate[]): ChartData {
return {
datasets: [
{
label: this.label || msg("Objects created"),
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true,
data:
data.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
],
};
getChartData(data: EventVolume[]): ChartData {
return this.eventVolume(data, {
optsMap: new Map([
[
this.action,
{
label: this.label || msg("Objects created"),
spanGaps: true,
},
],
]),
padToDays: 30,
});
}
}

View File

@ -1,3 +1,4 @@
import { actionToColor } from "#elements/charts/EventChart";
import { SummarizedSyncStatus } from "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
@ -7,7 +8,7 @@ import { ChartData, ChartOptions } from "chart.js";
import { msg } from "@lit/localize";
import { customElement } from "lit/decorators.js";
import { OutpostsApi } from "@goauthentik/api";
import { EventActions, OutpostsApi } from "@goauthentik/api";
@customElement("ak-admin-status-chart-outpost")
export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> {
@ -65,7 +66,11 @@ export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> {
labels: [msg("Healthy outposts"), msg("Outdated outposts"), msg("Unhealthy outposts")],
datasets: data.map((d) => {
return {
backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"],
backgroundColor: [
actionToColor(EventActions.Login),
actionToColor(EventActions.SuspiciousRequest),
actionToColor(EventActions.AuthorizeApplication),
],
spanGaps: true,
data: [d.healthy, d.failed, d.unsynced],
label: d.label,

View File

@ -1,3 +1,4 @@
import { actionToColor } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
import "@goauthentik/elements/forms/ConfirmationForm";
@ -7,7 +8,13 @@ import { ChartData, ChartOptions } from "chart.js";
import { msg } from "@lit/localize";
import { customElement } from "lit/decorators.js";
import { ProvidersApi, SourcesApi, SyncStatus, SystemTaskStatusEnum } from "@goauthentik/api";
import {
EventActions,
ProvidersApi,
SourcesApi,
SyncStatus,
SystemTaskStatusEnum,
} from "@goauthentik/api";
export interface SummarizedSyncStatus {
healthy: number;
@ -136,7 +143,11 @@ export class SyncStatusChart extends AKChart<SummarizedSyncStatus[]> {
labels: [msg("Healthy"), msg("Failed"), msg("Unsynced / N/A")],
datasets: data.map((d) => {
return {
backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"],
backgroundColor: [
actionToColor(EventActions.Login),
actionToColor(EventActions.SuspiciousRequest),
actionToColor(EventActions.AuthorizeApplication),
],
spanGaps: true,
data: [d.healthy, d.failed, d.unsynced],
label: d.label,

View File

@ -1,47 +1,37 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData, Tick } from "chart.js";
import { ChartData } from "chart.js";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { customElement, property } from "lit/decorators.js";
import { Coordinate, CoreApi } from "@goauthentik/api";
import { EventActions, EventVolume, EventsApi } from "@goauthentik/api";
@customElement("ak-charts-application-authorize")
export class ApplicationAuthorizeChart extends AKChart<Coordinate[]> {
@property()
applicationSlug!: string;
export class ApplicationAuthorizeChart extends EventChart {
@property({ attribute: "application-id" })
applicationId!: string;
async apiRequest(): Promise<Coordinate[]> {
return new CoreApi(DEFAULT_CONFIG).coreApplicationsMetricsList({
slug: this.applicationSlug,
async apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
action: EventActions.AuthorizeApplication,
contextAuthorizedApp: this.applicationId.replaceAll("-", ""),
});
}
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
const valueStamp = ticks[index];
const delta = Date.now() - valueStamp.value;
const ago = Math.round(delta / 1000 / 3600 / 24);
return msg(str`${ago} days ago`);
}
getChartData(data: Coordinate[]): ChartData {
return {
datasets: [
{
label: msg("Authorizations"),
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true,
data:
data.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
],
};
getChartData(data: EventVolume[]): ChartData {
return this.eventVolume(data, {
optsMap: new Map([
[
EventActions.AuthorizeApplication,
{
label: msg("Authorizations"),
spanGaps: true,
},
],
]),
padToDays: 7,
});
}
}

View File

@ -282,7 +282,7 @@ export class ApplicationViewPage extends AKElement {
<div class="pf-c-card__body">
${this.application &&
html` <ak-charts-application-authorize
applicationSlug=${this.application.slug}
application-id=${this.application.pk}
>
</ak-charts-application-authorize>`}
</div>

View File

@ -1,21 +1,21 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData } from "chart.js";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import { Coordinate, EventsApi, EventsEventsListRequest } from "@goauthentik/api";
import { EventVolume, EventsApi, EventsEventsListRequest } from "@goauthentik/api";
@customElement("ak-events-volume-chart")
export class EventVolumeChart extends AKChart<Coordinate[]> {
export class EventVolumeChart extends EventChart {
_query?: EventsEventsListRequest;
@property({ attribute: false })
set query(value: EventsEventsListRequest | undefined) {
if (JSON.stringify(this._query) === JSON.stringify(value)) return;
this._query = value;
this.refreshHandler();
}
@ -24,39 +24,28 @@ export class EventVolumeChart extends AKChart<Coordinate[]> {
return super.styles.concat(
PFCard,
css`
.pf-c-card__body {
height: 12rem;
.pf-c-card {
height: 20rem;
}
`,
);
}
apiRequest(): Promise<Coordinate[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList(this._query);
apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
historyDays: 7,
...this._query,
});
}
getChartData(data: Coordinate[]): ChartData {
return {
datasets: [
{
label: msg("Events"),
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true,
data:
data.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
],
};
getChartData(data: EventVolume[]): ChartData {
return this.eventVolume(data, {
padToDays: 7,
});
}
render(): TemplateResult {
return html`<div class="pf-c-card">
<div class="pf-c-card__title">${msg("Event volume")}</div>
<div class="pf-c-card__body">${super.render()}</div>
</div>`;
}

View File

@ -1,71 +1,55 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData, Tick } from "chart.js";
import { ChartData } from "chart.js";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { customElement, property } from "lit/decorators.js";
import { CoreApi, UserMetrics } from "@goauthentik/api";
import { EventActions, EventVolume, EventsApi } from "@goauthentik/api";
@customElement("ak-charts-user")
export class UserChart extends AKChart<UserMetrics> {
@property({ type: Number })
userId?: number;
export class UserChart extends EventChart {
@property()
username?: string;
async apiRequest(): Promise<UserMetrics> {
return new CoreApi(DEFAULT_CONFIG).coreUsersMetricsRetrieve({
id: this.userId || 0,
async apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
actions: [
EventActions.Login,
EventActions.LoginFailed,
EventActions.AuthorizeApplication,
],
username: this.username,
});
}
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
const valueStamp = ticks[index];
const delta = Date.now() - valueStamp.value;
const ago = Math.round(delta / 1000 / 3600 / 24);
return msg(str`${ago} days ago`);
}
getChartData(data: UserMetrics): ChartData {
return {
datasets: [
{
label: msg("Failed Logins"),
backgroundColor: "rgba(201, 25, 11, .5)",
spanGaps: true,
data:
data.loginsFailed?.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
{
label: msg("Successful Logins"),
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true,
data:
data.logins?.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
{
label: msg("Application authorizations"),
backgroundColor: "rgba(43, 154, 243, .5)",
spanGaps: true,
data:
data.authorizations?.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
},
],
};
getChartData(data: EventVolume[]): ChartData {
return this.eventVolume(data, {
optsMap: new Map([
[
EventActions.LoginFailed,
{
label: msg("Failed Logins"),
spanGaps: true,
},
],
[
EventActions.Login,
{
label: msg("Successful Logins"),
spanGaps: true,
},
],
[
EventActions.AuthorizeApplication,
{
label: msg("Application authorizations"),
spanGaps: true,
},
],
]),
padToDays: 7,
});
}
}

View File

@ -389,7 +389,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
${msg("Actions over the last week (per 8 hours)")}
</div>
<div class="pf-c-card__body">
<ak-charts-user userId=${this.user.pk || 0}> </ak-charts-user>
<ak-charts-user username=${this.user.username}> </ak-charts-user>
</div>
</div>
<div

View File

@ -21,7 +21,7 @@ import {
import { Legend, Tooltip } from "chart.js";
import { BarController, DoughnutController, LineController } from "chart.js";
import { ArcElement, BarElement } from "chart.js";
import { LinearScale, TimeScale } from "chart.js";
import { LinearScale, TimeScale, TimeSeriesScale } from "chart.js";
import "chartjs-adapter-date-fns";
import { msg } from "@lit/localize";
@ -33,37 +33,11 @@ import { UiThemeEnum } from "@goauthentik/api";
Chart.register(Legend, Tooltip);
Chart.register(LineController, BarController, DoughnutController);
Chart.register(ArcElement, BarElement, PointElement, LineElement);
Chart.register(TimeScale, LinearScale, Filler);
Chart.register(TimeScale, TimeSeriesScale, LinearScale, Filler);
export const FONT_COLOUR_DARK_MODE = "#fafafa";
export const FONT_COLOUR_LIGHT_MODE = "#151515";
export class RGBAColor {
constructor(
public r: number,
public g: number,
public b: number,
public a: number = 1,
) {}
toString(): string {
return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`;
}
}
export function getColorFromString(stringInput: string): RGBAColor {
let hash = 0;
for (let i = 0; i < stringInput.length; i++) {
hash = stringInput.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
const rgb = [0, 0, 0];
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 255;
rgb[i] = value;
}
return new RGBAColor(rgb[0], rgb[1], rgb[2]);
}
export abstract class AKChart<T> extends AKElement {
abstract apiRequest(): Promise<T>;
abstract getChartData(data: T): ChartData;
@ -184,7 +158,7 @@ export abstract class AKChart<T> extends AKElement {
responsive: true,
scales: {
x: {
type: "time",
type: "timeseries",
display: true,
ticks: {
callback: (tickValue: string | number, index: number, ticks: Tick[]) => {

View File

@ -0,0 +1,120 @@
import { actionToLabel } from "#common/labels";
import { AKChart } from "#elements/charts/Chart";
import { ChartData, ChartDataset } from "chart.js";
import { EventActions, EventVolume } from "@goauthentik/api";
export function actionToColor(action: EventActions): string {
switch (action) {
case EventActions.AuthorizeApplication:
return "#0060c0";
case EventActions.ConfigurationError:
return "#23511e";
case EventActions.EmailSent:
return "#009596";
case EventActions.FlowExecution:
return "#f4c145";
case EventActions.ImpersonationEnded:
return "#a2d9d9";
case EventActions.ImpersonationStarted:
return "#a2d9d9";
case EventActions.InvitationUsed:
return "#8bc1f7";
case EventActions.Login:
return "#4cb140";
case EventActions.LoginFailed:
return "#ec7a08";
case EventActions.Logout:
return "#f9e0a2";
case EventActions.ModelCreated:
return "#8f4700";
case EventActions.ModelDeleted:
return "#002f5d";
case EventActions.ModelUpdated:
return "#bde2b9";
case EventActions.PasswordSet:
return "#003737";
case EventActions.PolicyException:
return "#c58c00";
case EventActions.PolicyExecution:
return "#f4b678";
case EventActions.PropertyMappingException:
return "#519de9";
case EventActions.SecretRotate:
return "#38812f";
case EventActions.SecretView:
return "#73c5c5";
case EventActions.SourceLinked:
return "#f6d173";
case EventActions.SuspiciousRequest:
return "#c46100";
case EventActions.SystemException:
return "#004b95";
case EventActions.SystemTaskException:
return "#7cc674";
case EventActions.SystemTaskExecution:
return "#005f60";
case EventActions.UpdateAvailable:
return "#f0ab00";
case EventActions.UserWrite:
return "#ef9234";
}
return "";
}
export abstract class EventChart extends AKChart<EventVolume[]> {
eventVolume(
data: EventVolume[],
options?: {
optsMap?: Map<EventActions, Partial<ChartDataset>>;
padToDays?: number;
},
): ChartData {
const datasets: ChartData = {
datasets: [],
};
if (!options) {
options = {};
}
if (!options.optsMap) {
options.optsMap = new Map<EventActions, Partial<ChartDataset>>();
}
const actions = new Set(data.map((v) => v.action));
actions.forEach((action) => {
const actionData: { x: number; y: number }[] = [];
data.filter((v) => v.action === action).forEach((v) => {
actionData.push({
x: v.time.getTime(),
y: v.count,
});
});
// Check if we need to pad the data to reach a certain time window
const earliestDate = data
.filter((v) => v.action === action)
.map((v) => v.time)
.sort((a, b) => b.getTime() - a.getTime())
.reverse();
if (earliestDate.length > 0 && options.padToDays) {
const earliestPadded = new Date(
new Date().getTime() - options.padToDays * (1000 * 3600 * 24),
);
const daysDelta = Math.round(
(earliestDate[0].getTime() - earliestPadded.getTime()) / (1000 * 3600 * 24),
);
if (daysDelta > 0) {
actionData.push({
x: earliestPadded.getTime(),
y: 0,
});
}
}
datasets.datasets.push({
data: actionData,
label: actionToLabel(action),
backgroundColor: actionToColor(action),
...options.optsMap?.get(action),
});
});
return datasets;
}
}