From 734db4dee650e6cbc8c0a5a2a3c3843f162e1559 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Tue, 10 Jun 2025 02:36:09 +0200 Subject: [PATCH] events: rework metrics endpoint (#14934) * rework event volume Signed-off-by: Jens Langhammer * migrate more Signed-off-by: Jens Langhammer * migrate more Signed-off-by: Jens Langhammer * migrate more Signed-off-by: Jens Langhammer * the rest of the owl Signed-off-by: Jens Langhammer * client-side data padding Signed-off-by: Jens Langhammer * I love deleting code Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * fix clamping Signed-off-by: Jens Langhammer * chunk it Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * add event-to-color map Signed-off-by: Jens Langhammer * sync colours Signed-off-by: Jens Langhammer * switch colours Signed-off-by: Jens Langhammer * heatmap? Signed-off-by: Jens Langhammer * Revert "heatmap?" This reverts commit c1f549a18b61ef28753bc36cf7ffa4ed3d15b87d. * Revert "Revert "heatmap?"" This reverts commit 6d6175b96bda21ac6ca9030463eff4fbc1692f98. * Revert "Revert "Revert "heatmap?""" This reverts commit 3717903f1275a0efc5a6952c18d6103e2849daf8. * format Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/admin/api/metrics.py | 79 ----- authentik/admin/tests/test_api.py | 5 - authentik/admin/urls.py | 6 - authentik/core/api/applications.py | 19 -- authentik/core/api/users.py | 60 ---- authentik/core/tests/test_users_api.py | 16 - authentik/events/api/events.py | 79 ++--- authentik/events/models.py | 63 ---- authentik/rbac/tests/test_decorators.py | 45 +-- pyproject.toml | 1 + schema.yml | 288 ++++++------------ .../admin/admin-overview/DashboardUserPage.ts | 8 +- .../charts/AdminLoginAuthorizeChart.ts | 95 +++--- .../admin-overview/charts/AdminModelPerDay.ts | 61 ++-- .../charts/OutpostStatusChart.ts | 9 +- .../admin-overview/charts/SyncStatusChart.ts | 15 +- .../applications/ApplicationAuthorizeChart.ts | 58 ++-- .../admin/applications/ApplicationViewPage.ts | 2 +- web/src/admin/events/EventVolumeChart.ts | 41 +-- web/src/admin/users/UserChart.ts | 100 +++--- web/src/admin/users/UserViewPage.ts | 2 +- web/src/elements/charts/Chart.ts | 32 +- web/src/elements/charts/EventChart.ts | 120 ++++++++ 23 files changed, 461 insertions(+), 743 deletions(-) delete mode 100644 authentik/admin/api/metrics.py create mode 100644 web/src/elements/charts/EventChart.ts diff --git a/authentik/admin/api/metrics.py b/authentik/admin/api/metrics.py deleted file mode 100644 index 6929767ecf..0000000000 --- a/authentik/admin/api/metrics.py +++ /dev/null @@ -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) diff --git a/authentik/admin/tests/test_api.py b/authentik/admin/tests/test_api.py index ed6656470a..1268812e7d 100644 --- a/authentik/admin/tests/test_api.py +++ b/authentik/admin/tests/test_api.py @@ -36,11 +36,6 @@ class TestAdminAPI(TestCase): body = loads(response.content) 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): """Test apps API""" response = self.client.get(reverse("authentik_api:apps-list")) diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index 51bdd4eca5..0dd6fc02f2 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -3,7 +3,6 @@ from django.urls import path 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.version import VersionView from authentik.admin.api.version_history import VersionHistoryViewSet @@ -12,11 +11,6 @@ from authentik.admin.api.workers import WorkerView api_urlpatterns = [ ("admin/apps", AppsViewSet, "apps"), ("admin/models", ModelViewSet, "models"), - path( - "admin/metrics/", - AdministrationMetricsViewSet.as_view(), - name="admin_metrics", - ), path("admin/version/", VersionView.as_view(), name="admin_version"), ("admin/version/history", VersionHistoryViewSet, "version_history"), path("admin/workers/", WorkerView.as_view(), name="admin_workers"), diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index c96680d373..269b82d2db 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -2,11 +2,9 @@ from collections.abc import Iterator from copy import copy -from datetime import timedelta from django.core.cache import cache from django.db.models import QuerySet -from django.db.models.functions import ExtractHour from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes 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 structlog.stdlib import get_logger -from authentik.admin.api.metrics import CoordinateSerializer from authentik.api.pagination import Pagination from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT 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.models import Application, User from authentik.events.logs import LogEventSerializer, capture_logs -from authentik.events.models import EventAction from authentik.lib.utils.file import ( FilePathSerializer, FileUploadSerializer, @@ -321,18 +317,3 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): """Set application icon (as URL)""" app: Application = self.get_object() 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) - ) diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 2e95a1396e..7a560959e0 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -6,7 +6,6 @@ from typing import Any from django.contrib.auth import update_session_auth_hash from django.contrib.auth.models import Permission -from django.db.models.functions import ExtractHour from django.db.transaction import atomic from django.db.utils import IntegrityError from django.urls import reverse_lazy @@ -52,7 +51,6 @@ from rest_framework.validators import UniqueValidator from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger -from authentik.admin.api.metrics import CoordinateSerializer from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.brands.models import Brand from authentik.core.api.used_by import UsedByMixin @@ -317,53 +315,6 @@ class SessionUserSerializer(PassiveSerializer): 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): """Filter for users""" @@ -607,17 +558,6 @@ class UserViewSet(UsedByMixin, ModelViewSet): update_session_auth_hash(self.request, user) 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") @extend_schema( responses={ diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py index ff5a28bec4..b35ce2d70f 100644 --- a/authentik/core/tests/test_users_api.py +++ b/authentik/core/tests/test_users_api.py @@ -81,22 +81,6 @@ class TestUsersAPI(APITestCase): response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"}) 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): """Test user recovery link (no recovery flow set)""" self.client.force_login(self.admin) diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index 609161152f..73f3f2593d 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -1,28 +1,36 @@ """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 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.functions import ExtractDay, ExtractHour +from django.db.models.functions import TruncHour from django.db.models.query_utils import Q +from django.utils.timezone import now from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from guardian.shortcuts import get_objects_for_user 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.response import Response 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.utils import ModelSerializer, PassiveSerializer 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): """Event Serializer""" @@ -53,7 +61,7 @@ class EventsFilter(django_filters.FilterSet): """Filter for events""" 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( field_name="context", @@ -78,12 +86,19 @@ class EventsFilter(django_filters.FilterSet): field_name="action", lookup_expr="icontains", ) + actions = django_filters.MultipleChoiceFilter( + field_name="action", + choices=EventAction.choices, + ) brand_name = django_filters.CharFilter( field_name="brand", lookup_expr="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): """Because we store the PK as UUID.hex, 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) @extend_schema( - responses={200: CoordinateSerializer(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=[], + responses={200: EventVolumeSerializer(many=True)}, parameters=[ OpenApiParameter( - "action", - type=OpenApiTypes.STR, - location=OpenApiParameter.QUERY, - required=False, - ), - OpenApiParameter( - "query", - type=OpenApiTypes.STR, + "history_days", + type=OpenApiTypes.NUMBER, location=OpenApiParameter.QUERY, required=False, + default=7, ), ], ) @action(detail=False, methods=["GET"], pagination_class=None) - def per_month(self, request: Request): - """Get the count of events per month""" - filtered_action = request.query_params.get("action", EventAction.LOGIN) - try: - query = loads(request.query_params.get("query", "{}")) - except ValueError: - return Response(status=400) + def volume(self, request: Request) -> Response: + """Get event volume for specified filters and timeframe""" + queryset: QuerySet[Event] = self.filter_queryset(self.get_queryset()) + delta = timedelta(days=7) + time_delta = request.query_params.get("history_days", 7) + if time_delta: + delta = timedelta(days=min(int(time_delta), 60)) return Response( - get_objects_for_user(request.user, "authentik_events.view_event") - .filter(action=filtered_action) - .filter(**query) - .get_events_per(timedelta(weeks=4), ExtractDay, 30) + queryset.filter(created__gte=now() - delta) + .annotate(hour=TruncHour("created")) + .annotate( + 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)}) diff --git a/authentik/events/models.py b/authentik/events/models.py index 6ade3c107d..83d87da916 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -1,7 +1,5 @@ """authentik events models""" -import time -from collections import Counter from datetime import timedelta from difflib import get_close_matches from functools import lru_cache @@ -11,11 +9,6 @@ from uuid import uuid4 from django.apps import apps 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.request import QueryDict from django.utils.timezone import now @@ -124,60 +117,6 @@ class EventAction(models.TextChoices): 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): """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 expires = models.DateTimeField(default=default_event_duration) - objects = EventManager() - @staticmethod def _get_app_from_request(request: HttpRequest) -> str: if not isinstance(request, HttpRequest): diff --git a/authentik/rbac/tests/test_decorators.py b/authentik/rbac/tests/test_decorators.py index 974ce97652..dae9377418 100644 --- a/authentik/rbac/tests/test_decorators.py +++ b/authentik/rbac/tests/test_decorators.py @@ -1,12 +1,29 @@ """test decorators api""" -from django.urls import reverse 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.viewsets import ModelViewSet from authentik.core.models import Application from authentik.core.tests.utils import create_test_user 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): @@ -18,41 +35,33 @@ class TestAPIDecorators(APITestCase): def test_obj_perm_denied(self): """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()) - response = self.client.get( - 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) def test_obj_perm_global(self): """Test object perm successful (global)""" assign_perm("authentik_core.view_application", 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()) - response = self.client.get( - reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) - ) - self.assertEqual(response.status_code, 200) + request = get_request("", user=self.user) + response = MVS.as_view({"get": "test"})(request, slug=app.slug) + self.assertEqual(response.status_code, 200, response.data) def test_obj_perm_scoped(self): """Test object perm successful (scoped)""" assign_perm("authentik_events.view_event", self.user) app = Application.objects.create(name=generate_id(), slug=generate_id()) assign_perm("authentik_core.view_application", self.user, app) - self.client.force_login(self.user) - response = self.client.get( - reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) - ) + request = get_request("", user=self.user) + response = MVS.as_view({"get": "test"})(request, slug=app.slug) self.assertEqual(response.status_code, 200) def test_other_perm_denied(self): """Test other perm denied""" - self.client.force_login(self.user) app = Application.objects.create(name=generate_id(), slug=generate_id()) assign_perm("authentik_core.view_application", self.user, app) - response = self.client.get( - reverse("authentik_api:application-metrics", kwargs={"slug": app.slug}) - ) + request = get_request("", user=self.user) + response = MVS.as_view({"get": "test"})(request, slug=app.slug) self.assertEqual(response.status_code, 403) diff --git a/pyproject.toml b/pyproject.toml index 9c3adb2477..62c2cbb00c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,7 @@ skip = [ "**/storybook-static", "**/web/src/locales", "**/web/xliff", + "**/web/out", "./web/storybook-static", "./web/custom-elements.json", "./website/build", diff --git a/schema.yml b/schema.yml index f807f7af24..ae415b8f15 100644 --- a/schema.yml +++ b/schema.yml @@ -38,33 +38,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: get: operationId: admin_models_list @@ -4136,42 +4109,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: post: operationId: core_applications_set_icon_create @@ -6071,40 +6008,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: post: operationId: core_users_recovery_create @@ -7112,6 +7015,42 @@ paths: name: action schema: 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 name: brand_name schema: @@ -7398,44 +7337,6 @@ paths: schema: $ref: '#/components/schemas/GenericError' 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/: get: operationId: events_events_top_per_user_list @@ -7483,6 +7384,42 @@ paths: name: action schema: 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 name: brand_name schema: @@ -7512,6 +7449,11 @@ paths: schema: type: string description: Context Model Primary Key + - in: query + name: history_days + schema: + type: number + default: 7 - name: ordering required: false in: query @@ -7540,7 +7482,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Coordinate' + $ref: '#/components/schemas/EventVolume' description: '' '400': content: @@ -43596,19 +43538,6 @@ components: - sidebar_left - sidebar_right 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: enum: - AF @@ -44986,6 +44915,21 @@ components: - application - counted_events - 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: enum: - https://schemas.openid.net/secevent/caep/event-type/session-revoked @@ -48297,29 +48241,6 @@ components: xak-flow-redirect: '#/components/schemas/RedirectChallenge' ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge' 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: type: object description: Serializer for Login buttons of sources @@ -60729,29 +60650,6 @@ components: - username_link - username_deny 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: type: object description: User source connection diff --git a/web/src/admin/admin-overview/DashboardUserPage.ts b/web/src/admin/admin-overview/DashboardUserPage.ts index eb1b168bd0..0d8a765628 100644 --- a/web/src/admin/admin-overview/DashboardUserPage.ts +++ b/web/src/admin/admin-overview/DashboardUserPage.ts @@ -13,7 +13,7 @@ import PFList from "@patternfly/patternfly/components/List/list.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; -import { EventActions } from "@goauthentik/api"; +import { EventActions, EventsEventsVolumeListRequest } from "@goauthentik/api"; @customElement("ak-admin-dashboard-users") export class DashboardUserPage extends AKElement { @@ -46,9 +46,9 @@ export class DashboardUserPage extends AKElement { diff --git a/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts b/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts index 59345a1e7c..fdca98eb63 100644 --- a/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts +++ b/web/src/admin/admin-overview/charts/AdminLoginAuthorizeChart.ts @@ -1,68 +1,51 @@ +import { EventChart } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart"; -import { ChartData } from "chart.js"; +import { ChartData, ChartDataset } from "chart.js"; import { msg } from "@lit/localize"; import { customElement } from "lit/decorators.js"; -import { AdminApi, LoginMetrics } from "@goauthentik/api"; +import { EventActions, EventVolume, EventsApi } from "@goauthentik/api"; @customElement("ak-charts-admin-login-authorization") -export class AdminLoginAuthorizeChart extends AKChart { - async apiRequest(): Promise { - return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve(); +export class AdminLoginAuthorizeChart extends EventChart { + async apiRequest(): Promise { + return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({ + actions: [ + EventActions.AuthorizeApplication, + EventActions.Login, + EventActions.LoginFailed, + ], + }); } - getChartData(data: LoginMetrics): ChartData { - return { - datasets: [ - { - label: msg("Authorizations"), - backgroundColor: new RGBAColor(43, 154, 243, 0.5).toString(), - borderColor: new RGBAColor(43, 154, 243, 1).toString(), - spanGaps: true, - fill: "origin", - cubicInterpolationMode: "monotone", - tension: 0.4, - data: data.authorizations.map((cord) => { - return { - x: cord.xCord, - y: cord.yCord, - }; - }), - }, - { - label: msg("Failed Logins"), - backgroundColor: new RGBAColor(201, 24, 11, 0.5).toString(), - borderColor: new RGBAColor(201, 24, 11, 1).toString(), - spanGaps: true, - fill: "origin", - cubicInterpolationMode: "monotone", - tension: 0.4, - data: data.loginsFailed.map((cord) => { - return { - x: cord.xCord, - y: cord.yCord, - }; - }), - }, - { - label: msg("Successful Logins"), - backgroundColor: new RGBAColor(62, 134, 53, 0.5).toString(), - borderColor: new RGBAColor(62, 134, 53, 1).toString(), - spanGaps: true, - fill: "origin", - cubicInterpolationMode: "monotone", - tension: 0.4, - data: data.logins.map((cord) => { - return { - x: cord.xCord, - y: cord.yCord, - }; - }), - }, - ], - }; + getChartData(data: EventVolume[]): ChartData { + const optsMap = new Map>(); + optsMap.set(EventActions.AuthorizeApplication, { + label: msg("Authorizations"), + spanGaps: true, + fill: "origin", + cubicInterpolationMode: "monotone", + tension: 0.4, + }); + optsMap.set(EventActions.Login, { + label: msg("Successful Logins"), + spanGaps: true, + fill: "origin", + cubicInterpolationMode: "monotone", + tension: 0.4, + }); + optsMap.set(EventActions.LoginFailed, { + label: msg("Failed Logins"), + spanGaps: true, + fill: "origin", + cubicInterpolationMode: "monotone", + tension: 0.4, + }); + return this.eventVolume(data, { + optsMap: optsMap, + padToDays: 7, + }); } } diff --git a/web/src/admin/admin-overview/charts/AdminModelPerDay.ts b/web/src/admin/admin-overview/charts/AdminModelPerDay.ts index 5227616d4b..f728a415f2 100644 --- a/web/src/admin/admin-overview/charts/AdminModelPerDay.ts +++ b/web/src/admin/admin-overview/charts/AdminModelPerDay.ts @@ -1,14 +1,19 @@ +import { EventChart } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart } from "@goauthentik/elements/charts/Chart"; -import { ChartData, Tick } from "chart.js"; +import { ChartData } from "chart.js"; -import { msg, str } from "@lit/localize"; +import { msg } from "@lit/localize"; import { customElement, property } from "lit/decorators.js"; -import { Coordinate, EventActions, EventsApi } from "@goauthentik/api"; +import { + EventActions, + EventVolume, + EventsApi, + EventsEventsVolumeListRequest, +} from "@goauthentik/api"; @customElement("ak-charts-admin-model-per-day") -export class AdminModelPerDay extends AKChart { +export class AdminModelPerDay extends EventChart { @property() action: EventActions = EventActions.ModelCreated; @@ -16,39 +21,29 @@ export class AdminModelPerDay extends AKChart { label?: string; @property({ attribute: false }) - query?: { [key: string]: unknown } | undefined; + query?: EventsEventsVolumeListRequest; - async apiRequest(): Promise { - return new EventsApi(DEFAULT_CONFIG).eventsEventsPerMonthList({ + async apiRequest(): Promise { + return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({ action: this.action, - query: JSON.stringify(this.query || {}), + historyDays: 30, + ...this.query, }); } - timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string { - const valueStamp = ticks[index]; - const delta = Date.now() - valueStamp.value; - const ago = Math.round(delta / 1000 / 3600 / 24); - return msg(str`${ago} days ago`); - } - - getChartData(data: Coordinate[]): ChartData { - return { - datasets: [ - { - label: this.label || msg("Objects created"), - backgroundColor: "rgba(189, 229, 184, .5)", - spanGaps: true, - data: - data.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - ], - }; + getChartData(data: EventVolume[]): ChartData { + return this.eventVolume(data, { + optsMap: new Map([ + [ + this.action, + { + label: this.label || msg("Objects created"), + spanGaps: true, + }, + ], + ]), + padToDays: 30, + }); } } diff --git a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts index f62535bc84..1bee838288 100644 --- a/web/src/admin/admin-overview/charts/OutpostStatusChart.ts +++ b/web/src/admin/admin-overview/charts/OutpostStatusChart.ts @@ -1,3 +1,4 @@ +import { actionToColor } from "#elements/charts/EventChart"; import { SummarizedSyncStatus } from "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; @@ -7,7 +8,7 @@ import { ChartData, ChartOptions } from "chart.js"; import { msg } from "@lit/localize"; import { customElement } from "lit/decorators.js"; -import { OutpostsApi } from "@goauthentik/api"; +import { EventActions, OutpostsApi } from "@goauthentik/api"; @customElement("ak-admin-status-chart-outpost") export class OutpostStatusChart extends AKChart { @@ -65,7 +66,11 @@ export class OutpostStatusChart extends AKChart { labels: [msg("Healthy outposts"), msg("Outdated outposts"), msg("Unhealthy outposts")], datasets: data.map((d) => { return { - backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"], + backgroundColor: [ + actionToColor(EventActions.Login), + actionToColor(EventActions.SuspiciousRequest), + actionToColor(EventActions.AuthorizeApplication), + ], spanGaps: true, data: [d.healthy, d.failed, d.unsynced], label: d.label, diff --git a/web/src/admin/admin-overview/charts/SyncStatusChart.ts b/web/src/admin/admin-overview/charts/SyncStatusChart.ts index 7855823133..e864546fd1 100644 --- a/web/src/admin/admin-overview/charts/SyncStatusChart.ts +++ b/web/src/admin/admin-overview/charts/SyncStatusChart.ts @@ -1,3 +1,4 @@ +import { actionToColor } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; import "@goauthentik/elements/forms/ConfirmationForm"; @@ -7,7 +8,13 @@ import { ChartData, ChartOptions } from "chart.js"; import { msg } from "@lit/localize"; import { customElement } from "lit/decorators.js"; -import { ProvidersApi, SourcesApi, SyncStatus, SystemTaskStatusEnum } from "@goauthentik/api"; +import { + EventActions, + ProvidersApi, + SourcesApi, + SyncStatus, + SystemTaskStatusEnum, +} from "@goauthentik/api"; export interface SummarizedSyncStatus { healthy: number; @@ -136,7 +143,11 @@ export class SyncStatusChart extends AKChart { labels: [msg("Healthy"), msg("Failed"), msg("Unsynced / N/A")], datasets: data.map((d) => { return { - backgroundColor: ["#3e8635", "#C9190B", "#2b9af3"], + backgroundColor: [ + actionToColor(EventActions.Login), + actionToColor(EventActions.SuspiciousRequest), + actionToColor(EventActions.AuthorizeApplication), + ], spanGaps: true, data: [d.healthy, d.failed, d.unsynced], label: d.label, diff --git a/web/src/admin/applications/ApplicationAuthorizeChart.ts b/web/src/admin/applications/ApplicationAuthorizeChart.ts index 0d1b8ac7b2..37de1b0e3f 100644 --- a/web/src/admin/applications/ApplicationAuthorizeChart.ts +++ b/web/src/admin/applications/ApplicationAuthorizeChart.ts @@ -1,47 +1,37 @@ +import { EventChart } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart } from "@goauthentik/elements/charts/Chart"; -import { ChartData, Tick } from "chart.js"; +import { ChartData } from "chart.js"; -import { msg, str } from "@lit/localize"; +import { msg } from "@lit/localize"; import { customElement, property } from "lit/decorators.js"; -import { Coordinate, CoreApi } from "@goauthentik/api"; +import { EventActions, EventVolume, EventsApi } from "@goauthentik/api"; @customElement("ak-charts-application-authorize") -export class ApplicationAuthorizeChart extends AKChart { - @property() - applicationSlug!: string; +export class ApplicationAuthorizeChart extends EventChart { + @property({ attribute: "application-id" }) + applicationId!: string; - async apiRequest(): Promise { - return new CoreApi(DEFAULT_CONFIG).coreApplicationsMetricsList({ - slug: this.applicationSlug, + async apiRequest(): Promise { + return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({ + action: EventActions.AuthorizeApplication, + contextAuthorizedApp: this.applicationId.replaceAll("-", ""), }); } - timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string { - const valueStamp = ticks[index]; - const delta = Date.now() - valueStamp.value; - const ago = Math.round(delta / 1000 / 3600 / 24); - return msg(str`${ago} days ago`); - } - - getChartData(data: Coordinate[]): ChartData { - return { - datasets: [ - { - label: msg("Authorizations"), - backgroundColor: "rgba(189, 229, 184, .5)", - spanGaps: true, - data: - data.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - ], - }; + getChartData(data: EventVolume[]): ChartData { + return this.eventVolume(data, { + optsMap: new Map([ + [ + EventActions.AuthorizeApplication, + { + label: msg("Authorizations"), + spanGaps: true, + }, + ], + ]), + padToDays: 7, + }); } } diff --git a/web/src/admin/applications/ApplicationViewPage.ts b/web/src/admin/applications/ApplicationViewPage.ts index 7c23925316..39c7a19e69 100644 --- a/web/src/admin/applications/ApplicationViewPage.ts +++ b/web/src/admin/applications/ApplicationViewPage.ts @@ -282,7 +282,7 @@ export class ApplicationViewPage extends AKElement {
${this.application && html` `}
diff --git a/web/src/admin/events/EventVolumeChart.ts b/web/src/admin/events/EventVolumeChart.ts index 3ff3c49fd5..fa38d0e6e8 100644 --- a/web/src/admin/events/EventVolumeChart.ts +++ b/web/src/admin/events/EventVolumeChart.ts @@ -1,21 +1,21 @@ +import { EventChart } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart } from "@goauthentik/elements/charts/Chart"; import { ChartData } from "chart.js"; -import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import PFCard from "@patternfly/patternfly/components/Card/card.css"; -import { Coordinate, EventsApi, EventsEventsListRequest } from "@goauthentik/api"; +import { EventVolume, EventsApi, EventsEventsListRequest } from "@goauthentik/api"; @customElement("ak-events-volume-chart") -export class EventVolumeChart extends AKChart { +export class EventVolumeChart extends EventChart { _query?: EventsEventsListRequest; @property({ attribute: false }) set query(value: EventsEventsListRequest | undefined) { + if (JSON.stringify(this._query) === JSON.stringify(value)) return; this._query = value; this.refreshHandler(); } @@ -24,39 +24,28 @@ export class EventVolumeChart extends AKChart { return super.styles.concat( PFCard, css` - .pf-c-card__body { - height: 12rem; + .pf-c-card { + height: 20rem; } `, ); } - apiRequest(): Promise { - return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList(this._query); + apiRequest(): Promise { + return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({ + historyDays: 7, + ...this._query, + }); } - getChartData(data: Coordinate[]): ChartData { - return { - datasets: [ - { - label: msg("Events"), - backgroundColor: "rgba(189, 229, 184, .5)", - spanGaps: true, - data: - data.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - ], - }; + getChartData(data: EventVolume[]): ChartData { + return this.eventVolume(data, { + padToDays: 7, + }); } render(): TemplateResult { return html`
-
${msg("Event volume")}
${super.render()}
`; } diff --git a/web/src/admin/users/UserChart.ts b/web/src/admin/users/UserChart.ts index fb7c7fedf6..a5b8144486 100644 --- a/web/src/admin/users/UserChart.ts +++ b/web/src/admin/users/UserChart.ts @@ -1,71 +1,55 @@ +import { EventChart } from "#elements/charts/EventChart"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { AKChart } from "@goauthentik/elements/charts/Chart"; -import { ChartData, Tick } from "chart.js"; +import { ChartData } from "chart.js"; -import { msg, str } from "@lit/localize"; +import { msg } from "@lit/localize"; import { customElement, property } from "lit/decorators.js"; -import { CoreApi, UserMetrics } from "@goauthentik/api"; +import { EventActions, EventVolume, EventsApi } from "@goauthentik/api"; @customElement("ak-charts-user") -export class UserChart extends AKChart { - @property({ type: Number }) - userId?: number; +export class UserChart extends EventChart { + @property() + username?: string; - async apiRequest(): Promise { - return new CoreApi(DEFAULT_CONFIG).coreUsersMetricsRetrieve({ - id: this.userId || 0, + async apiRequest(): Promise { + return new EventsApi(DEFAULT_CONFIG).eventsEventsVolumeList({ + actions: [ + EventActions.Login, + EventActions.LoginFailed, + EventActions.AuthorizeApplication, + ], + username: this.username, }); } - timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string { - const valueStamp = ticks[index]; - const delta = Date.now() - valueStamp.value; - const ago = Math.round(delta / 1000 / 3600 / 24); - return msg(str`${ago} days ago`); - } - - getChartData(data: UserMetrics): ChartData { - return { - datasets: [ - { - label: msg("Failed Logins"), - backgroundColor: "rgba(201, 25, 11, .5)", - spanGaps: true, - data: - data.loginsFailed?.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - { - label: msg("Successful Logins"), - backgroundColor: "rgba(189, 229, 184, .5)", - spanGaps: true, - data: - data.logins?.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - { - label: msg("Application authorizations"), - backgroundColor: "rgba(43, 154, 243, .5)", - spanGaps: true, - data: - data.authorizations?.map((cord) => { - return { - x: cord.xCord || 0, - y: cord.yCord || 0, - }; - }) || [], - }, - ], - }; + getChartData(data: EventVolume[]): ChartData { + return this.eventVolume(data, { + optsMap: new Map([ + [ + EventActions.LoginFailed, + { + label: msg("Failed Logins"), + spanGaps: true, + }, + ], + [ + EventActions.Login, + { + label: msg("Successful Logins"), + spanGaps: true, + }, + ], + [ + EventActions.AuthorizeApplication, + { + label: msg("Application authorizations"), + spanGaps: true, + }, + ], + ]), + padToDays: 7, + }); } } diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 4360c34afd..85c7391273 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -389,7 +389,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { ${msg("Actions over the last week (per 8 hours)")}
- +
> (i * 8)) & 255; - rgb[i] = value; - } - return new RGBAColor(rgb[0], rgb[1], rgb[2]); -} - export abstract class AKChart extends AKElement { abstract apiRequest(): Promise; abstract getChartData(data: T): ChartData; @@ -184,7 +158,7 @@ export abstract class AKChart extends AKElement { responsive: true, scales: { x: { - type: "time", + type: "timeseries", display: true, ticks: { callback: (tickValue: string | number, index: number, ticks: Tick[]) => { diff --git a/web/src/elements/charts/EventChart.ts b/web/src/elements/charts/EventChart.ts new file mode 100644 index 0000000000..cfc5c43879 --- /dev/null +++ b/web/src/elements/charts/EventChart.ts @@ -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( + data: EventVolume[], + options?: { + optsMap?: Map>; + padToDays?: number; + }, + ): ChartData { + const datasets: ChartData = { + datasets: [], + }; + if (!options) { + options = {}; + } + if (!options.optsMap) { + options.optsMap = new Map>(); + } + 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; + } +}