events: rework metrics (#4407)
* rework metrics Signed-off-by: Jens Langhammer <jens@goauthentik.io> * change graphs to be over last week Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Apps with most usage card Signed-off-by: Jens Langhammer <jens@goauthentik.io> Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
a35b8f5862
commit
67a6fa6399
|
@ -1,4 +1,7 @@
|
|||
"""authentik administration metrics"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models.functions import ExtractDay
|
||||
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
|
||||
|
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
|
|||
class LoginMetricsSerializer(PassiveSerializer):
|
||||
"""Login Metrics per 1h"""
|
||||
|
||||
logins_per_1h = SerializerMethodField()
|
||||
logins_failed_per_1h = SerializerMethodField()
|
||||
authorizations_per_1h = SerializerMethodField()
|
||||
logins = SerializerMethodField()
|
||||
logins_failed = SerializerMethodField()
|
||||
authorizations = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_per_1h(self, _):
|
||||
"""Get successful logins per hour for the last 24 hours"""
|
||||
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)
|
||||
.get_events_per_hour()
|
||||
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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_failed_per_1h(self, _):
|
||||
"""Get failed logins per hour for the last 24 hours"""
|
||||
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)
|
||||
.get_events_per_hour()
|
||||
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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_authorizations_per_1h(self, _):
|
||||
"""Get successful authorizations per hour for the last 24 hours"""
|
||||
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)
|
||||
.get_events_per_hour()
|
||||
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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
"""Application API Views"""
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.functions import ExtractDay
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
@ -259,10 +261,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||
"""Metrics for application logins"""
|
||||
app = self.get_object()
|
||||
return Response(
|
||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||
.filter(
|
||||
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||
action=EventAction.AUTHORIZE_APPLICATION,
|
||||
context__authorized_application__pk=app.pk.hex,
|
||||
)
|
||||
.get_events_per_hour()
|
||||
# 3 data points per day, so 8 hour spans
|
||||
.get_events_per(timedelta(days=7), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
|
|
@ -4,6 +4,7 @@ from json import loads
|
|||
from typing import Any, Optional
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.db.models.functions import ExtractDay
|
||||
from django.db.models.query import QuerySet
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
|
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
|
|||
class UserMetricsSerializer(PassiveSerializer):
|
||||
"""User Metrics"""
|
||||
|
||||
logins_per_1h = SerializerMethodField()
|
||||
logins_failed_per_1h = SerializerMethodField()
|
||||
authorizations_per_1h = SerializerMethodField()
|
||||
logins = SerializerMethodField()
|
||||
logins_failed = SerializerMethodField()
|
||||
authorizations = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_per_1h(self, _):
|
||||
"""Get successful logins per hour for the last 24 hours"""
|
||||
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, user__pk=user.pk)
|
||||
.get_events_per_hour()
|
||||
get_objects_for_user(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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_logins_failed_per_1h(self, _):
|
||||
"""Get failed logins per hour for the last 24 hours"""
|
||||
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, context__username=user.username)
|
||||
.get_events_per_hour()
|
||||
get_objects_for_user(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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
@extend_schema_field(CoordinateSerializer(many=True))
|
||||
def get_authorizations_per_1h(self, _):
|
||||
"""Get failed logins per hour for the last 24 hours"""
|
||||
def get_authorizations(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.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
||||
.get_events_per_hour()
|
||||
get_objects_for_user(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), ExtractDay, 7 * 3)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
"""Events API Views"""
|
||||
from datetime import timedelta
|
||||
from json import loads
|
||||
|
||||
import django_filters
|
||||
from django.db.models.aggregates import Count
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from django.db.models.functions import ExtractDay
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
|
@ -178,7 +180,7 @@ class EventViewSet(ModelViewSet):
|
|||
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||
.filter(action=filtered_action)
|
||||
.filter(**query)
|
||||
.get_events_per_day()
|
||||
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
||||
)
|
||||
|
||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||
|
|
|
@ -11,8 +11,7 @@ from django.conf import settings
|
|||
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.functions import Extract
|
||||
from django.db.models.manager import Manager
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest
|
||||
|
@ -111,48 +110,36 @@ class EventAction(models.TextChoices):
|
|||
class EventQuerySet(QuerySet):
|
||||
"""Custom events query set with helper functions"""
|
||||
|
||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
||||
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"""
|
||||
date_from = now() - timedelta(days=1)
|
||||
_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_hours=ExtractHour("age"))
|
||||
.values("age_hours")
|
||||
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
||||
.annotate(age_interval=extract("age"))
|
||||
.values("age_interval")
|
||||
.annotate(count=Count("pk"))
|
||||
.order_by("age_hours")
|
||||
.order_by("age_interval")
|
||||
)
|
||||
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
||||
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
||||
results = []
|
||||
_now = now()
|
||||
for hour in range(0, -24, -1):
|
||||
interval_timdelta = time_since / data_points
|
||||
for interval in range(1, -data_points, -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],
|
||||
"x_cord": time.mktime((_now + (interval_timdelta * interval)).timetuple())
|
||||
* 1000,
|
||||
"y_cord": data[interval * -1],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
@ -165,13 +152,14 @@ class EventManager(Manager):
|
|||
"""use custom queryset"""
|
||||
return EventQuerySet(self.model, using=self._db)
|
||||
|
||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
||||
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_hour()
|
||||
|
||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
||||
"""Wrap method from queryset"""
|
||||
return self.get_queryset().get_events_per_day()
|
||||
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
||||
|
||||
|
||||
class Event(SerializerModel, ExpiringModel):
|
||||
|
|
24
schema.yml
24
schema.yml
|
@ -28957,25 +28957,25 @@ components:
|
|||
type: object
|
||||
description: Login Metrics per 1h
|
||||
properties:
|
||||
logins_per_1h:
|
||||
logins:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
logins_failed_per_1h:
|
||||
logins_failed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
authorizations_per_1h:
|
||||
authorizations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
required:
|
||||
- authorizations_per_1h
|
||||
- logins_failed_per_1h
|
||||
- logins_per_1h
|
||||
- authorizations
|
||||
- logins
|
||||
- logins_failed
|
||||
LoginSource:
|
||||
type: object
|
||||
description: Serializer for Login buttons of sources
|
||||
|
@ -37741,25 +37741,25 @@ components:
|
|||
type: object
|
||||
description: User Metrics
|
||||
properties:
|
||||
logins_per_1h:
|
||||
logins:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
logins_failed_per_1h:
|
||||
logins_failed:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
authorizations_per_1h:
|
||||
authorizations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Coordinate'
|
||||
readOnly: true
|
||||
required:
|
||||
- authorizations_per_1h
|
||||
- logins_failed_per_1h
|
||||
- logins_per_1h
|
||||
- authorizations
|
||||
- logins
|
||||
- logins_failed
|
||||
UserOAuthSourceConnection:
|
||||
type: object
|
||||
description: OAuth Source Serializer
|
||||
|
|
|
@ -180,7 +180,7 @@ export class AdminOverviewPage extends AKElement {
|
|||
>
|
||||
<ak-aggregate-card
|
||||
icon="pf-icon pf-icon-server"
|
||||
header=${t`Logins and authorizations over the last 24 hours`}
|
||||
header=${t`Logins and authorizations over the last week (per 8 hours)`}
|
||||
>
|
||||
<ak-charts-admin-login-authorization></ak-charts-admin-login-authorization>
|
||||
</ak-aggregate-card>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { AKChart, RGBAColor } from "@goauthentik/elements/charts/Chart";
|
||||
import { ChartData } from "chart.js";
|
||||
import { ChartData, Tick } from "chart.js";
|
||||
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
|
@ -14,6 +14,13 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
|
|||
return new AdminApi(DEFAULT_CONFIG).adminMetricsRetrieve();
|
||||
}
|
||||
|
||||
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
|
||||
const valueStamp = ticks[index];
|
||||
const delta = Date.now() - valueStamp.value;
|
||||
const ago = Math.round(delta / 1000 / 3600 / 24);
|
||||
return t`${ago} day(s) ago`;
|
||||
}
|
||||
|
||||
getChartData(data: LoginMetrics): ChartData {
|
||||
return {
|
||||
datasets: [
|
||||
|
@ -25,7 +32,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
|
|||
fill: "origin",
|
||||
cubicInterpolationMode: "monotone",
|
||||
tension: 0.4,
|
||||
data: data.authorizationsPer1h.map((cord) => {
|
||||
data: data.authorizations.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord,
|
||||
y: cord.yCord,
|
||||
|
@ -40,7 +47,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
|
|||
fill: "origin",
|
||||
cubicInterpolationMode: "monotone",
|
||||
tension: 0.4,
|
||||
data: data.loginsFailedPer1h.map((cord) => {
|
||||
data: data.loginsFailed.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord,
|
||||
y: cord.yCord,
|
||||
|
@ -55,7 +62,7 @@ export class AdminLoginAuthorizeChart extends AKChart<LoginMetrics> {
|
|||
fill: "origin",
|
||||
cubicInterpolationMode: "monotone",
|
||||
tension: 0.4,
|
||||
data: data.loginsPer1h.map((cord) => {
|
||||
data: data.logins.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord,
|
||||
y: cord.yCord,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 { t } from "@lingui/macro";
|
||||
|
||||
|
@ -19,6 +19,13 @@ export class ApplicationAuthorizeChart extends AKChart<Coordinate[]> {
|
|||
});
|
||||
}
|
||||
|
||||
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
|
||||
const valueStamp = ticks[index];
|
||||
const delta = Date.now() - valueStamp.value;
|
||||
const ago = Math.round(delta / 1000 / 3600 / 24);
|
||||
return t`${ago} days ago`;
|
||||
}
|
||||
|
||||
getChartData(data: Coordinate[]): ChartData {
|
||||
return {
|
||||
datasets: [
|
||||
|
|
|
@ -225,7 +225,9 @@ export class ApplicationViewPage extends AKElement {
|
|||
<div
|
||||
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl"
|
||||
>
|
||||
<div class="pf-c-card__title">${t`Logins over the last 24 hours`}</div>
|
||||
<div class="pf-c-card__title">
|
||||
${t`Logins over the last week (per 8 hours)`}
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
${this.application &&
|
||||
html` <ak-charts-application-authorize
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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 { t } from "@lingui/macro";
|
||||
|
||||
|
@ -19,6 +19,13 @@ export class UserChart extends AKChart<UserMetrics> {
|
|||
});
|
||||
}
|
||||
|
||||
timeTickCallback(tickValue: string | number, index: number, ticks: Tick[]): string {
|
||||
const valueStamp = ticks[index];
|
||||
const delta = Date.now() - valueStamp.value;
|
||||
const ago = Math.round(delta / 1000 / 3600 / 24);
|
||||
return t`${ago} days ago`;
|
||||
}
|
||||
|
||||
getChartData(data: UserMetrics): ChartData {
|
||||
return {
|
||||
datasets: [
|
||||
|
@ -27,7 +34,7 @@ export class UserChart extends AKChart<UserMetrics> {
|
|||
backgroundColor: "rgba(201, 25, 11, .5)",
|
||||
spanGaps: true,
|
||||
data:
|
||||
data.loginsFailedPer1h?.map((cord) => {
|
||||
data.loginsFailed?.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord || 0,
|
||||
y: cord.yCord || 0,
|
||||
|
@ -39,7 +46,7 @@ export class UserChart extends AKChart<UserMetrics> {
|
|||
backgroundColor: "rgba(189, 229, 184, .5)",
|
||||
spanGaps: true,
|
||||
data:
|
||||
data.loginsPer1h?.map((cord) => {
|
||||
data.logins?.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord || 0,
|
||||
y: cord.yCord || 0,
|
||||
|
@ -51,7 +58,7 @@ export class UserChart extends AKChart<UserMetrics> {
|
|||
backgroundColor: "rgba(43, 154, 243, .5)",
|
||||
spanGaps: true,
|
||||
data:
|
||||
data.authorizationsPer1h?.map((cord) => {
|
||||
data.authorizations?.map((cord) => {
|
||||
return {
|
||||
x: cord.xCord || 0,
|
||||
y: cord.yCord || 0,
|
||||
|
|
|
@ -276,7 +276,9 @@ export class UserViewPage extends AKElement {
|
|||
<div
|
||||
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-9-col-on-xl pf-m-9-col-on-2xl"
|
||||
>
|
||||
<div class="pf-c-card__title">${t`Actions over the last 24 hours`}</div>
|
||||
<div class="pf-c-card__title">
|
||||
${t`Actions over the last week (per 8 hours)`}
|
||||
</div>
|
||||
<div class="pf-c-card__body">
|
||||
<ak-charts-user userId=${this.user.pk || 0}> </ak-charts-user>
|
||||
</div>
|
||||
|
|
|
@ -40,6 +40,9 @@ export class AggregateCard extends AKElement {
|
|||
.subtext {
|
||||
font-size: var(--pf-global--FontSize--sm);
|
||||
}
|
||||
.pf-c-card__body {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
`,
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@ export abstract class AKChart<T> extends AKElement {
|
|||
const valueStamp = ticks[index];
|
||||
const delta = Date.now() - valueStamp.value;
|
||||
const ago = Math.round(delta / 1000 / 3600);
|
||||
return t`${ago} hours ago`;
|
||||
return t`${ago} hour(s) ago`;
|
||||
}
|
||||
|
||||
getOptions(): ChartOptions {
|
||||
|
|
Reference in New Issue