events: rework metrics (#4407)

* rework metrics

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

* change graphs to be over last week

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

* fix  Apps with most usage card

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

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-01-11 12:21:07 +01:00 committed by GitHub
parent a35b8f5862
commit 67a6fa6399
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 143 additions and 107 deletions

View File

@ -1,4 +1,7 @@
"""authentik administration metrics""" """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 drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import IntegerField, SerializerMethodField from rest_framework.fields import IntegerField, SerializerMethodField
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
class LoginMetricsSerializer(PassiveSerializer): class LoginMetricsSerializer(PassiveSerializer):
"""Login Metrics per 1h""" """Login Metrics per 1h"""
logins_per_1h = SerializerMethodField() logins = SerializerMethodField()
logins_failed_per_1h = SerializerMethodField() logins_failed = SerializerMethodField()
authorizations_per_1h = SerializerMethodField() authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_per_1h(self, _): def get_logins(self, _):
"""Get successful logins per hour for the last 24 hours""" """Get successful logins per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.LOGIN) action=EventAction.LOGIN
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.LOGIN_FAILED) action=EventAction.LOGIN_FAILED
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _): def get_authorizations(self, _):
"""Get successful authorizations per hour for the last 24 hours""" """Get successful authorizations per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.AUTHORIZE_APPLICATION) action=EventAction.AUTHORIZE_APPLICATION
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )

View File

@ -1,8 +1,10 @@
"""Application API Views""" """Application API Views"""
from datetime import timedelta
from typing import Optional from typing import Optional
from django.core.cache import cache from django.core.cache import cache
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.models.functions import ExtractDay
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -259,10 +261,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Metrics for application logins""" """Metrics for application logins"""
app = self.get_object() app = self.get_object()
return Response( return Response(
get_objects_for_user(request.user, "authentik_events.view_event") get_objects_for_user(request.user, "authentik_events.view_event").filter(
.filter(
action=EventAction.AUTHORIZE_APPLICATION, action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex, 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)
) )

View File

@ -4,6 +4,7 @@ from json import loads
from typing import Any, Optional from typing import Any, Optional
from django.contrib.auth import update_session_auth_hash 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.models.query import QuerySet
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
class UserMetricsSerializer(PassiveSerializer): class UserMetricsSerializer(PassiveSerializer):
"""User Metrics""" """User Metrics"""
logins_per_1h = SerializerMethodField() logins = SerializerMethodField()
logins_failed_per_1h = SerializerMethodField() logins_failed = SerializerMethodField()
authorizations_per_1h = SerializerMethodField() authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_per_1h(self, _): def get_logins(self, _):
"""Get successful logins per hour for the last 24 hours""" """Get successful logins per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.LOGIN, user__pk=user.pk) action=EventAction.LOGIN, user__pk=user.pk
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username) action=EventAction.LOGIN_FAILED, context__username=user.username
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _): def get_authorizations(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"] user = self.context["user"]
return ( return (
get_objects_for_user(user, "authentik_events.view_event") get_objects_for_user(user, "authentik_events.view_event").filter(
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk) action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
.get_events_per_hour() )
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
) )

View File

@ -1,9 +1,11 @@
"""Events API Views""" """Events API Views"""
from datetime import timedelta
from json import loads from json import loads
import django_filters import django_filters
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import ExtractDay
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user 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") get_objects_for_user(request.user, "authentik_events.view_event")
.filter(action=filtered_action) .filter(action=filtered_action)
.filter(**query) .filter(**query)
.get_events_per_day() .get_events_per(timedelta(weeks=4), ExtractDay, 30)
) )
@extend_schema(responses={200: TypeCreateSerializer(many=True)}) @extend_schema(responses={200: TypeCreateSerializer(many=True)})

View File

@ -11,8 +11,7 @@ from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Count, ExpressionWrapper, F from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour from django.db.models.functions import Extract
from django.db.models.functions.datetime import ExtractDay
from django.db.models.manager import Manager from django.db.models.manager import Manager
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
@ -111,48 +110,36 @@ class EventAction(models.TextChoices):
class EventQuerySet(QuerySet): class EventQuerySet(QuerySet):
"""Custom events query set with helper functions""" """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""" """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 = ( result = (
self.filter(created__gte=date_from) self.filter(created__gte=date_from)
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField())) .annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
.annotate(age_hours=ExtractHour("age")) .annotate(age_interval=extract("age"))
.values("age_hours") .values("age_interval")
.annotate(count=Count("pk")) .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 = [] results = []
_now = now() interval_timdelta = time_since / data_points
for hour in range(0, -24, -1): for interval in range(1, -data_points, -1):
results.append( results.append(
{ {
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000, "x_cord": time.mktime((_now + (interval_timdelta * interval)).timetuple())
"y_cord": data[hour * -1], * 1000,
} "y_cord": data[interval * -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],
} }
) )
return results return results
@ -165,13 +152,14 @@ class EventManager(Manager):
"""use custom queryset""" """use custom queryset"""
return EventQuerySet(self.model, using=self._db) 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""" """Wrap method from queryset"""
return self.get_queryset().get_events_per_hour() return self.get_queryset().get_events_per(time_since, extract, data_points)
def get_events_per_day(self) -> list[dict[str, int]]:
"""Wrap method from queryset"""
return self.get_queryset().get_events_per_day()
class Event(SerializerModel, ExpiringModel): class Event(SerializerModel, ExpiringModel):

View File

@ -28957,25 +28957,25 @@ components:
type: object type: object
description: Login Metrics per 1h description: Login Metrics per 1h
properties: properties:
logins_per_1h: logins:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
logins_failed_per_1h: logins_failed:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
authorizations_per_1h: authorizations:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
required: required:
- authorizations_per_1h - authorizations
- logins_failed_per_1h - logins
- logins_per_1h - logins_failed
LoginSource: LoginSource:
type: object type: object
description: Serializer for Login buttons of sources description: Serializer for Login buttons of sources
@ -37741,25 +37741,25 @@ components:
type: object type: object
description: User Metrics description: User Metrics
properties: properties:
logins_per_1h: logins:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
logins_failed_per_1h: logins_failed:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
authorizations_per_1h: authorizations:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/Coordinate'
readOnly: true readOnly: true
required: required:
- authorizations_per_1h - authorizations
- logins_failed_per_1h - logins
- logins_per_1h - logins_failed
UserOAuthSourceConnection: UserOAuthSourceConnection:
type: object type: object
description: OAuth Source Serializer description: OAuth Source Serializer

View File

@ -180,7 +180,7 @@ export class AdminOverviewPage extends AKElement {
> >
<ak-aggregate-card <ak-aggregate-card
icon="pf-icon pf-icon-server" icon="pf-icon pf-icon-server"
header=${t`Logins and authorizations over the last 24 hours`} header=${t`Logins and authorizations over the last week (per 8 hours)`}
> >
<ak-charts-admin-login-authorization></ak-charts-admin-login-authorization> <ak-charts-admin-login-authorization></ak-charts-admin-login-authorization>
</ak-aggregate-card> </ak-aggregate-card>

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart"; import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart";
import { ChartData } from "chart.js"; import { ChartData, Tick } from "chart.js";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
@ -14,6 +14,13 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve(); 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 { getChartData(data: LoginMetrics): ChartData {
return { return {
datasets: [ datasets: [
@ -25,7 +32,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
fill: "origin", fill: "origin",
cubicInterpolationMode: "monotone", cubicInterpolationMode: "monotone",
tension: 0.4, tension: 0.4,
data: data.authorizationsPer1h.map((cord) => { data: data.authorizations.map((cord) => {
return { return {
x: cord.xCord, x: cord.xCord,
y: cord.yCord, y: cord.yCord,
@ -40,7 +47,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
fill: "origin", fill: "origin",
cubicInterpolationMode: "monotone", cubicInterpolationMode: "monotone",
tension: 0.4, tension: 0.4,
data: data.loginsFailedPer1h.map((cord) => { data: data.loginsFailed.map((cord) => {
return { return {
x: cord.xCord, x: cord.xCord,
y: cord.yCord, y: cord.yCord,
@ -55,7 +62,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
fill: "origin", fill: "origin",
cubicInterpolationMode: "monotone", cubicInterpolationMode: "monotone",
tension: 0.4, tension: 0.4,
data: data.loginsPer1h.map((cord) => { data: data.logins.map((cord) => {
return { return {
x: cord.xCord, x: cord.xCord,
y: cord.yCord, y: cord.yCord,

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart"; import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData } from "chart.js"; import { ChartData, Tick } from "chart.js";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
@ -19,6 +19,13 @@ export class ApplicationAuthorizeChart extends AKChart<Coordinate[]> {
}); });
} }
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 { getChartData(data: Coordinate[]): ChartData {
return { return {
datasets: [ datasets: [

View File

@ -225,7 +225,9 @@ export class ApplicationViewPage extends AKElement {
<div <div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl" class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl"
> >
<div class="pf-c-card__title">${t`Logins over the last 24 hours`}</div> <div class="pf-c-card__title">
${t`Logins over the last week (per 8 hours)`}
</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
${this.application && ${this.application &&
html` <ak-charts-application-authorize html` <ak-charts-application-authorize

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart } from "@goauthentik/elements/charts/Chart"; import { AKChart } from "@goauthentik/elements/charts/Chart";
import { ChartData } from "chart.js"; import { ChartData, Tick } from "chart.js";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
@ -19,6 +19,13 @@ export class UserChart extends AKChart<UserMetrics> {
}); });
} }
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 { getChartData(data: UserMetrics): ChartData {
return { return {
datasets: [ datasets: [
@ -27,7 +34,7 @@ export class UserChart extends AKChart<UserMetrics> {
backgroundColor: "rgba(201, 25, 11, .5)", backgroundColor: "rgba(201, 25, 11, .5)",
spanGaps: true, spanGaps: true,
data: data:
data.loginsFailedPer1h?.map((cord) => { data.loginsFailed?.map((cord) => {
return { return {
x: cord.xCord || 0, x: cord.xCord || 0,
y: cord.yCord || 0, y: cord.yCord || 0,
@ -39,7 +46,7 @@ export class UserChart extends AKChart<UserMetrics> {
backgroundColor: "rgba(189, 229, 184, .5)", backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true, spanGaps: true,
data: data:
data.loginsPer1h?.map((cord) => { data.logins?.map((cord) => {
return { return {
x: cord.xCord || 0, x: cord.xCord || 0,
y: cord.yCord || 0, y: cord.yCord || 0,
@ -51,7 +58,7 @@ export class UserChart extends AKChart<UserMetrics> {
backgroundColor: "rgba(43, 154, 243, .5)", backgroundColor: "rgba(43, 154, 243, .5)",
spanGaps: true, spanGaps: true,
data: data:
data.authorizationsPer1h?.map((cord) => { data.authorizations?.map((cord) => {
return { return {
x: cord.xCord || 0, x: cord.xCord || 0,
y: cord.yCord || 0, y: cord.yCord || 0,

View File

@ -276,7 +276,9 @@ export class UserViewPage extends AKElement {
<div <div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-9-col-on-xl pf-m-9-col-on-2xl" class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-9-col-on-xl pf-m-9-col-on-2xl"
> >
<div class="pf-c-card__title">${t`Actions over the last 24 hours`}</div> <div class="pf-c-card__title">
${t`Actions over the last week (per 8 hours)`}
</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
<ak-charts-user userId=${this.user.pk || 0}> </ak-charts-user> <ak-charts-user userId=${this.user.pk || 0}> </ak-charts-user>
</div> </div>

View File

@ -40,6 +40,9 @@ export class AggregateCard extends AKElement {
.subtext { .subtext {
font-size: var(--pf-global--FontSize--sm); font-size: var(--pf-global--FontSize--sm);
} }
.pf-c-card__body {
overflow-x: scroll;
}
`, `,
]); ]);
} }

View File

@ -168,7 +168,7 @@ export abstract class AKChart<T> extends AKElement {
const valueStamp = ticks[index]; const valueStamp = ticks[index];
const delta = Date.now() - valueStamp.value; const delta = Date.now() - valueStamp.value;
const ago = Math.round(delta / 1000 / 3600); const ago = Math.round(delta / 1000 / 3600);
return t`${ago} hours ago`; return t`${ago} hour(s) ago`;
} }
getOptions(): ChartOptions { getOptions(): ChartOptions {