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

@ -1,79 +0,0 @@
"""authentik administration metrics"""
from datetime import timedelta
from django.db.models.functions import ExtractHour
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
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import EventAction
class CoordinateSerializer(PassiveSerializer):
"""Coordinates for diagrams"""
x_cord = IntegerField(read_only=True)
y_cord = IntegerField(read_only=True)
class LoginMetricsSerializer(PassiveSerializer):
"""Login Metrics per 1h"""
logins = SerializerMethodField()
logins_failed = SerializerMethodField()
authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
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
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
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
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
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
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
class AdministrationMetricsViewSet(APIView):
"""Login Metrics per 1h"""
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True)
serializer.context["user"] = request.user
return Response(serializer.data)

View File

@ -36,11 +36,6 @@ class TestAdminAPI(TestCase):
body = loads(response.content) body = loads(response.content)
self.assertEqual(len(body), 0) self.assertEqual(len(body), 0)
def test_metrics(self):
"""Test metrics API"""
response = self.client.get(reverse("authentik_api:admin_metrics"))
self.assertEqual(response.status_code, 200)
def test_apps(self): def test_apps(self):
"""Test apps API""" """Test apps API"""
response = self.client.get(reverse("authentik_api:apps-list")) response = self.client.get(reverse("authentik_api:apps-list"))

View File

