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 commitc1f549a18b. * Revert "Revert "heatmap?"" This reverts commit6d6175b96b. * Revert "Revert "Revert "heatmap?""" This reverts commit3717903f12. * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -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)
|
|
||||||
@ -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"))
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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)
|
|
||||||
)
|
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)})
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
288
schema.yml
288
schema.yml
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"),
|
spanGaps: true,
|
||||||
backgroundColor: new RGBAColor(43, 154, 243, 0.5).toString(),
|
fill: "origin",
|
||||||
borderColor: new RGBAColor(43, 154, 243, 1).toString(),
|
cubicInterpolationMode: "monotone",
|
||||||
spanGaps: true,
|
tension: 0.4,
|
||||||
fill: "origin",
|
});
|
||||||
cubicInterpolationMode: "monotone",
|
optsMap.set(EventActions.Login, {
|
||||||
tension: 0.4,
|
label: msg("Successful Logins"),
|
||||||
data: data.authorizations.map((cord) => {
|
spanGaps: true,
|
||||||
return {
|
fill: "origin",
|
||||||
x: cord.xCord,
|
cubicInterpolationMode: "monotone",
|
||||||
y: cord.yCord,
|
tension: 0.4,
|
||||||
};
|
});
|
||||||
}),
|
optsMap.set(EventActions.LoginFailed, {
|
||||||
},
|
label: msg("Failed Logins"),
|
||||||
{
|
spanGaps: true,
|
||||||
label: msg("Failed Logins"),
|
fill: "origin",
|
||||||
backgroundColor: new RGBAColor(201, 24, 11, 0.5).toString(),
|
cubicInterpolationMode: "monotone",
|
||||||
borderColor: new RGBAColor(201, 24, 11, 1).toString(),
|
tension: 0.4,
|
||||||
spanGaps: true,
|
});
|
||||||
fill: "origin",
|
return this.eventVolume(data, {
|
||||||
cubicInterpolationMode: "monotone",
|
optsMap: optsMap,
|
||||||
tension: 0.4,
|
padToDays: 7,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
{
|
||||||
|
label: this.label || msg("Objects created"),
|
||||||
getChartData(data: Coordinate[]): ChartData {
|
spanGaps: true,
|
||||||
return {
|
},
|
||||||
datasets: [
|
],
|
||||||
{
|
]),
|
||||||
label: this.label || msg("Objects created"),
|
padToDays: 30,
|
||||||
backgroundColor: "rgba(189, 229, 184, .5)",
|
});
|
||||||
spanGaps: true,
|
|
||||||
data:
|
|
||||||
data.map((cord) => {
|
|
||||||
return {
|
|
||||||
x: cord.xCord || 0,
|
|
||||||
y: cord.yCord || 0,
|
|
||||||
};
|
|
||||||
}) || [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
{
|
||||||
|
label: msg("Authorizations"),
|
||||||
getChartData(data: Coordinate[]): ChartData {
|
spanGaps: true,
|
||||||
return {
|
},
|
||||||
datasets: [
|
],
|
||||||
{
|
]),
|
||||||
label: msg("Authorizations"),
|
padToDays: 7,
|
||||||
backgroundColor: "rgba(189, 229, 184, .5)",
|
});
|
||||||
spanGaps: true,
|
|
||||||
data:
|
|
||||||
data.map((cord) => {
|
|
||||||
return {
|
|
||||||
x: cord.xCord || 0,
|
|
||||||
y: cord.yCord || 0,
|
|
||||||
};
|
|
||||||
}) || [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
{
|
||||||
|
label: msg("Failed Logins"),
|
||||||
getChartData(data: UserMetrics): ChartData {
|
spanGaps: true,
|
||||||
return {
|
},
|
||||||
datasets: [
|
],
|
||||||
{
|
[
|
||||||
label: msg("Failed Logins"),
|
EventActions.Login,
|
||||||
backgroundColor: "rgba(201, 25, 11, .5)",
|
{
|
||||||
spanGaps: true,
|
label: msg("Successful Logins"),
|
||||||
data:
|
spanGaps: true,
|
||||||
data.loginsFailed?.map((cord) => {
|
},
|
||||||
return {
|
],
|
||||||
x: cord.xCord || 0,
|
[
|
||||||
y: cord.yCord || 0,
|
EventActions.AuthorizeApplication,
|
||||||
};
|
{
|
||||||
}) || [],
|
label: msg("Application authorizations"),
|
||||||
},
|
spanGaps: true,
|
||||||
{
|
},
|
||||||
label: msg("Successful Logins"),
|
],
|
||||||
backgroundColor: "rgba(189, 229, 184, .5)",
|
]),
|
||||||
spanGaps: true,
|
padToDays: 7,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}) || [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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[]) => {
|
||||||
|
|||||||
120
web/src/elements/charts/EventChart.ts
Normal file
120
web/src/elements/charts/EventChart.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user