admin: add metrics and charts
This commit is contained in:
parent
ae125dd1f0
commit
da9aaf69df
80
passbook/admin/api/overview_metrics.py
Normal file
80
passbook/admin/api/overview_metrics.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
"""passbook administration overview"""
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from typing import Dict, List
|
||||
|
||||
from django.db.models import Count, ExpressionWrapper, F
|
||||
from django.db.models.fields import DurationField
|
||||
from django.db.models.functions import ExtractHour
|
||||
from django.http import response
|
||||
from django.utils.timezone import now
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
|
||||
|
||||
class AdministrationMetricsSerializer(Serializer):
|
||||
"""Overview View"""
|
||||
|
||||
logins_per_1h = SerializerMethodField()
|
||||
logins_failed_per_1h = SerializerMethodField()
|
||||
|
||||
def get_events_per_1h(self, action: str) -> 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(action=action, 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({d["age_hours"]: d["count"] for d in result})
|
||||
results = []
|
||||
_now = now()
|
||||
for hour in range(0, -24, -1):
|
||||
results.append(
|
||||
{
|
||||
"x": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
||||
"y": data[hour * -1],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def get_logins_per_1h(self, _):
|
||||
"""Get successful logins per hour for the last 24 hours"""
|
||||
return self.get_events_per_1h(EventAction.LOGIN)
|
||||
|
||||
def get_logins_failed_per_1h(self, _):
|
||||
"""Get failed logins per hour for the last 24 hours"""
|
||||
return self.get_events_per_1h(EventAction.LOGIN_FAILED)
|
||||
|
||||
def create(self, request: Request) -> response:
|
||||
raise NotImplementedError
|
||||
|
||||
def update(self, request: Request) -> Response:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AdministrationMetricsViewSet(ViewSet):
|
||||
"""Return single instance of AdministrationMetricsSerializer"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
@swagger_auto_schema(responses={200: AdministrationMetricsSerializer(many=True)})
|
||||
def list(self, request: Request) -> Response:
|
||||
"""Return single instance of AdministrationMetricsSerializer"""
|
||||
serializer = AdministrationMetricsSerializer(True)
|
||||
return Response(serializer.data)
|
|
@ -1,6 +1,7 @@
|
|||
{% extends "administration/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<section class="pf-c-page__main-section pf-m-light">
|
||||
|
@ -10,33 +11,51 @@
|
|||
</section>
|
||||
<section class="pf-c-page__main-section">
|
||||
<div class="pf-l-gallery pf-m-gutter">
|
||||
<a href="{% url 'passbook_admin:applications' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col" style="grid-column-end: span 3;grid-row-end: span 2;">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-applications"></i> {% trans 'Applications' %}
|
||||
<i class="pf-icon pf-icon-server"></i> {% trans 'Logins over the last 24 hours' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body" style="position: relative; height:100%; width:100%">
|
||||
<canvas id="logins-last-metrics"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-4-col" style="grid-column-end: span 2;grid-row-end: span 3;">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-server"></i> {% trans 'Apps with most usage' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<p class="aggregate-status">
|
||||
<i class="fa fa-check-circle"></i> {{ application_count }}
|
||||
</p>
|
||||
<table class="pf-c-table pf-m-compact" role="grid">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">{% trans 'Application' %}</th>
|
||||
<th role="columnheader" scope="col">{% trans 'Logins' %}</th>
|
||||
<th role="columnheader" scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{% for app in most_used_applications %}
|
||||
<tr role="row">
|
||||
<td role="cell">
|
||||
{{ app.application.name }}
|
||||
</td>
|
||||
<td role="cell">
|
||||
{{ app.total_logins }}
|
||||
</td>
|
||||
<td role="cell">
|
||||
<progress value="{{ app.total_logins }}" max="{{ most_used_applications.0.total_logins }}"></progress>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'passbook_admin:sources' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-middleware"></i> {% trans 'Sources' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<p class="aggregate-status">
|
||||
<i class="fa fa-check-circle"></i> {{ source_count }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:providers' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-plugged"></i> {% trans 'Providers' %}
|
||||
|
@ -54,42 +73,9 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-plugged"></i> {% trans 'Stages' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
{% if stage_count < 1 %}
|
||||
<p class="aggregate-status">
|
||||
<i class="pficon-error-circle-o"></i> {{ stage_count }}
|
||||
</p>
|
||||
<p>{% trans 'No Stages configured. No Users will be able to login.' %}"></p>
|
||||
{% else %}
|
||||
<p class="aggregate-status">
|
||||
<i class="fa fa-check-circle"></i> {{ stage_count }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:stages' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-topology"></i> {% trans 'Flows' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<p class="aggregate-status">
|
||||
<i class="fa fa-check-circle"></i> {{ flow_count }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:policies' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-infrastructure"></i> {% trans 'Policies' %}
|
||||
|
@ -107,22 +93,9 @@
|
|||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'passbook_admin:stage-invitations' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-migration"></i> {% trans 'Invitation' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<p class="aggregate-status">
|
||||
<i class="fa fa-check-circle"></i> {{ invitation_count }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'passbook_admin:users' %}" class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-user"></i> {% trans 'Users' %}
|
||||
|
@ -133,9 +106,9 @@
|
|||
<i class="fa fa-check-circle"></i> {{ user_count }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-bundle"></i> {% trans 'Version' %}
|
||||
|
@ -161,7 +134,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-server"></i> {% trans 'Workers' %}
|
||||
|
@ -189,7 +162,7 @@
|
|||
</fetch-fill-slot>
|
||||
</div>
|
||||
|
||||
<a class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact" data-target="modal" data-modal="clearCacheModalRoot">
|
||||
<a class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-hoverable pf-m-compact" data-target="modal" data-modal="clearCacheModalRoot">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-server"></i> {% trans 'Cached Policies' %}
|
||||
|
@ -209,7 +182,7 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-m-hoverable pf-m-compact">
|
||||
<div class="pf-c-card pf-c-card-aggregate pf-l-gallery__item pf-m-compact">
|
||||
<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<i class="pf-icon pf-icon-server"></i> {% trans 'Cached Flows' %}
|
||||
|
@ -262,4 +235,65 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'node_modules/chart.js/dist/Chart.bundle.min.js' %}"></script>
|
||||
<script>
|
||||
var ctx = document.getElementById('logins-last-metrics').getContext('2d');
|
||||
fetch("{% url 'passbook_api:admin_metrics-list' %}").then(r => r.json()).then(r => {
|
||||
var myChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Failed Logins',
|
||||
backgroundColor: "rgba(201, 25, 11, .5)",
|
||||
spanGaps: true,
|
||||
data: r.logins_failed_per_1h,
|
||||
},
|
||||
{
|
||||
label: 'Successful Logins',
|
||||
backgroundColor: "rgba(189, 229, 184, .5)",
|
||||
spanGaps: true,
|
||||
data: r.logins_per_1h,
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
spanGaps: true,
|
||||
scales: {
|
||||
xAxes: [{
|
||||
stacked: true,
|
||||
gridLines: {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
type: 'time',
|
||||
offset: true,
|
||||
ticks: {
|
||||
callback: function (value, index, values) {
|
||||
const date = new Date();
|
||||
const delta = (date - values[index].value);
|
||||
const ago = Math.round(delta / 1000 / 3600);
|
||||
console.log(ago);
|
||||
return `${ago} Hours ago`;
|
||||
},
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
},
|
||||
stacked: true,
|
||||
gridLines: {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -3,6 +3,8 @@ from typing import Union
|
|||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Count
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from django.shortcuts import redirect, reverse
|
||||
from django.views.generic import TemplateView
|
||||
from packaging.version import LegacyVersion, Version, parse
|
||||
|
@ -10,10 +12,9 @@ from packaging.version import LegacyVersion, Version, parse
|
|||
from passbook import __version__
|
||||
from passbook.admin.mixins import AdminRequiredMixin
|
||||
from passbook.admin.tasks import VERSION_CACHE_KEY, update_latest_version
|
||||
from passbook.core.models import Application, Provider, Source, User
|
||||
from passbook.flows.models import Flow, Stage
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Provider, User
|
||||
from passbook.policies.models import Policy
|
||||
from passbook.stages.invitation.models import Invitation
|
||||
|
||||
|
||||
class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
||||
|
@ -37,17 +38,27 @@ class AdministrationOverviewView(AdminRequiredMixin, TemplateView):
|
|||
return parse(__version__)
|
||||
return parse(version_in_cache)
|
||||
|
||||
def get_most_used_applications(self):
|
||||
"""Get Most used applications, total login counts and unique users that have used them."""
|
||||
return (
|
||||
Event.objects.filter(action=EventAction.AUTHORIZE_APPLICATION)
|
||||
.exclude(context__authorized_application=None)
|
||||
.annotate(application=KeyTextTransform("authorized_application", "context"))
|
||||
.annotate(user_pk=KeyTextTransform("pk", "user"))
|
||||
.values("application")
|
||||
.annotate(total_logins=Count("application"))
|
||||
.annotate(unique_users=Count("user_pk", distinct=True))
|
||||
.values("unique_users", "application", "total_logins")
|
||||
.order_by("-total_logins")[:15]
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs["application_count"] = len(Application.objects.all())
|
||||
kwargs["policy_count"] = len(Policy.objects.all())
|
||||
kwargs["user_count"] = len(User.objects.all()) - 1 # Remove anonymous user
|
||||
kwargs["provider_count"] = len(Provider.objects.all())
|
||||
kwargs["source_count"] = len(Source.objects.all())
|
||||
kwargs["stage_count"] = len(Stage.objects.all())
|
||||
kwargs["flow_count"] = len(Flow.objects.all())
|
||||
kwargs["invitation_count"] = len(Invitation.objects.all())
|
||||
kwargs["version"] = parse(__version__)
|
||||
kwargs["version_latest"] = self.get_latest_version()
|
||||
kwargs["most_used_applications"] = self.get_most_used_applications()
|
||||
kwargs["providers_without_application"] = Provider.objects.filter(
|
||||
application=None
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ from drf_yasg.views import get_schema_view
|
|||
from rest_framework import routers
|
||||
|
||||
from passbook.admin.api.overview import AdministrationOverviewViewSet
|
||||
from passbook.admin.api.overview_metrics import AdministrationMetricsViewSet
|
||||
from passbook.api.permissions import CustomObjectPermissions
|
||||
from passbook.api.v2.messages import MessagesViewSet
|
||||
from passbook.audit.api import EventViewSet
|
||||
|
@ -55,6 +56,7 @@ router.register("root/messages", MessagesViewSet, basename="messages")
|
|||
router.register(
|
||||
"admin/overview", AdministrationOverviewViewSet, basename="admin_overview"
|
||||
)
|
||||
router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
|
||||
|
||||
router.register("core/applications", ApplicationViewSet)
|
||||
router.register("core/groups", GroupViewSet)
|
||||
|
|
|
@ -10,6 +10,7 @@ from django.utils import timezone
|
|||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application
|
||||
from passbook.flows.models import in_memory_stage
|
||||
from passbook.flows.planner import (
|
||||
|
@ -226,6 +227,11 @@ class OAuthFulfillmentStage(StageView):
|
|||
"consent_required",
|
||||
self.params.grant_type,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
).from_http(self.request)
|
||||
return redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
self.executor.stage_invalid()
|
||||
|
|
18
passbook/static/static/package-lock.json
generated
18
passbook/static/static/package-lock.json
generated
|
@ -167,6 +167,15 @@
|
|||
"supports-color": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.3.tgz",
|
||||
"integrity": "sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==",
|
||||
"requires": {
|
||||
"chartjs-color": "^2.1.0",
|
||||
"moment": "^2.10.2"
|
||||
}
|
||||
},
|
||||
"clean-css": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz",
|
||||
|
@ -185,7 +194,6 @@
|
|||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
|
@ -193,8 +201,7 @@
|
|||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||
"dev": true
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
},
|
||||
"commander": {
|
||||
"version": "2.20.3",
|
||||
|
@ -349,6 +356,11 @@
|
|||
"parse-literals": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.0",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.0.tgz",
|
||||
"integrity": "sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA=="
|
||||
},
|
||||
"no-case": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz",
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^5.15.0",
|
||||
"@patternfly/patternfly": "^4.42.2",
|
||||
"chart.js": "^2.9.3",
|
||||
"codemirror": "^5.58.1",
|
||||
"lit-element": "^2.4.0",
|
||||
"lit-html": "^1.3.0",
|
||||
|
|
26
swagger.yaml
26
swagger.yaml
|
@ -19,6 +19,21 @@ securityDefinitions:
|
|||
security:
|
||||
- token: []
|
||||
paths:
|
||||
/admin/metrics/:
|
||||
get:
|
||||
operationId: admin_metrics_list
|
||||
description: Return single instance of AdministrationMetricsSerializer
|
||||
parameters: []
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/AdministrationMetrics'
|
||||
tags:
|
||||
- admin
|
||||
parameters: []
|
||||
/admin/overview/:
|
||||
get:
|
||||
operationId: admin_overview_list
|
||||
|
@ -5958,6 +5973,17 @@ paths:
|
|||
type: string
|
||||
format: uuid
|
||||
definitions:
|
||||
AdministrationMetrics:
|
||||
type: object
|
||||
properties:
|
||||
logins_per_1h:
|
||||
title: Logins per 1h
|
||||
type: string
|
||||
readOnly: true
|
||||
logins_failed_per_1h:
|
||||
title: Logins failed per 1h
|
||||
type: string
|
||||
readOnly: true
|
||||
AdministrationOverview:
|
||||
type: object
|
||||
properties:
|
||||
|
|
Reference in a new issue