@ -3,7 +3,6 @@
from django.urls import path from django.urls import path
from authentik.admin.api.meta import AppsViewSet, ModelViewSet from authentik.admin.api.meta import AppsViewSet, ModelViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView from authentik.admin.api.system import SystemView
from authentik.admin.api.version import VersionView from authentik.admin.api.version import VersionView
from authentik.admin.api.version_history import VersionHistoryViewSet from authentik.admin.api.version_history import VersionHistoryViewSet
@ -12,11 +11,6 @@ from authentik.admin.api.workers import WorkerView
api_urlpatterns = [ api_urlpatterns = [
("admin/apps", AppsViewSet, "apps"), ("admin/apps", AppsViewSet, "apps"),
("admin/models", ModelViewSet, "models"), ("admin/models", ModelViewSet, "models"),
path(
"admin/metrics/",
AdministrationMetricsViewSet.as_view(),
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"), path("admin/version/", VersionView.as_view(), name="admin_version"),
("admin/version/history", VersionHistoryViewSet, "version_history"), ("admin/version/history", VersionHistoryViewSet, "version_history"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"), path("admin/workers/", WorkerView.as_view(), name="admin_workers"),

View File

@ -2,11 +2,9 @@
from collections.abc import Iterator from collections.abc import Iterator
from copy import copy from copy import copy
from datetime import timedelta
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 ExtractHour
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
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
@ -20,7 +18,6 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.api.pagination import Pagination from authentik.api.pagination import Pagination
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
@ -28,7 +25,6 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.events.models import EventAction
from authentik.lib.utils.file import ( from authentik.lib.utils.file import (
FilePathSerializer, FilePathSerializer,
FileUploadSerializer, FileUploadSerializer,
@ -321,18 +317,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Set application icon (as URL)""" """Set application icon (as URL)"""
app: Application = self.get_object() app: Application = self.get_object()
return set_file_url(request, app, "meta_icon") return set_file_url(request, app, "meta_icon")
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
@extend_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[])
def metrics(self, request: Request, slug: str):
"""Metrics for application logins"""
app = self.get_object()
return Response(
get_objects_for_user(request.user, "authentik_events.view_event").filter(
action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex,
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)

View File

@ -6,7 +6,6 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -52,7 +51,6 @@ from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -317,53 +315,6 @@ class SessionUserSerializer(PassiveSerializer):
original = UserSelfSerializer(required=False) original = UserSelfSerializer(required=False)
class UserMetricsSerializer(PassiveSerializer):
"""User Metrics"""
logins = SerializerMethodField()
logins_failed = SerializerMethodField()
authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins(self, _):
"""Get successful logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.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), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed(self, _):
"""Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.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), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations(self, _):
"""Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.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), ExtractHour, 7 * 3)
)
class UsersFilter(FilterSet): class UsersFilter(FilterSet):
"""Filter for users""" """Filter for users"""
@ -607,17 +558,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
update_session_auth_hash(self.request, user) update_session_auth_hash(self.request, user)
return Response(status=204) return Response(status=204)
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
def metrics(self, request: Request, pk: int) -> Response:
"""User metrics per 1h"""
user: User = self.get_object()
serializer = UserMetricsSerializer(instance={})
serializer.context["user"] = user
serializer.context["request"] = request
return Response(serializer.data)
@permission_required("authentik_core.reset_user_password") @permission_required("authentik_core.reset_user_password")
@extend_schema( @extend_schema(
responses={ responses={

View File

@ -81,22 +81,6 @@ class TestUsersAPI(APITestCase):
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"}) response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 200)
def test_metrics_denied(self):
"""Test user's metrics (non-superuser)"""
self.client.force_login(self.user)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 403)
def test_recovery_no_flow(self): def test_recovery_no_flow(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin) self.client.force_login(self.admin)

View File

@ -1,28 +1,36 @@
"""Events API Views""" """Events API Views"""
from datetime import timedelta from datetime import timedelta
from json import loads
import django_filters import django_filters
from django.db.models.aggregates import Count from django.db.models import Count, ExpressionWrapper, F, QuerySet
from django.db.models import DateTimeField as DjangoDateTimeField
from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import ExtractDay, ExtractHour from django.db.models.functions import TruncHour
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.utils.timezone import now
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
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import DictField, IntegerField from rest_framework.fields import ChoiceField, DateTimeField, DictField, IntegerField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.core.api.object_types import TypeCreateSerializer from authentik.core.api.object_types import TypeCreateSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
class EventVolumeSerializer(PassiveSerializer):
"""Count of events of action created on day"""
action = ChoiceField(choices=EventAction.choices)
time = DateTimeField()
count = IntegerField()
class EventSerializer(ModelSerializer): class EventSerializer(ModelSerializer):
"""Event Serializer""" """Event Serializer"""
@ -53,7 +61,7 @@ class EventsFilter(django_filters.FilterSet):
"""Filter for events""" """Filter for events"""
username = django_filters.CharFilter( username = django_filters.CharFilter(
field_name="user", lookup_expr="username", label="Username" field_name="user", label="Username", method="filter_username"
) )
context_model_pk = django_filters.CharFilter( context_model_pk = django_filters.CharFilter(
field_name="context", field_name="context",
@ -78,12 +86,19 @@ class EventsFilter(django_filters.FilterSet):
field_name="action", field_name="action",
lookup_expr="icontains", lookup_expr="icontains",
) )
actions = django_filters.MultipleChoiceFilter(
field_name="action",
choices=EventAction.choices,
)
brand_name = django_filters.CharFilter( brand_name = django_filters.CharFilter(
field_name="brand", field_name="brand",
lookup_expr="name", lookup_expr="name",
label="Brand name", label="Brand name",
) )
def filter_username(self, queryset, name, value):
return queryset.filter(Q(user__username=value) | Q(context__username=value))
def filter_context_model_pk(self, queryset, name, value): def filter_context_model_pk(self, queryset, name, value):
"""Because we store the PK as UUID.hex, """Because we store the PK as UUID.hex,
we need to remove the dashes that a client may send. We can't use a we need to remove the dashes that a client may send. We can't use a
@ -156,45 +171,37 @@ class EventViewSet(ModelViewSet):
return Response(EventTopPerUserSerializer(instance=events, many=True).data) return Response(EventTopPerUserSerializer(instance=events, many=True).data)
@extend_schema( @extend_schema(
responses={200: CoordinateSerializer(many=True)}, responses={200: EventVolumeSerializer(many=True)},
)
@action(detail=False, methods=["GET"], pagination_class=None)
def volume(self, request: Request) -> Response:
"""Get event volume for specified filters and timeframe"""
queryset = self.filter_queryset(self.get_queryset())
return Response(queryset.get_events_per(timedelta(days=7), ExtractHour, 7 * 3))
@extend_schema(
responses={200: CoordinateSerializer(many=True)},
filters=[],
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
"action", "history_days",
type=OpenApiTypes.STR, type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
),
OpenApiParameter(
"query",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=False, required=False,
default=7,
), ),
], ],
) )
@action(detail=False, methods=["GET"], pagination_class=None) @action(detail=False, methods=["GET"], pagination_class=None)
def per_month(self, request: Request): def volume(self, request: Request) -> Response:
"""Get the count of events per month""" """Get event volume for specified filters and timeframe"""
filtered_action = request.query_params.get("action", EventAction.LOGIN) queryset: QuerySet[Event] = self.filter_queryset(self.get_queryset())
try: delta = timedelta(days=7)
query = loads(request.query_params.get("query", "{}")) time_delta = request.query_params.get("history_days", 7)
except ValueError: if time_delta:
return Response(status=400) delta = timedelta(days=min(int(time_delta), 60))
return Response( return Response(
get_objects_for_user(request.user, "authentik_events.view_event") queryset.filter(created__gte=now() - delta)
.filter(action=filtered_action) .annotate(hour=TruncHour("created"))
.filter(**query) .annotate(
.get_events_per(timedelta(weeks=4), ExtractDay, 30) time=ExpressionWrapper(
F("hour") - (F("hour__hour") % 6) * timedelta(hours=1),
output_field=DjangoDateTimeField(),
)
)
.values("time", "action")
.annotate(count=Count("pk"))
.order_by("time", "action")
) )
@extend_schema(responses={200: TypeCreateSerializer(many=True)}) @extend_schema(responses={200: TypeCreateSerializer(many=True)})

View File

@ -1,7 +1,5 @@
"""authentik events models""" """authentik events models"""
import time
from collections import Counter
from datetime import timedelta from datetime import timedelta
from difflib import get_close_matches from difflib import get_close_matches
from functools import lru_cache from functools import lru_cache
@ -11,11 +9,6 @@ from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.db import connection, models from django.db import connection, models
from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
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 from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.timezone import now from django.utils.timezone import now
@ -124,60 +117,6 @@ class EventAction(models.TextChoices):
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"
class EventQuerySet(QuerySet):
"""Custom events query set with helper functions"""
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"""
_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_interval=extract("age"))
.values("age_interval")
.annotate(count=Count("pk"))
.order_by("age_interval")
)
data = Counter({int(d["age_interval"]): d["count"] for d in result})
results = []
interval_delta = time_since / data_points
for interval in range(1, -data_points, -1):
results.append(
{
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
"y_cord": data[interval * -1],
}
)
return results
class EventManager(Manager):
"""Custom helper methods for Events"""
def get_queryset(self) -> QuerySet:
"""use custom queryset"""
return EventQuerySet(self.model, using=self._db)
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(time_since, extract, data_points)
class Event(SerializerModel, ExpiringModel): class Event(SerializerModel, ExpiringModel):
"""An individual Audit/Metrics/Notification/Error Event""" """An individual Audit/Metrics/Notification/Error Event"""
@ -193,8 +132,6 @@ class Event(SerializerModel, ExpiringModel):
# Shadow the expires attribute from ExpiringModel to override the default duration # Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_duration) expires = models.DateTimeField(default=default_event_duration)
objects = EventManager()
@staticmethod @staticmethod
def _get_app_from_request(request: HttpRequest) -> str: def _get_app_from_request(request: HttpRequest) -> str:
if not isinstance(request, HttpRequest): if not isinstance(request, HttpRequest):

View File

@ -1,12 +1,29 @@
"""test decorators api""" """test decorators api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework.viewsets import ModelViewSet
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_user from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.rbac.decorators import permission_required
class MVS(ModelViewSet):
queryset = Application.objects.all()
lookup_field = "slug"
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
@action(detail=True, pagination_class=None, filter_backends=[])
def test(self, request: Request, slug: str):
self.get_object()
return Response(status=200)
class TestAPIDecorators(APITestCase): class TestAPIDecorators(APITestCase):
@ -18,41 +35,33 @@ class TestAPIDecorators(APITestCase):
def test_obj_perm_denied(self): def test_obj_perm_denied(self):
"""Test object perm denied""" """Test object perm denied"""
self.client.force_login(self.user) request = get_request("", user=self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
response = self.client.get( response = MVS.as_view({"get": "test"})(request, slug=app.slug)
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_obj_perm_global(self): def test_obj_perm_global(self):
"""Test object perm successful (global)""" """Test object perm successful (global)"""
assign_perm("authentik_core.view_application", self.user) assign_perm("authentik_core.view_application", self.user)
assign_perm("authentik_events.view_event", self.user) assign_perm("authentik_events.view_event", self.user)
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
response = self.client.get( request = get_request("", user=self.user)
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) response = MVS.as_view({"get": "test"})(request, slug=app.slug)
) self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.status_code, 200)
def test_obj_perm_scoped(self): def test_obj_perm_scoped(self):
"""Test object perm successful (scoped)""" """Test object perm successful (scoped)"""
assign_perm("authentik_events.view_event", self.user) assign_perm("authentik_events.view_event", self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app) assign_perm("authentik_core.view_application", self.user, app)
self.client.force_login(self.user) request = get_request("", user=self.user)
response = self.client.get( response = MVS.as_view({"get": "test"})(request, slug=app.slug)
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_other_perm_denied(self): def test_other_perm_denied(self):
"""Test other perm denied""" """Test other perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app) assign_perm("authentik_core.view_application", self.user, app)
response = self.client.get( request = get_request("", user=self.user)
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) response = MVS.as_view({"get": "test"})(request, slug=app.slug)
)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)

View File

@ -140,6 +140,7 @@ skip = [
"**/storybook-static", "**/storybook-static",
"**/web/src/locales", "**/web/src/locales",
"**/web/xliff", "**/web/xliff",
"**/web/out",
"./web/storybook-static", "./web/storybook-static",
"./web/custom-elements.json", "./web/custom-elements.json",
"./website/build", "./website/build",

View File

@ -38,33 +38,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/admin/metrics/:
get:
operationId: admin_metrics_retrieve
description: Login Metrics per 1h
tags:
- admin
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/LoginMetrics'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/admin/models/: /admin/models/:
get: get:
operationId: admin_models_list operationId: admin_models_list
@ -4136,42 +4109,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/core/applications/{slug}/metrics/:
get:
operationId: core_applications_metrics_list
description: Metrics for application logins
parameters:
- in: path
name: slug
schema:
type: string
description: Internal application name, used in URLs.
required: true
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Coordinate'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/applications/{slug}/set_icon/: /core/applications/{slug}/set_icon/:
post: post:
operationId: core_applications_set_icon_create operationId: core_applications_set_icon_create
@ -6071,40 +6008,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/core/users/{id}/metrics/:
get:
operationId: core_users_metrics_retrieve
description: User metrics per 1h
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserMetrics'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/{id}/recovery/: /core/users/{id}/recovery/:
post: post:
operationId: core_users_recovery_create operationId: core_users_recovery_create
@ -7112,6 +7015,42 @@ paths:
name: action name: action
schema: schema:
type: string type: string
- in: query
name: actions
schema:
type: array
items:
type: string
enum:
- authorize_application
- configuration_error
- custom_
- email_sent
- flow_execution
- impersonation_ended
- impersonation_started
- invitation_used
- login
- login_failed
- logout
- model_created
- model_deleted
- model_updated
- password_set
- policy_exception
- policy_execution
- property_mapping_exception
- secret_rotate
- secret_view
- source_linked
- suspicious_request
- system_exception
- system_task_exception
- system_task_execution
- update_available
- user_write
explode: true
style: form
- in: query - in: query
name: brand_name name: brand_name
schema: schema:
@ -7398,44 +7337,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/events/events/per_month/:
get:
operationId: events_events_per_month_list
description: Get the count of events per month
parameters:
- in: query
name: action
schema:
type: string
- in: query
name: query
schema:
type: string
tags:
- events
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Coordinate'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/events/events/top_per_user/: /events/events/top_per_user/:
get: get:
operationId: events_events_top_per_user_list operationId: events_events_top_per_user_list
@ -7483,6 +7384,42 @@ paths:
name: action name: action
schema: schema:
type: string type: string
- in: query
name: actions
schema:
type: array
items:
type: string
enum:
- authorize_application
- configuration_error
- custom_
- email_sent
- flow_execution
- impersonation_ended
- impersonation_started
- invitation_used
- login
- login_failed
- logout
- model_created
- model_deleted
- model_updated
- password_set
- policy_exception
- policy_execution
- property_mapping_exception
- secret_rotate
- secret_view
- source_linked
- suspicious_request
- system_exception
- system_task_exception
- system_task_execution
- update_available
- user_write
explode: true
style: form
- in: query - in: query
name: brand_name name: brand_name
schema: schema:
@ -7512,6 +7449,11 @@ paths:
schema: schema:
type: string type: string
description: Context Model Primary Key description: Context Model Primary Key
- in: query
name: history_days
schema:
type: number
default: 7
- name: ordering - name: ordering
required: false required: false
in: query in: query
@ -7540,7 +7482,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/Coordinate' $ref: '#/components/schemas/EventVolume'
description: '' description: ''
'400': '400':
content: content:
@ -43596,19 +43538,6 @@ components:
- sidebar_left - sidebar_left
- sidebar_right - sidebar_right
type: string type: string
Coordinate:
type: object
description: Coordinates for diagrams
properties:
x_cord:
type: integer
readOnly: true
y_cord:
type: integer
readOnly: true
required:
- x_cord
- y_cord
CountryCodeEnum: CountryCodeEnum:
enum: enum:
- AF - AF
@ -44986,6 +44915,21 @@ components:
- application - application
- counted_events - counted_events
- unique_users - unique_users
EventVolume:
type: object
description: Count of events of action created on day
properties:
action:
$ref: '#/components/schemas/EventActions'
time:
type: string
format: date-time
count:
type: integer
required:
- action
- count
- time
EventsRequestedEnum: EventsRequestedEnum:
enum: enum:
- https://schemas.openid.net/secevent/caep/event-type/session-revoked - https://schemas.openid.net/secevent/caep/event-type/session-revoked
@ -48297,29 +48241,6 @@ components:
xak-flow-redirect: '#/components/schemas/RedirectChallenge' xak-flow-redirect: '#/components/schemas/RedirectChallenge'
ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge' ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge'
ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge' ak-source-plex: '#/components/schemas/PlexAuthenticationChallenge'
LoginMetrics:
type: object
description: Login Metrics per 1h
properties:
logins:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
logins_failed:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
authorizations:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
required:
- authorizations
- logins
- logins_failed
LoginSource: LoginSource:
type: object type: object
description: Serializer for Login buttons of sources description: Serializer for Login buttons of sources
@ -60729,29 +60650,6 @@ components:
- username_link - username_link
- username_deny - username_deny
type: string type: string
UserMetrics:
type: object
description: User Metrics
properties:
logins:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
logins_failed:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
authorizations:
type: array
items:
$ref: '#/components/schemas/Coordinate'
readOnly: true
required:
- authorizations
- logins
- logins_failed
UserOAuthSourceConnection: UserOAuthSourceConnection:
type: object type: object
description: User source connection description: User source connection

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 PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.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") @customElement("ak-admin-dashboard-users")
export class DashboardUserPage extends AKElement { 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-aggregate-card header=${msg("Users created per day in the last month")}>
<ak-charts-admin-model-per-day <ak-charts-admin-model-per-day
.query=${{ .query=${{
context__model__app: "authentik_core", contextModelApp: "authentik_core",
context__model__model_name: "user", contextModelName: "user",
}} } as EventsEventsVolumeListRequest}
label=${msg("Users created")} label=${msg("Users created")}
> >
</ak-charts-admin-model-per-day> </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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart"; import { ChartData, ChartDataset } from "chart.js";
import { ChartData } from "chart.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement } from "lit/decorators.js"; 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") @customElement("ak-charts-admin-login-authorization")
export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> { export class AdminLoginAuthorizeChart extends EventChart {
async apiRequest(): Promise<LoginMetrics> { async apiRequest(): Promise<EventVolume[]> {
return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve(); return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
actions: [
EventActions.AuthorizeApplication,
EventActions.Login,
EventActions.LoginFailed,
],
});
} }
getChartData(data: LoginMetrics): ChartData { getChartData(data: EventVolume[]): ChartData {
return { const optsMap = new Map<EventActions, Partial<ChartDataset>>();
datasets: [ optsMap.set(EventActions.AuthorizeApplication, {
{
label: msg("Authorizations"), label: msg("Authorizations"),
backgroundColor: new RGBAColor(43, 154, 243, 0.5).toString(),
borderColor: new RGBAColor(43, 154, 243, 1).toString(),
spanGaps: true, spanGaps: true,
fill: "origin", fill: "origin",
cubicInterpolationMode: "monotone", cubicInterpolationMode: "monotone",
tension: 0.4, tension: 0.4,
data: data.authorizations.map((cord) => { });
return { optsMap.set(EventActions.Login, {
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"), label: msg("Successful Logins"),
backgroundColor: new RGBAColor(62, 134, 53, 0.5).toString(),
borderColor: new RGBAColor(62, 134, 53, 1).toString(),
spanGaps: true, spanGaps: true,
fill: "origin", fill: "origin",
cubicInterpolationMode: "monotone", cubicInterpolationMode: "monotone",
tension: 0.4, tension: 0.4,
data: data.logins.map((cord) => { });
return { optsMap.set(EventActions.LoginFailed, {
x: cord.xCord, label: msg("Failed Logins"),
y: cord.yCord, 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 { 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 { msg, str } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement, property } from "lit/decorators.js"; 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") @customElement("ak-charts-admin-model-per-day")
export class AdminModelPerDay extends AKChart<Coordinate[]> { export class AdminModelPerDay extends EventChart {
@property() @property()
action: EventActions = EventActions.ModelCreated; action: EventActions = EventActions.ModelCreated;
@ -16,39 +21,29 @@ export class AdminModelPerDay extends AKChart<Coordinate[]> {
label?: string; label?: string;
@property({ attribute: false }) @property({ attribute: false })
query?: { [key: string]: unknown } | undefined; query?: EventsEventsVolumeListRequest;
async apiRequest(): Promise<Coordinate[]> { async apiRequest(): Promise<EventVolume[]> {
return new EventsApi(DEFAULT_CONFIG).eventsEventsPerMonthList({ return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
action: this.action, action: this.action,
query: JSON.stringify(this.query || {}), historyDays: 30,
...this.query,
}); });
} }
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string { getChartData(data: EventVolume[]): ChartData {
const valueStamp = ticks[index]; return this.eventVolume(data, {
const delta = Date.now() - valueStamp.value; optsMap: new Map([
const ago = Math.round(delta / 1000 / 3600 / 24); [
return msg(str`${ago} days ago`); this.action,
}
getChartData(data: Coordinate[]): ChartData {
return {
datasets: [
{ {
label: this.label || msg("Objects created"), label: this.label || msg("Objects created"),
backgroundColor: "rgba(189, 229, 184, .5)",
spanGaps: true, spanGaps: true,
data:
data.map((cord) => {
return {
x: cord.xCord || 0,
y: cord.yCord || 0,
};
}) || [],
}, },
], ],
}; ]),
padToDays: 30,
});
} }
} }

View File

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

View File

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

View File

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

View File

@ -282,7 +282,7 @@ export class ApplicationViewPage extends AKElement {
<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
applicationSlug=${this.application.slug} application-id=${this.application.pk}
> >
</ak-charts-application-authorize>`} </ak-charts-application-authorize>`}
</div> </div>

View File

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

View File

@ -1,71 +1,55 @@
import { EventChart } from "#elements/charts/EventChart";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; 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 { msg, str } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { CoreApi, UserMetrics } from "@goauthentik/api"; import { EventActions, EventVolume, EventsApi } from "@goauthentik/api";
@customElement("ak-charts-user") @customElement("ak-charts-user")
export class UserChart extends AKChart<UserMetrics> { export class UserChart extends EventChart {
@property({ type: Number }) @property()
userId?: number; username?: string;
async apiRequest(): Promise<UserMetrics> { async apiRequest(): Promise<EventVolume[]> {
return new CoreApi(DEFAULT_CONFIG).coreUsersMetricsRetrieve({ return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({
id: this.userId || 0, actions: [
EventActions.Login,
EventActions.LoginFailed,
EventActions.AuthorizeApplication,
],
username: this.username,
}); });
} }
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string { getChartData(data: EventVolume[]): ChartData {
const valueStamp = ticks[index]; return this.eventVolume(data, {
const delta = Date.now() - valueStamp.value; optsMap: new Map([
const ago = Math.round(delta / 1000 / 3600 / 24); [
return msg(str`${ago} days ago`); EventActions.LoginFailed,
}
getChartData(data: UserMetrics): ChartData {
return {
datasets: [
{ {
label: msg("Failed Logins"), label: msg("Failed Logins"),
backgroundColor: "rgba(201, 25, 11, .5)",
spanGaps: true, 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,
};
}) || [],
}, },
], ],
}; [
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)")} ${msg("Actions over the last week (per 8 hours)")}
</div> </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 username=${this.user.username}> </ak-charts-user>
</div> </div>
</div> </div>
<div <div

View File

@ -21,7 +21,7 @@ import {
import { Legend, Tooltip } from "chart.js"; import { Legend, Tooltip } from "chart.js";
import { BarController, DoughnutController, LineController } from "chart.js"; import { BarController, DoughnutController, LineController } from "chart.js";
import { ArcElement, BarElement } 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 "chartjs-adapter-date-fns";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -33,37 +33,11 @@ import { UiThemeEnum } from "@goauthentik/api";
Chart.register(Legend, Tooltip); Chart.register(Legend, Tooltip);
Chart.register(LineController, BarController, DoughnutController); Chart.register(LineController, BarController, DoughnutController);
Chart.register(ArcElement, BarElement, PointElement, LineElement); 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_DARK_MODE = "#fafafa";
export const FONT_COLOUR_LIGHT_MODE = "#151515"; 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 { export abstract class AKChart<T> extends AKElement {
abstract apiRequest(): Promise<T>; abstract apiRequest(): Promise<T>;
abstract getChartData(data: T): ChartData; abstract getChartData(data: T): ChartData;
@ -184,7 +158,7 @@ export abstract class AKChart<T> extends AKElement {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
type: "time", type: "timeseries",
display: true, display: true,
ticks: { ticks: {
callback: (tickValue: string | number, index: number, ticks: Tick[]) => { 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;
}
}