events: add custom manager with helpers for metrics
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
64a10e9a46
commit
30386cd899
|
@ -1,13 +1,6 @@
|
||||||
"""authentik administration metrics"""
|
"""authentik administration metrics"""
|
||||||
import time
|
|
||||||
from collections import Counter
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.db.models import Count, ExpressionWrapper, F
|
|
||||||
from django.db.models.fields import DurationField
|
|
||||||
from django.db.models.functions import ExtractHour
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.fields import IntegerField, SerializerMethodField
|
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
|
@ -15,31 +8,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import EventAction
|
||||||
|
|
||||||
|
|
||||||
def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]:
|
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
|
||||||
date_from = now() - timedelta(days=1)
|
|
||||||
result = (
|
|
||||||
Event.objects.filter(created__gte=date_from, **filter_kwargs)
|
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
|
||||||
.annotate(age_hours=ExtractHour("age"))
|
|
||||||
.values("age_hours")
|
|
||||||
.annotate(count=Count("pk"))
|
|
||||||
.order_by("age_hours")
|
|
||||||
)
|
|
||||||
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
|
||||||
results = []
|
|
||||||
_now = now()
|
|
||||||
for hour in range(0, -24, -1):
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
|
||||||
"y_cord": data[hour * -1],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class CoordinateSerializer(PassiveSerializer):
|
class CoordinateSerializer(PassiveSerializer):
|
||||||
|
@ -58,12 +27,22 @@ class LoginMetricsSerializer(PassiveSerializer):
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
return get_events_per_1h(action=EventAction.LOGIN)
|
user = self.context["user"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
|
.filter(action=EventAction.LOGIN)
|
||||||
|
.get_events_per_hour()
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
|
user = self.context["user"]
|
||||||
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
|
.filter(action=EventAction.LOGIN_FAILED)
|
||||||
|
.get_events_per_hour()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AdministrationMetricsViewSet(APIView):
|
class AdministrationMetricsViewSet(APIView):
|
||||||
|
@ -75,4 +54,5 @@ class AdministrationMetricsViewSet(APIView):
|
||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
"""Login Metrics per 1h"""
|
"""Login Metrics per 1h"""
|
||||||
serializer = LoginMetricsSerializer(True)
|
serializer = LoginMetricsSerializer(True)
|
||||||
|
serializer.context["user"] = request.user
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
|
@ -5,6 +5,7 @@ from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
|
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 ReadOnlyField
|
from rest_framework.fields import ReadOnlyField
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
|
@ -15,7 +16,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.providers import ProviderSerializer
|
from authentik.core.api.providers import ProviderSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
|
@ -231,7 +232,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||||
app.save()
|
app.save()
|
||||||
return Response({})
|
return Response({})
|
||||||
|
|
||||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_application")
|
||||||
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
@ -239,8 +240,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""Metrics for application logins"""
|
"""Metrics for application logins"""
|
||||||
app = self.get_object()
|
app = self.get_object()
|
||||||
return Response(
|
return Response(
|
||||||
get_events_per_1h(
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
|
.filter(
|
||||||
action=EventAction.AUTHORIZE_APPLICATION,
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
context__authorized_application__pk=app.pk.hex,
|
context__authorized_application__pk=app.pk.hex,
|
||||||
)
|
)
|
||||||
|
.get_events_per_hour()
|
||||||
)
|
)
|
||||||
|
|
|
@ -38,7 +38,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.groups import GroupSerializer
|
from authentik.core.api.groups import GroupSerializer
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
|
@ -184,19 +184,31 @@ class UserMetricsSerializer(PassiveSerializer):
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins_per_1h(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
|
.filter(action=EventAction.LOGIN, user__pk=user.pk)
|
||||||
|
.get_events_per_hour()
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return get_events_per_1h(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
|
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
||||||
|
.get_events_per_hour()
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations_per_1h(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per hour for the last 24 hours"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return get_events_per_1h(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
return (
|
||||||
|
get_objects_for_user(user, "authentik_events.view_event")
|
||||||
|
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
||||||
|
.get_events_per_hour()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UsersFilter(FilterSet):
|
class UsersFilter(FilterSet):
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
|
from json import loads
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
|
@ -12,6 +14,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
|
from authentik.admin.api.metrics import CoordinateSerializer
|
||||||
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
|
@ -116,7 +119,6 @@ class EventViewSet(ModelViewSet):
|
||||||
"action",
|
"action",
|
||||||
type=OpenApiTypes.STR,
|
type=OpenApiTypes.STR,
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
enum=[action for action in EventAction],
|
|
||||||
required=False,
|
required=False,
|
||||||
),
|
),
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
|
@ -124,7 +126,7 @@ class EventViewSet(ModelViewSet):
|
||||||
type=OpenApiTypes.INT,
|
type=OpenApiTypes.INT,
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=False, methods=["GET"], pagination_class=None)
|
@action(detail=False, methods=["GET"], pagination_class=None)
|
||||||
|
@ -145,6 +147,40 @@ class EventViewSet(ModelViewSet):
|
||||||
.order_by("-counted_events")[:top_n]
|
.order_by("-counted_events")[:top_n]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
methods=["GET"],
|
||||||
|
responses={200: CoordinateSerializer(many=True)},
|
||||||
|
filters=[],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
"action",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
OpenApiParameter(
|
||||||
|
"query",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@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)
|
||||||
|
return Response(
|
||||||
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
|
.filter(action=filtered_action)
|
||||||
|
.filter(**query)
|
||||||
|
.get_events_per_day()
|
||||||
|
)
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(detail=False, pagination_class=None, filter_backends=[])
|
||||||
def actions(self, request: Request) -> Response:
|
def actions(self, request: Request) -> Response:
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""authentik events models"""
|
"""authentik events models"""
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from inspect import getmodule, stack
|
from inspect import getmodule, stack
|
||||||
from smtplib import SMTPException
|
from smtplib import SMTPException
|
||||||
|
@ -7,6 +9,12 @@ from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
|
from django.db.models.fields import DurationField
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
|
from django.db.models.functions.datetime import ExtractDay
|
||||||
|
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
|
||||||
|
@ -91,6 +99,72 @@ class EventAction(models.TextChoices):
|
||||||
CUSTOM_PREFIX = "custom_"
|
CUSTOM_PREFIX = "custom_"
|
||||||
|
|
||||||
|
|
||||||
|
class EventQuerySet(QuerySet):
|
||||||
|
"""Custom events query set with helper functions"""
|
||||||
|
|
||||||
|
def get_events_per_hour(self) -> list[dict[str, int]]:
|
||||||
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
|
date_from = now() - timedelta(days=1)
|
||||||
|
result = (
|
||||||
|
self.filter(created__gte=date_from)
|
||||||
|
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
||||||
|
.annotate(age_hours=ExtractHour("age"))
|
||||||
|
.values("age_hours")
|
||||||
|
.annotate(count=Count("pk"))
|
||||||
|
.order_by("age_hours")
|
||||||
|
)
|
||||||
|
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
||||||
|
results = []
|
||||||
|
_now = now()
|
||||||
|
for hour in range(0, -24, -1):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
||||||
|
"y_cord": data[hour * -1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_events_per_day(self) -> list[dict[str, int]]:
|
||||||
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
|
date_from = now() - timedelta(weeks=4)
|
||||||
|
result = (
|
||||||
|
self.filter(created__gte=date_from)
|
||||||
|
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
||||||
|
.annotate(age_days=ExtractDay("age"))
|
||||||
|
.values("age_days")
|
||||||
|
.annotate(count=Count("pk"))
|
||||||
|
.order_by("age_days")
|
||||||
|
)
|
||||||
|
data = Counter({int(d["age_days"]): d["count"] for d in result})
|
||||||
|
results = []
|
||||||
|
_now = now()
|
||||||
|
for day in range(0, -30, -1):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
|
||||||
|
"y_cord": data[day * -1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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_hour(self) -> list[dict[str, int]]:
|
||||||
|
"""Wrap method from queryset"""
|
||||||
|
return self.get_queryset().get_events_per_hour()
|
||||||
|
|
||||||
|
def get_events_per_day(self) -> list[dict[str, int]]:
|
||||||
|
"""Wrap method from queryset"""
|
||||||
|
return self.get_queryset().get_events_per_day()
|
||||||
|
|
||||||
|
|
||||||
class Event(ExpiringModel):
|
class Event(ExpiringModel):
|
||||||
"""An individual Audit/Metrics/Notification/Error Event"""
|
"""An individual Audit/Metrics/Notification/Error Event"""
|
||||||
|
|
||||||
|
@ -106,6 +180,8 @@ class Event(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):
|
||||||
|
|
58
schema.yml
58
schema.yml
|
@ -3909,6 +3909,36 @@ paths:
|
||||||
$ref: '#/components/schemas/ValidationError'
|
$ref: '#/components/schemas/ValidationError'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/components/schemas/GenericError'
|
$ref: '#/components/schemas/GenericError'
|
||||||
|
/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':
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/schemas/GenericError'
|
||||||
/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
|
||||||
|
@ -3918,34 +3948,6 @@ paths:
|
||||||
name: action
|
name: action
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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
|
|
||||||
- in: query
|
- in: query
|
||||||
name: top_n
|
name: top_n
|
||||||
schema:
|
schema:
|
||||||
|
|
Reference in New Issue