From 67a6fa6399a3555fa4bb614f50a4362553b8e2c7 Mon Sep 17 00:00:00 2001 From: Jens L Date: Wed, 11 Jan 2023 12:21:07 +0100 Subject: [PATCH] events: rework metrics (#4407) * rework metrics Signed-off-by: Jens Langhammer * change graphs to be over last week Signed-off-by: Jens Langhammer * fix Apps with most usage card Signed-off-by: Jens Langhammer Signed-off-by: Jens Langhammer --- authentik/admin/api/metrics.py | 45 +++++++----- authentik/core/api/applications.py | 8 ++- authentik/core/api/users.py | 43 ++++++----- authentik/events/api/events.py | 4 +- authentik/events/models.py | 72 ++++++++----------- schema.yml | 24 +++---- .../admin/admin-overview/AdminOverviewPage.ts | 2 +- .../charts/AdminLoginAuthorizeChart.ts | 15 ++-- .../applications/ApplicationAuthorizeChart.ts | 9 ++- .../admin/applications/ApplicationViewPage.ts | 4 +- web/src/admin/users/UserChart.ts | 15 ++-- web/src/admin/users/UserViewPage.ts | 4 +- web/src/elements/cards/AggregateCard.ts | 3 + web/src/elements/charts/Chart.ts | 2 +- 14 files changed, 143 insertions(+), 107 deletions(-) diff --git a/authentik/admin/api/metrics.py b/authentik/admin/api/metrics.py index bf64e9a80..78bdbf36d 100644 --- a/authentik/admin/api/metrics.py +++ b/authentik/admin/api/metrics.py @@ -1,4 +1,7 @@ """authentik administration metrics""" +from datetime import timedelta + +from django.db.models.functions import ExtractDay from drf_spectacular.utils import extend_schema, extend_schema_field from guardian.shortcuts import get_objects_for_user from rest_framework.fields import IntegerField, SerializerMethodField @@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer): class LoginMetricsSerializer(PassiveSerializer): """Login Metrics per 1h""" - logins_per_1h = SerializerMethodField() - logins_failed_per_1h = SerializerMethodField() - authorizations_per_1h = SerializerMethodField() + logins = SerializerMethodField() + logins_failed = SerializerMethodField() + authorizations = SerializerMethodField() @extend_schema_field(CoordinateSerializer(many=True)) - def get_logins_per_1h(self, _): - """Get successful logins per hour for the last 24 hours""" + def get_logins(self, _): + """Get successful logins per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.LOGIN) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.LOGIN + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) @extend_schema_field(CoordinateSerializer(many=True)) - def get_logins_failed_per_1h(self, _): - """Get failed logins per hour for the last 24 hours""" + def get_logins_failed(self, _): + """Get failed logins per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.LOGIN_FAILED) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.LOGIN_FAILED + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) @extend_schema_field(CoordinateSerializer(many=True)) - def get_authorizations_per_1h(self, _): - """Get successful authorizations per hour for the last 24 hours""" + def get_authorizations(self, _): + """Get successful authorizations per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.AUTHORIZE_APPLICATION) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.AUTHORIZE_APPLICATION + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 7cc8dfbdf..0243c438e 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -1,8 +1,10 @@ """Application API Views""" +from datetime import timedelta from typing import Optional from django.core.cache import cache from django.db.models import QuerySet +from django.db.models.functions import ExtractDay from django.http.response import HttpResponseBadRequest from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -259,10 +261,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): """Metrics for application logins""" app = self.get_object() return Response( - get_objects_for_user(request.user, "authentik_events.view_event") - .filter( + get_objects_for_user(request.user, "authentik_events.view_event").filter( action=EventAction.AUTHORIZE_APPLICATION, context__authorized_application__pk=app.pk.hex, ) - .get_events_per_hour() + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index a46706b41..6f00d871b 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -4,6 +4,7 @@ from json import loads from typing import Any, Optional from django.contrib.auth import update_session_auth_hash +from django.db.models.functions import ExtractDay from django.db.models.query import QuerySet from django.db.transaction import atomic from django.db.utils import IntegrityError @@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer): class UserMetricsSerializer(PassiveSerializer): """User Metrics""" - logins_per_1h = SerializerMethodField() - logins_failed_per_1h = SerializerMethodField() - authorizations_per_1h = SerializerMethodField() + logins = SerializerMethodField() + logins_failed = SerializerMethodField() + authorizations = SerializerMethodField() @extend_schema_field(CoordinateSerializer(many=True)) - def get_logins_per_1h(self, _): - """Get successful logins per hour for the last 24 hours""" + def get_logins(self, _): + """Get successful logins per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.LOGIN, user__pk=user.pk) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.LOGIN, user__pk=user.pk + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) @extend_schema_field(CoordinateSerializer(many=True)) - def get_logins_failed_per_1h(self, _): - """Get failed logins per hour for the last 24 hours""" + def get_logins_failed(self, _): + """Get failed logins per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.LOGIN_FAILED, context__username=user.username) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.LOGIN_FAILED, context__username=user.username + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) @extend_schema_field(CoordinateSerializer(many=True)) - def get_authorizations_per_1h(self, _): - """Get failed logins per hour for the last 24 hours""" + def get_authorizations(self, _): + """Get failed logins per 8 hours for the last 7 days""" user = self.context["user"] return ( - get_objects_for_user(user, "authentik_events.view_event") - .filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk) - .get_events_per_hour() + get_objects_for_user(user, "authentik_events.view_event").filter( + action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk + ) + # 3 data points per day, so 8 hour spans + .get_events_per(timedelta(days=7), ExtractDay, 7 * 3) ) diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index 836789604..9bf28040c 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -1,9 +1,11 @@ """Events API Views""" +from datetime import timedelta from json import loads import django_filters from django.db.models.aggregates import Count from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import ExtractDay from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from guardian.shortcuts import get_objects_for_user @@ -178,7 +180,7 @@ class EventViewSet(ModelViewSet): get_objects_for_user(request.user, "authentik_events.view_event") .filter(action=filtered_action) .filter(**query) - .get_events_per_day() + .get_events_per(timedelta(weeks=4), ExtractDay, 30) ) @extend_schema(responses={200: TypeCreateSerializer(many=True)}) diff --git a/authentik/events/models.py b/authentik/events/models.py index 60e5944c7..c0d9b43c6 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -11,8 +11,7 @@ from django.conf import settings from django.db import models from django.db.models import Count, ExpressionWrapper, F from django.db.models.fields import DurationField -from django.db.models.functions import ExtractHour -from django.db.models.functions.datetime import ExtractDay +from django.db.models.functions import Extract from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.http import HttpRequest @@ -111,48 +110,36 @@ class EventAction(models.TextChoices): class EventQuerySet(QuerySet): """Custom events query set with helper functions""" - def get_events_per_hour(self) -> list[dict[str, int]]: + def get_events_per( + self, + time_since: timedelta, + extract: Extract, + data_points: int, + ) -> list[dict[str, int]]: """Get event count by hour in the last day, fill with zeros""" - date_from = now() - timedelta(days=1) + _now = now() + max_since = timedelta(days=60) + # Allow maximum of 60 days to limit load + if time_since.total_seconds() > max_since.total_seconds(): + time_since = max_since + date_from = _now - time_since result = ( self.filter(created__gte=date_from) - .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) - .annotate(age_hours=ExtractHour("age")) - .values("age_hours") + .annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField())) + .annotate(age_interval=extract("age")) + .values("age_interval") .annotate(count=Count("pk")) - .order_by("age_hours") + .order_by("age_interval") ) - data = Counter({int(d["age_hours"]): d["count"] for d in result}) + data = Counter({int(d["age_interval"]): d["count"] for d in result}) results = [] - _now = now() - for hour in range(0, -24, -1): + interval_timdelta = time_since / data_points + for interval in range(1, -data_points, -1): results.append( { - "x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, - "y_cord": data[hour * -1], - } - ) - return results - - def get_events_per_day(self) -> list[dict[str, int]]: - """Get event count by hour in the last day, fill with zeros""" - date_from = now() - timedelta(weeks=4) - result = ( - self.filter(created__gte=date_from) - .annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) - .annotate(age_days=ExtractDay("age")) - .values("age_days") - .annotate(count=Count("pk")) - .order_by("age_days") - ) - data = Counter({int(d["age_days"]): d["count"] for d in result}) - results = [] - _now = now() - for day in range(0, -30, -1): - results.append( - { - "x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000, - "y_cord": data[day * -1], + "x_cord": time.mktime((_now + (interval_timdelta * interval)).timetuple()) + * 1000, + "y_cord": data[interval * -1], } ) return results @@ -165,13 +152,14 @@ class EventManager(Manager): """use custom queryset""" return EventQuerySet(self.model, using=self._db) - def get_events_per_hour(self) -> list[dict[str, int]]: + def get_events_per( + self, + time_since: timedelta, + extract: Extract, + data_points: int, + ) -> list[dict[str, int]]: """Wrap method from queryset""" - return self.get_queryset().get_events_per_hour() - - def get_events_per_day(self) -> list[dict[str, int]]: - """Wrap method from queryset""" - return self.get_queryset().get_events_per_day() + return self.get_queryset().get_events_per(time_since, extract, data_points) class Event(SerializerModel, ExpiringModel): diff --git a/schema.yml b/schema.yml index d77bceaf3..8aba83abe 100644 --- a/schema.yml +++ b/schema.yml @@ -28957,25 +28957,25 @@ components: type: object description: Login Metrics per 1h properties: - logins_per_1h: + logins: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true - logins_failed_per_1h: + logins_failed: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true - authorizations_per_1h: + authorizations: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true required: - - authorizations_per_1h - - logins_failed_per_1h - - logins_per_1h + - authorizations + - logins + - logins_failed LoginSource: type: object description: Serializer for Login buttons of sources @@ -37741,25 +37741,25 @@ components: type: object description: User Metrics properties: - logins_per_1h: + logins: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true - logins_failed_per_1h: + logins_failed: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true - authorizations_per_1h: + authorizations: type: array items: $ref: '#/components/schemas/Coordinate' readOnly: true required: - - authorizations_per_1h - - logins_failed_per_1h - - logins_per_1h + - authorizations + - logins + - logins_failed UserOAuthSourceConnection: type: object description: OAuth Source Serializer diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index 7c12b20d5..933ac9b7d 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -180,7 +180,7 @@ export class AdminOverviewPage extends AKElement { > diff --git a/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts b/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts index 7df331ff0..1afbca943 100644 --- a/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts +++ b/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart"; -import { ChartData } from "chart.js"; +import { ChartData, Tick } from "chart.js"; import { t } from "@lingui/macro"; @@ -14,6 +14,13 @@ export class AdminLoginAuthorizeChart extends AKChart { return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve(); } + 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 t`${ago} day(s) ago`; + } + getChartData(data: LoginMetrics): ChartData { return { datasets: [ @@ -25,7 +32,7 @@ export class AdminLoginAuthorizeChart extends AKChart { fill: "origin", cubicInterpolationMode: "monotone", tension: 0.4, - data: data.authorizationsPer1h.map((cord) => { + data: data.authorizations.map((cord) => { return { x: cord.xCord, y: cord.yCord, @@ -40,7 +47,7 @@ export class AdminLoginAuthorizeChart extends AKChart { fill: "origin", cubicInterpolationMode: "monotone", tension: 0.4, - data: data.loginsFailedPer1h.map((cord) => { + data: data.loginsFailed.map((cord) => { return { x: cord.xCord, y: cord.yCord, @@ -55,7 +62,7 @@ export class AdminLoginAuthorizeChart extends AKChart { fill: "origin", cubicInterpolationMode: "monotone", tension: 0.4, - data: data.loginsPer1h.map((cord) => { + data: data.logins.map((cord) => { return { x: cord.xCord, y: cord.yCord, diff --git a/web/src/admin/applications/ApplicationAuthorizeChart.ts b/web/src/admin/applications/ApplicationAuthorizeChart.ts index b502a5788..7671f0539 100644 --- a/web/src/admin/applications/ApplicationAuthorizeChart.ts +++ b/web/src/admin/applications/ApplicationAuthorizeChart.ts @@ -1,6 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; -import { ChartData } from "chart.js"; +import { ChartData, Tick } from "chart.js"; import { t } from "@lingui/macro"; @@ -19,6 +19,13 @@ export class ApplicationAuthorizeChart extends AKChart { }); } + 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 t`${ago} days ago`; + } + getChartData(data: Coordinate[]): ChartData { return { datasets: [ diff --git a/web/src/admin/applications/ApplicationViewPage.ts b/web/src/admin/applications/ApplicationViewPage.ts index 1346d4702..a6bad43ed 100644 --- a/web/src/admin/applications/ApplicationViewPage.ts +++ b/web/src/admin/applications/ApplicationViewPage.ts @@ -225,7 +225,9 @@ export class ApplicationViewPage extends AKElement {
-
${t`Logins over the last 24 hours`}
+
+ ${t`Logins over the last week (per 8 hours)`} +
${this.application && html` { }); } + 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 t`${ago} days ago`; + } + getChartData(data: UserMetrics): ChartData { return { datasets: [ @@ -27,7 +34,7 @@ export class UserChart extends AKChart { backgroundColor: "rgba(201, 25, 11, .5)", spanGaps: true, data: - data.loginsFailedPer1h?.map((cord) => { + data.loginsFailed?.map((cord) => { return { x: cord.xCord || 0, y: cord.yCord || 0, @@ -39,7 +46,7 @@ export class UserChart extends AKChart { backgroundColor: "rgba(189, 229, 184, .5)", spanGaps: true, data: - data.loginsPer1h?.map((cord) => { + data.logins?.map((cord) => { return { x: cord.xCord || 0, y: cord.yCord || 0, @@ -51,7 +58,7 @@ export class UserChart extends AKChart { backgroundColor: "rgba(43, 154, 243, .5)", spanGaps: true, data: - data.authorizationsPer1h?.map((cord) => { + data.authorizations?.map((cord) => { return { x: cord.xCord || 0, y: cord.yCord || 0, diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index e8f9d0f60..147119585 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -276,7 +276,9 @@ export class UserViewPage extends AKElement {
-
${t`Actions over the last 24 hours`}
+
+ ${t`Actions over the last week (per 8 hours)`} +
diff --git a/web/src/elements/cards/AggregateCard.ts b/web/src/elements/cards/AggregateCard.ts index 36c5883e4..73f8cddf1 100644 --- a/web/src/elements/cards/AggregateCard.ts +++ b/web/src/elements/cards/AggregateCard.ts @@ -40,6 +40,9 @@ export class AggregateCard extends AKElement { .subtext { font-size: var(--pf-global--FontSize--sm); } + .pf-c-card__body { + overflow-x: scroll; + } `, ]); } diff --git a/web/src/elements/charts/Chart.ts b/web/src/elements/charts/Chart.ts index 4f2884fa0..dae29fa70 100644 --- a/web/src/elements/charts/Chart.ts +++ b/web/src/elements/charts/Chart.ts @@ -168,7 +168,7 @@ export abstract class AKChart extends AKElement { const valueStamp = ticks[index]; const delta = Date.now() - valueStamp.value; const ago = Math.round(delta / 1000 / 3600); - return t`${ago} hours ago`; + return t`${ago} hour(s) ago`; } getOptions(): ChartOptions {