openvidu-dashboard: chart axes, skeleton delay, rounded durations, About storage and track IDs in the search

Adds the ui-review-fixes change: x-axis ticks never closer than their labels, whole-number ticks for counts and the last step drawn; loading placeholders invisible for 300 ms; ended durations rounded; About dates more collections and adds a Total row; the footer version is plain text; the room averages explain a range without ended rooms; the Egress tile reads Total duration; browser and OS labels; and an exact track ID opens a locked track route.
master
juancarmore 2026-11-03 11:26:40 +01:00
parent 88d7a941e3
commit efba660a59
45 changed files with 1214 additions and 67 deletions

View File

@ -1,4 +1,4 @@
import type { Db, Document } from 'mongodb';
import type { Db, Document, FindCursor } from 'mongodb';
import {
Collection,
@ -23,8 +23,40 @@ import type { StatsModeReader } from '../stats/stats-mode.js';
export const STORAGE_CACHE_MS = 60_000;
/** Field whose earliest value dates a collection, when an index starts with it. */
const OLDEST_FIELDS = ['ts', 'bucket', 'createdAt'] as const;
/**
* Field that dates each collection, read through an index that leads with it (the `global` scope for the daily rollups);
* `nodes` holds one document per node. Tracks and subscriptions have no such index, and the earliest stats sample
* would scan the buckets of the time series.
*/
const OLDEST: Partial<Record<string, { field: string; filter?: Document }>> = {
[Collection.ROOMS]: { field: 'createdAt' },
[Collection.PARTICIPANTS]: { field: 'joinedAt' },
[Collection.EGRESSES]: { field: 'startedAt' },
[Collection.INGRESSES]: { field: 'createdAt' },
[Collection.INGRESS_SESSIONS]: { field: 'startedAt' },
[Collection.EVENTS]: { field: 'ts' },
[Collection.ANALYTICS_5MIN]: { field: 'bucket' },
[Collection.ANALYTICS_HOURLY]: { field: 'bucket' },
[Collection.ANALYTICS_DAILY]: { field: 'bucket', filter: { scope: 'global' } },
[Collection.NODES]: { field: 'firstSeenAt' }
};
/** The query of the oldest document of a collection and its date field, or `null` for a collection without one. */
export function oldestQuery(db: Db, name: string): { cursor: FindCursor<Document>; field: string } | null {
const oldest = OLDEST[name];
if (oldest === undefined) {
return null;
}
const cursor = db
.collection(name)
.find({ ...oldest.filter, [oldest.field]: { $type: 'date' } }, { projection: { _id: 0, [oldest.field]: 1 } })
.sort({ [oldest.field]: 1 })
.limit(1);
return { cursor, field: oldest.field };
}
type SchemaMetaDocument =
SchemaMetaSchema | SchemaMetaMigration | SchemaMetaRetention | SchemaMetaCapabilities | SchemaMetaConfig;
@ -128,18 +160,11 @@ export class InfoService {
storageBytes: Number(storageStats.storageSize ?? 0),
indexBytes: Number(storageStats.totalIndexSize ?? 0)
};
const indexes = (await collection.listIndexes().toArray()) as { key: Document }[];
const field = OLDEST_FIELDS.find((candidate) =>
indexes.some((index) => Object.keys(index.key)[0] === candidate)
);
const query = oldestQuery(this.db, name);
if (field !== undefined) {
const [oldest] = await collection
.find({ [field]: { $type: 'date' } }, { projection: { [field]: 1 } })
.sort({ [field]: 1 })
.limit(1)
.toArray();
const value: unknown = oldest?.[field];
if (query !== null) {
const [oldest] = await query.cursor.toArray();
const value: unknown = oldest?.[query.field];
if (value instanceof Date) {
entry.oldest = value.toISOString();

View File

@ -0,0 +1,80 @@
import { generate } from '@openvidu-dashboard/data-generator/generator';
import { Collection } from '@openvidu-dashboard/typings';
import { type Db, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { oldestQuery } from '../../src/info/info-service.js';
import { expectIndexBacked } from '../support/query-plan.js';
/** The oldest-document query of every collection About dates, explained on a generated database. */
const mongoUri = inject('mongoUri');
const DATABASE = 'dashboard_test_info_plan';
const DATED = [
Collection.ROOMS,
Collection.PARTICIPANTS,
Collection.EGRESSES,
Collection.INGRESSES,
Collection.INGRESS_SESSIONS,
Collection.EVENTS,
Collection.ANALYTICS_5MIN,
Collection.ANALYTICS_HOURLY,
Collection.ANALYTICS_DAILY
];
describe.skipIf(mongoUri === '')('info oldest-document queries', () => {
let client: MongoClient;
let db: Db;
beforeAll(async () => {
client = new MongoClient(mongoUri);
db = client.db(DATABASE);
await db.dropDatabase();
await generate(db, {
scenario: 'S',
days: 1,
statsMode: 'participant',
seed: 1,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true }
});
}, 600_000);
afterAll(async () => {
await db.dropDatabase();
await client.close();
});
it.each(DATED)('%s reads one document through an index', async (name) => {
const query = oldestQuery(db, name);
if (query === null) {
throw new Error(`${name} has no oldest-document query`);
}
await expectIndexBacked(db, query.cursor.clone(), { maxDocsExamined: 1, collscanThreshold: 0 });
const [oldest] = await query.cursor.toArray();
expect(oldest?.[query.field]).toBeInstanceOf(Date);
});
it('dates nodes by their first heartbeat without an index', async () => {
const query = oldestQuery(db, Collection.NODES);
if (query === null) {
throw new Error('nodes has no oldest-document query');
}
expect(query.field).toBe('firstSeenAt');
await expectIndexBacked(db, query.cursor.clone(), { maxDocsExamined: 10, collscanThreshold: 10 });
});
it('leaves tracks, subscriptions and stats undated', () => {
expect(oldestQuery(db, Collection.TRACKS)).toBeNull();
expect(oldestQuery(db, Collection.TRACK_SUBSCRIPTIONS)).toBeNull();
expect(oldestQuery(db, Collection.TRACK_STATS)).toBeNull();
});
});

View File

@ -32,7 +32,7 @@ test('egress against the real backend: seeded list, filters, the locked detail a
await expect(
table.getByRole('row', { name: new RegExp(RECONCILED_EGRESS) }).getByRole('img', { name: /Egress failed/ })
).toHaveCount(0);
await expect(page.getByRole('region', { name: 'Summary' })).toContainText('Recorded time');
await expect(page.getByRole('region', { name: 'Summary' })).toContainText('Total duration');
await page.getByRole('combobox', { name: 'Status' }).click();
await page.getByRole('option', { name: 'Failed' }).click();

View File

@ -81,6 +81,29 @@ test('a failed list keeps the error banner with retry', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('a slow first response reveals the skeleton only after 300 ms', async ({ page }) => {
let release = (): void => undefined;
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v1/egresses?*', async (route) => {
await held;
await route.fallback();
});
await page.goto('/egress?from=now-24h&to=now&tz=UTC');
const skeleton = page.locator('tr.skeleton-row').first();
await expect(skeleton).toBeAttached();
expect(await skeleton.evaluate((element) => getComputedStyle(element).animationDelay)).toBe('0.3s');
await expect.poll(() => skeleton.evaluate((element) => getComputedStyle(element).opacity)).toBe('1');
release();
await expect(page.getByRole('table', { name: 'Egress' }).getByRole('row')).not.toHaveCount(0);
await expect(page.locator('tr.skeleton-row')).toHaveCount(0);
});
test('the egress detail is locked in CE with the header from the list', async ({ page, api }) => {
await page.goto('/egress?from=now-24h&to=now&tz=UTC');
await page.getByRole('link', { name: 'EG_a1b2' }).click();

View File

@ -28,6 +28,17 @@ test('CE shows no locked sidenav entry and the full-page lock on PRO routes with
expect(detailCalls).toEqual([]);
});
test('CE opens the track route of an exact track ID as the full-page lock without requesting the track', async ({
page,
api
}) => {
await page.goto('/tracks/TR_a2b3');
await expect(page.getByText('Participant detail is available in OpenVidu PRO')).toBeVisible();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('TR_a2b3');
expect(api.calls().filter((call) => call.startsWith('GET tracks'))).toEqual([]);
});
test.describe('PRO', () => {
test.use({ apiOptions: { edition: 'pro' } });

View File

@ -32,7 +32,8 @@ application adds its own manifest to the library one.
- **Routes.** `DASHBOARD_ROUTES` are `DashboardRoute` configs; `composeRoutes(...configs)` lets a later config replace
any route by path (children merge by path) while keeping the `data` keys it does not set. A page of an extending
application replaces a locked CE page by declaring the same path, e.g. `{ path: '', children: [{ path: 'rooms', children: [{ path: ':roomSid',
loadComponent: … }] }] }`.
loadComponent: … }] }] }`. The locked `tracks/:trackSid` route, outside the sidenav, is the one the global search
opens for an exact track ID; an edition with track details replaces it.
- **Navigation metadata.** `data.navigation` is a `NavigationMeta` (label, icon, path, order, `feature`, `liveCapable`,
`rangeScoped`, and on range-scoped routes `retention`, the `GET /api/v1/info` retention of the page's data that the
range picker warns about); orders leave gaps of 10 for new entries. Detail routes declare `data.page` (`PageMeta`),

View File

@ -247,10 +247,35 @@ export function chartSummary(
return `${title}, ${range} (${options.tz}). ${parts.join('; ')}.`;
}
const MINUTE_MS = 60_000;
const DAY_MS = 86_400_000;
function axisLabel(value: number, tz: string, spanMs: number): string {
const date = new TZDate(value, tz);
return format(date, spanMs <= 86_400_000 ? 'HH:mm' : spanMs <= 7 * 86_400_000 ? 'MM-dd HH:mm' : 'MM-dd');
return format(date, spanMs <= DAY_MS ? 'HH:mm' : spanMs <= 7 * DAY_MS ? 'MM-dd HH:mm' : 'MM-dd');
}
/** The unit the axis labels of a span show (minutes up to 7 days, then days): ticks never come closer. */
function axisLabelUnitMs(spanMs: number): number {
return spanMs <= 7 * DAY_MS ? MINUTE_MS : DAY_MS;
}
/** Every value of every series is a whole number (nulls aside), as counts are. */
function integerSeries(series: readonly ChartSeries[]): boolean {
return series.every((item) => item.points.every(([, value]) => value === null || Number.isInteger(value)));
}
/** The points of a series, a step series extended to `to` with its last value so its last bucket is drawn. */
function seriesData(series: ChartSeries, kind: SeriesKind, to: number): [number, number | null][] {
const data = series.points.map(([ts, value]): [number, number | null] => [new Date(ts).getTime(), value]);
const last = data.at(-1);
if (kind === 'step' && last !== undefined && last[1] !== null && last[0] < to) {
data.push([to, last[1]]);
}
return data;
}
/** ECharts option of a time series fixed to `from`–`to`, with gaps for missing buckets and no zoom. */
@ -277,6 +302,7 @@ export function timeSeriesOption(options: TimeSeriesOptions): EChartsCoreOption
type: 'time',
min: from,
max: to,
minInterval: axisLabelUnitMs(to - from),
axisLabel: {
color: colors.text,
formatter: (value: number) => axisLabel(value, options.tz, to - from),
@ -287,6 +313,7 @@ export function timeSeriesOption(options: TimeSeriesOptions): EChartsCoreOption
},
yAxis: {
type: 'value',
...(integerSeries(options.series) ? { minInterval: 1 } : {}),
...(options.yRange ?? {}),
axisLabel: { color: colors.text, formatter: (value: number) => formatValue(value, options.format) },
splitLine: { lineStyle: { color: colors.grid } }
@ -310,7 +337,7 @@ export function timeSeriesOption(options: TimeSeriesOptions): EChartsCoreOption
return {
name: series.name,
type: options.kind === 'bar' ? 'bar' : 'line',
data: series.points.map(([ts, value]) => [new Date(ts).getTime(), value]),
data: seriesData(series, options.kind, to),
connectNulls: false,
showSymbol: false,
symbolSize: 8,

View File

@ -74,6 +74,55 @@ describe('chart options', () => {
expect(option.yAxis).not.toHaveProperty('name');
});
describe('axes and steps', () => {
const counts = (
points: [string, number | null][],
kind: 'step' | 'line' = 'step',
from = '2026-10-13T10:06:00.000Z',
to = '2026-10-13T10:09:50.000Z'
) =>
timeSeriesOption({
series: [{ name: 'Active participants', points }],
from,
to,
tz: 'UTC',
format: 'number',
kind,
colors: COLORS,
animation: false
}) as {
xAxis: { minInterval: number };
yAxis: { minInterval?: number };
series: { data: [number, number | null][] }[];
};
const minutes: [string, number | null][] = [
['2026-10-13T10:06:00.000Z', 3],
['2026-10-13T10:07:00.000Z', 3],
['2026-10-13T10:08:00.000Z', 2],
['2026-10-13T10:09:00.000Z', 1]
];
it('never ticks the x axis closer than the unit of its labels', () => {
expect(counts(minutes, 'step', '2026-10-13T10:06:20.000Z').xAxis.minInterval).toBe(60_000);
expect(
counts(minutes, 'line', '2026-10-01T00:00:00.000Z', '2026-10-13T10:09:50.000Z').xAxis.minInterval
).toBe(86_400_000);
});
it('ticks the y axis at whole numbers only when every value is an integer', () => {
expect(counts(minutes).yAxis.minInterval).toBe(1);
expect(counts([...minutes, ['2026-10-13T10:09:30.000Z', 0.5]]).yAxis).not.toHaveProperty('minInterval');
});
it('holds the last value of a step series until the end of the range', () => {
expect(counts(minutes).series[0]?.data.at(-1)).toEqual([Date.parse('2026-10-13T10:09:50.000Z'), 1]);
expect(counts(minutes, 'line').series[0]?.data).toHaveLength(4);
expect(counts([...minutes.slice(0, 3), ['2026-10-13T10:09:00.000Z', null]]).series[0]?.data.at(-1)).toEqual(
[Date.parse('2026-10-13T10:09:00.000Z'), null]
);
});
});
it('draws at most 50 markers and otherwise reports the count', () => {
const markers = (count: number) =>
Array.from({ length: count }, (_, i) => ({

View File

@ -202,7 +202,7 @@
<tbody>
@if (skeletonRows()) {
@for (index of skeletonIndexes(); track index) {
<tr class="skeleton-row" aria-hidden="true">
<tr class="skeleton-row ovd-skeleton-reveal" aria-hidden="true">
@for (column of visible(); track column.key) {
<td><span class="bar"></span></td>
}

View File

@ -72,6 +72,23 @@ describe('Duration', () => {
expect(element.textContent).toBe('—');
});
it('counts whole seconds while running and rounds the stored duration once ended', async () => {
vi.useFakeTimers({ now: NOW, toFake: ['Date', 'setInterval', 'clearInterval'] });
const fixture = TestBed.createComponent(Duration);
const element = fixture.nativeElement as HTMLElement;
fixture.componentRef.setInput('start', new Date(NOW.getTime() - 2_700).toISOString());
fixture.componentRef.setInput('running', true);
await fixture.whenStable();
expect(element.textContent).toBe('2 s');
fixture.componentRef.setInput('running', false);
fixture.componentRef.setInput('storedMs', 2_700);
await fixture.whenStable();
expect(element.textContent).toBe('3 s');
});
it('adds the running part to a stored total', async () => {
vi.useFakeTimers({ now: NOW, toFake: ['Date', 'setInterval', 'clearInterval'] });

View File

@ -77,8 +77,11 @@ export class Duration {
protected readonly text = computed(() => {
const total = this.totalMs();
// A running duration counts whole elapsed units; an ended one is rounded.
const options = { floor: this.running() };
if (total !== undefined) {
return formatDuration(liveTotalMs(total, this.running() ? this.start() : undefined, this.now()));
return formatDuration(liveTotalMs(total, this.running() ? this.start() : undefined, this.now()), options);
}
const ms = liveDurationMs(
@ -86,6 +89,6 @@ export class Duration {
this.running() ? this.now() : new Date(0)
);
return ms === undefined ? '—' : formatDuration(ms);
return ms === undefined ? '—' : formatDuration(ms, options);
});
}

View File

@ -2,9 +2,13 @@ import { Component, computed, input } from '@angular/core';
export type SkeletonVariant = 'rows' | 'tiles' | 'chart' | 'text';
/** Placeholder with the height of what it replaces, so content never jumps when it arrives. */
/**
* Placeholder with the height of what it replaces, so content never jumps when it arrives. It stays invisible for its
* first 300 ms, so a fast response never flashes it.
*/
@Component({
selector: 'ovd-skeleton',
host: { class: 'ovd-skeleton-reveal' },
template: `
<div class="ovd-visually-hidden" role="status">Loading</div>
@switch (variant()) {

View File

@ -101,11 +101,20 @@ export class LockedPage {
return entity === undefined || id === null ? undefined : this.previews.get(entity.kind, id);
});
protected readonly domain = computed(() => STATUS_DOMAINS[this.data.entity?.kind ?? 'room'] ?? 'room');
/** The name from the list (never for egress, titled by its ID), else the identifier, else the page label. */
/** The breadcrumb parameter of a route without an entity, such as the track route. */
private readonly breadcrumbId = computed(() => {
const breadcrumb = this.data.breadcrumb;
return typeof breadcrumb === 'object' ? this.route.snapshot.paramMap.get(breadcrumb.param) : null;
});
/**
* The name from the list (never for egress, titled by its ID), else the identifier, else the breadcrumb parameter,
* else the page label.
*/
protected readonly heading = computed(() => {
const name = this.data.entity?.kind === 'egress' ? undefined : this.preview()?.name;
return name ?? this.entityId() ?? this.data.navigation?.label ?? '';
return name ?? this.entityId() ?? this.breadcrumbId() ?? this.data.navigation?.label ?? '';
});
protected readonly copyLabel = computed(() => {
const kind = this.data.entity?.kind;

View File

@ -23,7 +23,7 @@ describe('sidTarget', () => {
['IN_c3d4', '/ingress/IN_c3d4'],
['dem', null],
['RM_', null],
['TR_abc', null]
['TR_abc', '/tracks/TR_abc']
])('maps %j to %s', (query, target) => {
expect(sidTarget(query)).toBe(target);
});
@ -86,6 +86,20 @@ describe('GlobalSearch', () => {
expect(TestBed.inject(Router).url).toMatch(/^\/participants\/PA_x1y2\?from=.+&to=now&tz=/);
});
it('offers to open an exact track ID on its track route', async () => {
type('TR_a2b3');
input().dispatchEvent(new Event('focusin'));
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS);
controller.expectNone(() => true);
harness.fixture.detectChanges();
expect(document.querySelector('mat-option.exact')?.textContent.trim()).toBe('Open track TR_a2b3');
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
await vi.advanceTimersByTimeAsync(50);
expect(TestBed.inject(Router).url).toMatch(/^\/tracks\/TR_a2b3\?/);
});
it('clears the box with "Clear search", keeping the focus and closing the results', async () => {
const clearButton = () =>
routeElement(harness).querySelector<HTMLButtonElement>(

View File

@ -108,12 +108,17 @@ export const SEARCH_SOURCES = new InjectionToken<readonly SearchSource[]>('SEARC
factory: () => DEFAULT_SEARCH_SOURCES
});
/** The entity of an exact ID with a detail page (`RM_`, `PA_`, `EG_`, `IN_`) and that page, or `null`. */
export function exactIdTarget(query: string): { kind: EntityKind; id: string; path: string } | null {
/** Path of the track route, which an edition with track details resolves to the publisher's page. */
export const TRACK_PATH = '/tracks';
/** The entity of an exact ID (`RM_`, `PA_`, `EG_`, `IN_`, `TR_`) and its page, or `null`. */
export function exactIdTarget(query: string): { kind: EntityKind | 'track'; id: string; path: string } | null {
const id = query.trim();
const kind = entityIdKind(id);
return kind === undefined || kind === 'track' ? null : { kind, id, path: `${DETAIL_PATHS[kind]}/${id}` };
return kind === undefined
? null
: { kind, id, path: `${kind === 'track' ? TRACK_PATH : DETAIL_PATHS[kind]}/${id}` };
}
/** Detail path of an exact ID, or `null`. */

View File

@ -42,7 +42,7 @@ export const TICKING_MS = 5_000;
</div>
@if (shown() === null) {
@if (loading()) {
<div class="skeleton" aria-hidden="true"></div>
<div class="skeleton ovd-skeleton-reveal" aria-hidden="true"></div>
<span class="ovd-visually-hidden">Loading {{ label() }}</span>
} @else {
<div class="value">—</div>

View File

@ -100,12 +100,12 @@
<ovd-kpi-tile
label="Average room size"
[value]="avgRoomSize()"
[hint]="missingHint(summary()?.avgRoomSize)"
[hint]="roomAverageHint(summary()?.avgRoomSize)"
/>
<ovd-kpi-tile
label="Average room duration"
[value]="avgRoomDuration()"
[hint]="missingHint(summary()?.avgRoomDurationMs)"
[hint]="roomAverageHint(summary()?.avgRoomDurationMs)"
/>
</ovd-tile-row>
<ovd-time-series-chart

View File

@ -288,7 +288,9 @@ describe('AnalyticsPage', () => {
expect(dimensionLabel('connectionType', 'turn')).toBe('TURN');
expect(dimensionLabel('connectionType', 'quic')).toBe('quic');
expect(dimensionLabel('ingressInputType', 'whip')).toBe('WHIP');
expect(dimensionLabel('browser', 'chrome')).toBe('chrome');
expect(dimensionLabel('browser', 'chrome')).toBe('Chrome');
expect(dimensionLabel('browser', 'none')).toBe('No browser');
expect(dimensionLabel('browser', 'other')).toBe('Other');
});
it('shows the Ingress section in CE, after Egress and right before the Quality card, with no divider', async () => {
@ -334,6 +336,21 @@ describe('AnalyticsPage', () => {
expect(size).toContain('Not provided by this server version');
});
it('says no room ended in the range when the room averages are missing for that reason', async () => {
const { avgRoomSize: _size, avgRoomDurationMs: _duration, ...withoutAverages } = SUMMARY;
const { element } = await open('/analytics?from=now-7d&to=now&tz=UTC', [], {
summary: { ...withoutAverages, roomsCreated: 2, roomsEnded: 0 }
});
const tiles = [...(card(element, 'Rooms')?.querySelectorAll('ovd-kpi-tile') ?? [])].map(text);
expect(tiles).toHaveLength(2);
for (const tile of tiles) {
expect(tile).toContain('n/a');
expect(tile).toContain('No room ended in this range');
}
});
it('says the data is not reported when no server of the range reports media statistics', async () => {
const { element } = await open('/analytics?from=now-7d&to=now&tz=UTC', [], {
summary: { ...SUMMARY, mediaStats: 'none' }

View File

@ -41,6 +41,7 @@ import { ToastService } from '../../core/ui/toast.js';
import { formatBytes, formatDuration, formatNumber, formatPercent } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js';
import {
BROWSER_LABELS,
CONNECTION_TYPE_LABELS,
EGRESS_TYPE_LABELS,
INGRESS_INPUT_LABELS,
@ -80,6 +81,7 @@ import {
} from './analytics-seams.js';
const NOT_PROVIDED = 'Not provided by this server version';
const NO_ROOM_ENDED = 'No room ended in this range';
function retentionLength(ms: number): string {
return formatRetentionLength(ms, { formatCount: formatNumber });
@ -118,10 +120,11 @@ const SERIES_LABELS: Partial<Record<AnalyticsMetric, { label: string; entity: st
ingressSessions: { label: 'Sessions', entity: 'sessions' }
};
/** Labels of the breakdown keys of the CE dimensions, matched case-insensitively; browsers read as stored. */
/** Labels of the breakdown keys of the CE dimensions, matched case-insensitively. */
const DIMENSION_LABELS: Partial<Record<AnalyticsDimension, Readonly<Record<string, string>>>> = {
sdk: lowerKeys(SDK_LABELS),
os: lowerKeys(OS_LABELS),
browser: lowerKeys(BROWSER_LABELS),
connectionType: lowerKeys(CONNECTION_TYPE_LABELS),
kind: lowerKeys(PARTICIPANT_KIND_LABELS),
egressType: lowerKeys(EGRESS_TYPE_LABELS),
@ -424,6 +427,11 @@ export class AnalyticsPage implements AnalyticsPageContext {
return this.summary() !== null && value === undefined ? hint : undefined;
}
/** Why a room average is missing: no room ended in the range, or a server version that does not provide it. */
protected roomAverageHint(value: number | undefined): string | undefined {
return this.missingHint(value, this.summary()?.roomsEnded === 0 ? NO_ROOM_ENDED : NOT_PROVIDED);
}
// Series and breakdowns
/** The series of `metrics` (and the expansions of `egressEnded`) from the request that holds them. */

View File

@ -16,7 +16,12 @@
[loading]="summary() === null"
[approximate]="approximate()"
/>
<ovd-kpi-tile label="Recorded time" [value]="recorded(summary())" [loading]="summary() === null" />
<ovd-kpi-tile
label="Total duration"
[info]="infoTexts.egressTotalDuration"
[value]="recorded(summary())"
[loading]="summary() === null"
/>
</ovd-tile-row>
<div class="ovd-filter-bar">

View File

@ -137,6 +137,13 @@ describe('EgressPage', () => {
expect(tiles[2]).toContain('7');
expect(tiles[2]).toContain('Aborted 2');
expect(tiles[3]).toContain('10 h 00 m');
expect(tiles[3]).toContain('Total duration');
expect(
element
.querySelectorAll('ovd-kpi-tile')[3]
?.querySelector('ovd-info-tip button')
?.getAttribute('aria-label')
).toBe('About Total duration');
});
it('renders an active row, a failed row, a complete row with an error marker and a reconciler-closed row', async () => {

View File

@ -35,6 +35,7 @@ import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js';
import { EGRESS_STATUS_LABELS, EGRESS_TYPE_LABELS, RECONCILER_ESTIMATE } from '../../format/labels.js';
import { isRoomSid } from '../rooms/room-display.js';
import { EgressDataService, type EgressListRequest } from './egress-data.js';
@ -103,6 +104,7 @@ export class EgressPage {
protected readonly types = TYPES;
protected readonly typeLabels = EGRESS_TYPE_LABELS;
protected readonly estimate = RECONCILER_ESTIMATE;
protected readonly infoTexts = INFO_TEXTS;
protected readonly now = injectTicker();
protected readonly tz = this.query.tz;
protected readonly limit = this.query.limit;

View File

@ -65,7 +65,13 @@
<th scope="row" class="ovd-mono">{{ row.collection }}</th>
<td class="end ovd-tabular">{{ row.documents }}</td>
<td class="end ovd-tabular">{{ row.size }}</td>
<td class="ovd-tabular">{{ row.oldest }}</td>
<td class="ovd-tabular">
@if (row.undatedHint) {
<span tabindex="0" [matTooltip]="row.undatedHint">{{ row.oldest }}</span>
} @else {
{{ row.oldest }}
}
</td>
</tr>
} @empty {
<tr>
@ -73,6 +79,16 @@
</tr>
}
</tbody>
@if (storageTotal(); as total) {
<tfoot>
<tr>
<th scope="row">Total</th>
<td class="end ovd-tabular">{{ total.documents }}</td>
<td class="end ovd-tabular">{{ total.size }}</td>
<td></td>
</tr>
</tfoot>
}
</table>
</div>
} @else {

View File

@ -48,3 +48,9 @@ td {
.end {
text-align: end;
}
tfoot th,
tfoot td {
border-bottom: none;
font-weight: 500;
}

View File

@ -1,6 +1,7 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { MatProgressBar } from '@angular/material/progress-bar';
import { formatRetentionLength, type InfoMigration, type InfoRetention } from '@openvidu-dashboard/typings';
import { MatTooltip } from '@angular/material/tooltip';
import { Collection, formatRetentionLength, type InfoMigration, type InfoRetention } from '@openvidu-dashboard/typings';
import { Notice } from '../../components/feedback/notice.js';
import { Skeleton } from '../../components/feedback/skeleton.js';
@ -46,13 +47,21 @@ export function migrationLine(migration: InfoMigration, tz: string): string | nu
}
}
/** Collections without an index that leads with their time field, which the info endpoint never dates. */
const UNDATED: ReadonlySet<string> = new Set([
Collection.TRACKS,
Collection.TRACK_SUBSCRIPTIONS,
Collection.TRACK_STATS
]);
const UNDATED_HINT = "Not shown: no index starts with this collection's time field";
/**
* The OpenVidu version with the edition, the legacy migration while it runs (and its outcome when it ends while the
* tab is open), the retention per data class and the storage per collection.
*/
@Component({
selector: 'ovd-about-tab',
imports: [EditionBadge, MatProgressBar, Notice, Skeleton],
imports: [EditionBadge, MatProgressBar, MatTooltip, Notice, Skeleton],
templateUrl: './about-tab.html',
styleUrl: './about-tab.scss'
})
@ -96,9 +105,20 @@ export class AboutTab {
collection,
documents: formatNumber(entry.documents),
size: formatBytes(entry.storageBytes + entry.indexBytes),
oldest: entry.oldest === undefined ? '—' : formatTimestamp(entry.oldest, { tz: this.tz() })
oldest: entry.oldest === undefined ? '—' : formatTimestamp(entry.oldest, { tz: this.tz() }),
undatedHint: entry.oldest === undefined && UNDATED.has(collection) ? UNDATED_HINT : null
}))
);
protected readonly storageTotal = computed(() => {
const entries = Object.values(this.info()?.storage ?? {});
return entries.length === 0
? null
: {
documents: formatNumber(entries.reduce((sum, entry) => sum + entry.documents, 0)),
size: formatBytes(entries.reduce((sum, entry) => sum + entry.storageBytes + entry.indexBytes, 0))
};
});
constructor() {
effect(() => {

View File

@ -222,6 +222,36 @@ describe('SettingsPage', () => {
expect(about()?.querySelector('table')).not.toBeNull();
});
it('closes the storage table with a Total row and explains an undated collection', async () => {
const { harness } = await settings(
'/settings?tab=about',
infoResponse({
storage: {
rooms: { documents: 4, storageBytes: 36_864, indexBytes: 344_064, oldest: '2026-11-03T08:59:58Z' },
events: {
documents: 285,
storageBytes: 65_536,
indexBytes: 290_816,
oldest: '2026-11-03T08:59:58Z'
},
tracks: { documents: 16, storageBytes: 36_864, indexBytes: 147_456 }
}
})
);
const table = [...(harness.fixture.nativeElement as HTMLElement).querySelectorAll('ovd-about-tab table')].find(
(candidate) => candidate.querySelector('caption')?.textContent.includes('Storage per collection')
);
const cells = (row: Element | null | undefined) =>
[...(row?.querySelectorAll('th, td') ?? [])].map((cell) => cell.textContent.trim());
const tracks = [...(table?.querySelectorAll('tbody tr') ?? [])].find((row) =>
row.textContent.includes('tracks')
);
expect(cells(table?.querySelector('tfoot tr'))).toEqual(['Total', '305', '922 kB', '']);
expect(cells(tracks)[3]).toBe('—');
expect(tracks?.querySelector('[mattooltip], .mat-mdc-tooltip-trigger')).not.toBeNull();
});
it('reports unavailable server info with retry while Preferences stays usable', async () => {
const { harness, http } = await settings('/settings?tab=about', null);
const element = harness.fixture.nativeElement as HTMLElement;

View File

@ -59,6 +59,18 @@ describe('formatters', () => {
expect(formatDuration(value)).toBe(expected);
});
it.each([
[2_700, '3 s', '2 s'],
[59_600, '1 m 00 s', '59 s'],
[3_569_600, '59 m 30 s', '59 m 29 s'],
[3_599_600, '1 h 00 m', '59 m 59 s'],
[999.6, '1 s', '999 ms'],
[86_370_000, '1 d 0 h', '23 h 59 m']
])('rounds %d ms to the smallest unit shown, and truncates it with floor', (value, rounded, floored) => {
expect(formatDuration(value)).toBe(rounded);
expect(formatDuration(value, { floor: true })).toBe(floored);
});
it('formats timestamps in the zone with a UTC suffix', () => {
const at = '2026-10-13T10:31:05.120Z';

View File

@ -49,33 +49,51 @@ function pad(value: number): string {
return String(value).padStart(2, '0');
}
/** `850 ms`, `42 s`, `12 m 05 s`, `1 h 02 m`, `3 d 4 h`. */
export function formatDuration(ms: number): string {
const SECOND_MS = 1000;
const MINUTE_MS = 60 * SECOND_MS;
const HOUR_MS = 60 * MINUTE_MS;
export interface DurationOptions {
/** Truncate to the smallest unit shown instead of rounding to it, as a running counter does. */
floor?: boolean;
}
/** Smallest unit a duration of this length shows: seconds below an hour, minutes below a day, then hours. */
function durationUnitMs(ms: number): number {
return ms < HOUR_MS ? SECOND_MS : ms < DAY_MS ? MINUTE_MS : HOUR_MS;
}
/** `850 ms`, `42 s`, `12 m 05 s`, `1 h 02 m`, `3 d 4 h`, rounded to the smallest unit shown (truncated with `floor`). */
export function formatDuration(ms: number, options: DurationOptions = {}): string {
const value = Math.max(0, ms);
const fit = options.floor === true ? Math.floor : Math.round;
if (value < 1000) {
return `${String(Math.round(value))} ms`;
if (fit(value) < SECOND_MS) {
return `${String(fit(value))} ms`;
}
const seconds = Math.floor(value / 1000);
let unit = durationUnitMs(value);
let total = fit(value / unit) * unit;
if (seconds < 60) {
return `${String(seconds)} s`;
// Rounding up can reach the next unit, as 59.6 s does: round again at that unit.
if (durationUnitMs(total) !== unit) {
unit = durationUnitMs(total);
total = fit(value / unit) * unit;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${String(minutes)} m ${pad(seconds % 60)} s`;
if (total < MINUTE_MS) {
return `${String(total / SECOND_MS)} s`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${String(hours)} h ${pad(minutes % 60)} m`;
if (total < HOUR_MS) {
return `${String(Math.floor(total / MINUTE_MS))} m ${pad((total % MINUTE_MS) / SECOND_MS)} s`;
}
return `${String(Math.floor(hours / 24))} d ${String(hours % 24)} h`;
if (total < DAY_MS) {
return `${String(Math.floor(total / HOUR_MS))} h ${pad((total % HOUR_MS) / MINUTE_MS)} m`;
}
return `${String(Math.floor(total / DAY_MS))} d ${String((total % DAY_MS) / HOUR_MS)} h`;
}
export interface TimestampOptions {

View File

@ -32,7 +32,9 @@ export const INFO_TEXTS = {
activeParticipants: 'Regular participants connected now.',
roomFeatures: 'Services the room used: E egress, I ingress, A agent.',
ingressSessions: 'An ingress is a reusable input; each stream published to it is one session.'
ingressSessions: 'An ingress is a reusable input; each stream published to it is one session.',
egressTotalDuration: 'Time from each egress request to its end, failed egress included.'
} as const;
/** Tooltip of a value prefixed with "≈": a count summed from the hourly rollups. */

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { EGRESS_TYPE_LABELS, enumLabel, INGRESS_INPUT_LABELS } from './labels.js';
import { BROWSER_LABELS, clientOsLabel, EGRESS_TYPE_LABELS, enumLabel, INGRESS_INPUT_LABELS } from './labels.js';
describe('enumLabel', () => {
it('reads the label, the raw value without one, and "—" when absent', () => {
@ -9,3 +9,24 @@ describe('enumLabel', () => {
expect(enumLabel(EGRESS_TYPE_LABELS, undefined)).toBe('—');
});
});
describe('client labels', () => {
it('labels the browser keys, sessions without a browser included', () => {
expect(['chrome', 'none', 'other'].map((key) => enumLabel(BROWSER_LABELS, key))).toEqual([
'Chrome',
'No browser',
'Other'
]);
});
it('reads a reported OS as its family label when it names one, else as reported', () => {
expect(['linux', 'Linux', 'macos', ' iOS ', 'Chrome OS', ''].map(clientOsLabel)).toEqual([
'Linux',
'Linux',
'macOS',
'iOS',
'Chrome OS',
'—'
]);
});
});

View File

@ -1,4 +1,5 @@
import type {
BrowserFamily,
ClientSdk,
ConnectionType,
EgressStatus,
@ -95,6 +96,36 @@ export const OS_LABELS: Readonly<Record<OsFamily, string>> = {
other: 'Other'
};
/** Family names a client may report as its OS, matched whole and case-insensitively, and their family. */
const REPORTED_OS: Readonly<Record<string, OsFamily>> = {
windows: 'windows',
macos: 'macos',
'mac os': 'macos',
linux: 'linux',
android: 'android',
ios: 'ios'
};
/** The OS a client reported, as its family label when it names one (`linux` reads "Linux"), else as reported. */
export function clientOsLabel(os: string | null | undefined): string {
if (os === null || os === undefined || os.trim() === '') {
return '—';
}
const family = REPORTED_OS[os.trim().toLowerCase()];
return family === undefined ? os : OS_LABELS[family];
}
export const BROWSER_LABELS: Readonly<Record<BrowserFamily, string>> = {
chrome: 'Chrome',
firefox: 'Firefox',
safari: 'Safari',
edge: 'Edge',
none: 'No browser',
other: 'Other'
};
export const CONNECTION_TYPE_LABELS: Readonly<Record<ConnectionType, string>> = {
udp: 'UDP',
tcp: 'TCP',

View File

@ -223,6 +223,15 @@ export const DASHBOARD_ROUTES: DashboardRoute[] = [
}
]
},
{
path: 'tracks/:trackSid',
title: 'Track',
loadComponent: () => import('./components/lock/locked-page.js').then((module) => module.LockedPage),
data: {
page: { feature: 'participant-detail', liveCapable: false, rangeScoped: false, timed: true },
breadcrumb: { param: 'trackSid' }
}
},
{
path: 'settings',
title: 'Settings',

View File

@ -52,6 +52,7 @@ describe('route metadata', () => {
'egress/:egressId': true,
ingress: true,
'ingress/:ingressId': true,
'tracks/:trackSid': true,
settings: false
});
});
@ -306,7 +307,8 @@ describe('DashboardShell', () => {
);
const footer = element.querySelector('ovd-navigation-list ovd-sidenav-footer');
expect(footer?.querySelector('.version a')?.textContent.trim()).toBe('OpenVidu 3.10.0');
expect(footer?.querySelector('.version .name')?.textContent.trim()).toBe('OpenVidu 3.10.0');
expect(footer?.querySelector('.version a')).toBeNull();
expect(footer?.querySelector('ovd-edition-badge')?.textContent.trim()).toBe('COMMUNITY');
expect(
[...(footer?.querySelectorAll('ovd-external-link') ?? [])].map((link) =>

View File

@ -1,7 +1,6 @@
import { booleanAttribute, Component, computed, inject, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { ExternalLink } from '../components/external-link/external-link.js';
import { InfoService } from '../core/info.js';
@ -9,12 +8,12 @@ import { EXTERNAL_LINKS } from '../core/links.js';
import { EditionBadge } from './edition-badge.js';
/**
* The bottom of the sidenav: "OpenVidu {version}" linking to About, the edition badge, the documentation link and, when
* The bottom of the sidenav: "OpenVidu {version}", the edition badge, the documentation link and, when
* the server serves it, the OpenAPI link; in rail mode the short badge and a documentation icon.
*/
@Component({
selector: 'ovd-sidenav-footer',
imports: [EditionBadge, ExternalLink, MatIcon, MatTooltip, RouterLink],
imports: [EditionBadge, ExternalLink, MatIcon, MatTooltip],
template: `
@if (rail()) {
<ovd-edition-badge compact />
@ -31,7 +30,7 @@ import { EditionBadge } from './edition-badge.js';
</a>
} @else {
<div class="version">
<a routerLink="/settings" [queryParams]="{ tab: 'about' }">{{ version() }}</a>
<span class="name">{{ version() }}</span>
<ovd-edition-badge />
</div>
<ovd-external-link [href]="documentation">Documentation</ovd-external-link>
@ -55,16 +54,11 @@ import { EditionBadge } from './edition-badge.js';
align-items: center;
gap: 4px 8px;
a {
.name {
color: var(--mat-sys-on-surface-variant);
font: var(--mat-sys-label-large);
text-decoration: none;
white-space: nowrap;
}
a:hover {
text-decoration: underline;
}
}
.icon-link {

View File

@ -124,6 +124,21 @@ $mono-family: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, monospa
font-variant-numeric: tabular-nums;
}
// A loading placeholder stays invisible for its first 300 ms, so a fast response never flashes it.
.ovd-skeleton-reveal {
animation: ovd-skeleton-reveal 150ms ease-in 300ms both;
}
@keyframes ovd-skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.ovd-visually-hidden {
position: absolute;
width: 1px;

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-03

View File

@ -0,0 +1,171 @@
# Design
## Context
- **Charts:** `charts/chart-options.ts` builds every time-series option.
- The x-axis is an ECharts `time` axis whose labels are `HH:mm`, `MM-dd HH:mm` or `MM-dd` by span, with no minimum
interval, so a 3-minute range ticks every 20–30 s.
- The y-axis has no minimum interval either.
- Step series use `step: 'end'`: each value is drawn from its bucket start to the next point, so the last point has no
segment.
- **Skeletons:** `ovd-skeleton` paints its bars at once and is removed on the first response, which flashes when the
response takes tens of milliseconds.
- **Durations:** `formatDuration()` truncates at every unit. `ovd-duration` formats both running and stored durations
with it.
- **About storage:** `InfoService.storageEntry()` lists each collection's indexes and dates it only when an index leads
with `ts`, `bucket` or `createdAt`. The analytics indexes of the time fields are:
- `rooms.createdAt`, `ingresses.createdAt`;
- `participants { joinedAt: -1, _id: -1 }`;
- `egresses { startedAt: -1, _id: -1 }` and `ingress_sessions { startedAt: -1, _id: -1 }`;
- `events.ts`, `analytics_5min.bucket`, `analytics_hourly.bucket`;
- `analytics_daily { scope, bucket }`;
- the `track_stats` time series, indexed on its metadata before `ts`;
- `nodes`, with no time index and one document per node;
- `tracks` and `track_subscriptions`, with their time field only behind `participantSid`, `roomSid`, `subscriberSid`
or `trackSid`.
- **Global search:** `exactIdTarget()` recognises `TR_` through `entityIdKind()` but drops it, because no route takes
a track ID.
- **Browser and OS labels:** the rollups key browsers by family, and an empty browser falls into `other` until the
writer change adds `none`. Pages that show a client's OS print the reported string (`linux` from the Go SDK, `Linux`
from the JS SDK).
## Goals / Non-Goals
**Goals:**
- One rule per chart property, applied in `timeSeriesOption()` for every chart of every edition.
- No new inputs on the chart, the skeleton or the duration components.
**Non-Goals:**
- Changing the label formats of the axes or the retention notices.
- New indexes for tracks and subscriptions.
## Decisions
### D1. The x-axis interval follows the label unit
`minInterval` is 60 000 ms when the labels show minutes (`HH:mm`, `MM-dd HH:mm`) and 86 400 000 ms when they show days.
It comes from the same span rule as the label format.
Rejected: passing the bucket width from every chart. The labels are the constraint, and the granularity is already in
the chart note.
### D2. Integer data gets integer ticks
`yAxis.minInterval` is 1 when every non-null value of every series is an integer.
Rejected: an `integer` input on the chart, which every count chart of every edition would have to set.
### D3. A step series is extended to `to`
When the last point of a step series is not null and starts before `to`, `timeSeriesOption()` appends `[to, value]`, so
`step: 'end'` draws the last bucket up to the end of the range. A dense series always ends in the bucket that holds
`to`.
Rejected: `step: 'start'`, which would draw every value one bucket early.
### D4. The skeleton fades in after 300 ms
Every loading placeholder keeps its height from the start and carries the global class `ovd-skeleton-reveal`, whose CSS
animation keeps its opacity at 0 for 300 ms and then shows it: `ovd-skeleton`, the skeleton rows of `ovd-data-table`
and the KPI tile skeleton. Pages keep their loading logic. Under `prefers-reduced-motion` the global rule shortens the
fade and the delay stays.
Rejected:
- delaying the loading flag in every loader, which touches every page and both loaders;
- keeping the previous content, which does not exist on a first load.
### D5. Stored durations are rounded, running ones are not
`formatDuration(ms, { mode })` rounds to the smallest unit it will show by default:
- it picks the unit from the magnitude, rounds, and formats again when rounding crosses a unit (59 600 ms → 60 s →
"1 m 00 s");
- `mode: 'floor'` keeps the current truncation;
- `ovd-duration` passes `floor` while `running` is true, both for `liveDurationMs` and for `liveTotalMs`.
Rejected: rounding live durations, which would make a counter jump to the next second halfway through.
### D6. A fixed time field per collection
`InfoService` holds one map from collection to field, and optional filter, and reads `find(filter).sort({ field: 1 })
.limit(1)` with a projection of the field. This replaces the index discovery and saves a `listIndexes` per collection.
| Collection | Field |
| --- | --- |
| `rooms`, `ingresses` | `createdAt` |
| `participants` | `joinedAt` |
| `egresses`, `ingress_sessions` | `startedAt` |
| `events` | `ts` |
| `analytics_5min`, `analytics_hourly` | `bucket` |
| `analytics_daily` | `bucket` with `{ scope: 'global' }` |
| `nodes` | `firstSeenAt` (one document per node, no index needed) |
`tracks`, `track_subscriptions` and `track_stats` have no entry, and About explains the "—". The `track_stats` query
was explained on a generated dataset: the time series has no index on its time alone, so the plan is a bounded sort
over a scan of the buckets.
Rejected:
- new `{ publishedAt }` and `{ subscribedAt }` indexes, two more indexes on the most written collections for one About
cell;
- a sort without an index, a collection scan every 60 s.
### D7. Plain version text
`sidenav-footer.ts` renders the version in a `span`.
### D8. The room averages say why they are missing
`missingHint()` takes the reason: `roomsEnded === 0` gives "No room ended in this range", anything else "Not provided by
this server version".
### D9. Total duration tile
The Egress page tile is labelled "Total duration", with `INFO_TEXTS.egressTotalDuration`.
### D10. Browser labels and the OS label
`format/labels.ts` gains:
- `BROWSER_LABELS` for `chrome`, `firefox`, `safari`, `edge`, `none` ("No browser") and `other`, used by the browser
donut, which today prints the key;
- `clientOsLabel(os)`, which compares the reported string, case-insensitively and trimmed, with the family names
(`windows`, `macos`, `mac os`, `linux`, `android`, `ios`) and returns the `OS_LABELS` entry, or the string as
reported.
The library exports both for the detail pages of every edition.
Rejected: mapping by substring as the writer does, which would turn "Chrome OS" into "Linux" on a page meant to show
what the client reported.
### D11. A locked track route
`routes.ts` adds `tracks/:trackSid`:
- with no navigation entry and `page.feature: 'participant-detail'`;
- with the breadcrumb param `trackSid`, which titles the lock, as the route has no list above it;
- opening a locked page that reuses the participant lock.
`exactIdTarget()` maps `track` to `/tracks`. An edition with the feature replaces the route by path, as it does for
the other detail routes.
## API
No shape change: `storage.<collection>.oldest` exists and only covers more collections.
## Performance budget
About reads at most eleven point queries every 60 s, with the existing cache. Each is verified in the info integration
test with `explain()`: an `IXSCAN` with one document examined, and a `COLLSCAN` only on `nodes`.
`track_stats` has no entry: its plan scans the buckets of the time series.
## Risks / Trade-offs
- [Rounding changes durations across the UI, by up to half a unit] → Unit tests of the formatter for both modes, and
updated fixtures where a duration reads differently.
- [A running duration that ends shows one second more than it last counted] → Accepted: the stored value is the
measured one.
- [The `none` browser key only appears in rollups written after the writer change] → Older rollups keep those sessions
under `other`, which still reads "Other".
## Migration Plan
Ships with the 3.10 dashboard. The browser label of `none` relies on the typings of `writer-review-fixes`, applied
first.

View File

@ -0,0 +1,79 @@
# Proposal
## Why
A review against a real deployment, with OpenVidu Meet rooms, CLI participants, every egress output kind and an RTMP
ingress, found shared UI pieces showing values the data does not hold. Short-range charts repeat their time labels,
count charts tick at half participants and step lines drop their last bucket. Skeletons flash on fast loads, and ended
durations are truncated. About leaves most "oldest" cells empty and has no total. The Analytics room averages blame the
server version when no room has ended yet.
## What Changes
- **Time-series charts:**
- x-axis ticks are never closer than the unit their labels show (one minute for `HH:mm`, one day for `MM-dd`), so a
label never repeats;
- the y-axis ticks at whole numbers when every value of the chart is an integer;
- a step series holds its last value until the end of the range, so the last bucket is drawn.
- **Skeletons** keep the space of what they replace but stay invisible for their first 300 ms: a fast response shows
no flash.
- **Durations:** ended durations are rounded to the smallest unit they show (2.7 s reads "3 s"); running durations
keep counting whole elapsed units.
- **About storage:**
- a Total row with the documents and size of every listed collection;
- the oldest document of every collection whose time field leads an index or that holds one document per node:
participants, egresses, ingress sessions, daily rollups and nodes, besides the ones shown today;
- `GET /api/v1/info` reports those dates;
- tracks, subscriptions and stats keep "—" with a tooltip that says why.
- **Footer:** the version is plain text, no longer a link to About.
- **Analytics room averages:** with no room ended in the range, the Average room size and Average room duration tiles
hint "No room ended in this range". "Not provided by this server version" stays for a range with ended rooms and no
average.
- **Egress page:** the "Recorded time" tile becomes "Total duration", with an info tip: from each egress request to
its end, failed ones included.
- **Client labels:**
- browser keys show friendly labels (Chrome, Firefox, Safari, Edge, Other), and the key `none` reads "No browser";
- a shared label maps a reported OS to its family label (`linux` reads "Linux") for every page that shows the raw
value.
- **Global search:** an exact track ID (`TR_` prefix) shows "Open track <ID>" and opens the track route, locked in CE
like the other detail routes.
Scope excludes the following:
- The writer and contract side (the `none` browser key, the ingress session's room, the egress status history):
change `writer-review-fixes`.
- Column widths of tables at 1440 px (backlog).
- The Average room size rounding: it stays a whole number of participants.
Edition: CE. Repository: the dashboard (backend info service, UI library, specs).
CHANGELOG lines: none. These views have not shipped in a release, and the 3.10 lines already describe them.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `ui-building-blocks`: chart axes and last step, skeleton delay, rounded durations, track IDs in the global search
and OS and browser labels.
- `settings-about`: the storage table's Total row and its oldest cells.
- `editions-info`: the oldest document of the collections whose time field is indexed.
- `ui-shell`: the footer version is plain text, and the track route is locked in CE.
- `analytics-ui`: the room averages' hint without ended rooms, and the browser donut's labels.
- `egress-ui`: the Total duration tile.
## Impact
- Backend: `info/info-service.ts` (a time field per collection, the `{ scope, bucket }` index of the daily rollups),
its unit and integration tests.
- UI library:
- `charts/chart-options.ts`;
- `components/feedback/skeleton.ts`;
- `format/formatters.ts` and `components/duration/`;
- `format/labels.ts` (browser labels, OS label function);
- the global search, `shell/sidenav-footer.ts`, the About tab, the Analytics page and the Egress page;
- `routes.ts` (the locked `tracks/:trackSid` route);
- unit and e2e tests.
- No API shape change: `storage.<collection>.oldest` already exists.

View File

@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: KPI tiles
Edition: CE. The tile row SHALL show, in this order, Rooms created, Participant sessions, Unique identities (labelled
"sum of N UTC days"), Data up / down (up and down on two lines, each with its arrow), Connection success, Egress
(count and total duration) and Ingress (sessions and total duration) from the summary endpoint with the shared
formatters, show "n/a" with a hint for a value the summary omits and keep the last value while refreshing.
#### Scenario: Tiles from the summary
- **GIVEN** a summary with `roomsCreated: 1203`, `bytesUp: 1200000000000`, `bytesDown: 3400000000000`,
`uniqueIdentities: { value: 4120, days: 7 }`, `ingressSessions: 12` and `ingressDurationMs: 21600000`
- **WHEN** the tiles render
- **THEN** they read "1,203", "↑ 1.2 TB" over "↓ 3.4 TB" under "Data up / down", "4,120" with the label "Unique
identities (sum of 7 UTC days)", and the Ingress tile "12" with the hint "6 h 00 m in total"
#### Scenario: Tile order
- **WHEN** the tile row renders
- **THEN** the labels read Rooms created, Participant sessions, Unique identities, Data up / down, Connection
success, Egress and Ingress
#### Scenario: Missing average
- **GIVEN** a summary with `roomsEnded: 12` and without `avgRoomSize`
- **WHEN** the Rooms section tiles render
- **THEN** the Average room size tile reads "n/a" with the hint "Not provided by this server version"
### Requirement: Rooms section
Edition: CE. The Rooms section SHALL show a bar chart of `roomsCreated` and `roomsEnded` per bucket and the tiles
Average room size (rounded to a whole number of participants) and Average room duration (in the shared duration
format) from the summary; with `roomsEnded: 0` both tiles read "n/a" with the hint "No room ended in this range".
#### Scenario: Rooms over 24 hours
- **GIVEN** the default range at `auto` and a summary with `avgRoomSize: 4.4` and `avgRoomDurationMs: 360000`
- **WHEN** the section renders
- **THEN** the chart shows 288 five-minute buckets with two bar series in fixed colours and the tiles read
"4 participants" and "6 m 00 s"
#### Scenario: No room ended yet
- **GIVEN** a summary with `roomsCreated: 2`, `roomsEnded: 0` and neither average
- **WHEN** the section renders
- **THEN** both tiles read "n/a" with the hint "No room ended in this range"
### Requirement: Client breakdowns section
Edition: CE. The Client breakdowns section SHALL show SDK, OS, browser and connection type from the breakdowns
endpoint as donuts with legend, direct labels, fixed colours per key, "Other" beyond eight entries and a table toggle
listing key, count and percent, every key shown by its shared friendly label and every percent in the shared percent
format ("62.8 %").
#### Scenario: Nine SDKs
- **GIVEN** nine SDK keys in the range
- **WHEN** the SDK donut renders
- **THEN** eight slices plus "Other" are drawn and the table toggle lists all nine with counts and percentages
#### Scenario: Donut percent
- **GIVEN** an SDK breakdown in which `js` holds 628 of 1 000 sessions
- **WHEN** the SDK donut renders
- **THEN** the direct label of its slice and the percent column of its table read "62.8 %"
#### Scenario: Friendly labels
- **GIVEN** a connection type breakdown with a known key and a key the label map does not know
- **WHEN** the donut and its table render
- **THEN** the known key reads its friendly label and the other key reads as stored
#### Scenario: Sessions without a browser
- **GIVEN** a browser breakdown with `chrome: 5` and `none: 5`
- **WHEN** the browser donut renders
- **THEN** its slices read "Chrome: 50.0 %" and "No browser: 50.0 %"

View File

@ -0,0 +1,24 @@
## MODIFIED Requirements
### Requirement: Storage and database facts in info
Edition: CE. With a valid bearer token `GET /api/v1/info` SHALL include `database` (`version` of the MongoDB server
and `timeseriesSupported`) and `storage`, one entry per analytics collection with `documents`, `storageBytes`,
`indexBytes` and `oldest` (the earliest value of the collection's time field when an index leads with it, and of
`firstSeenAt` for `nodes`), read with `$collStats` and cached for 60 seconds.
#### Scenario: Storage per collection
- **GIVEN** a database with 1 200 rooms and 20 000 raw events
- **WHEN** the client requests `GET /api/v1/info` with a valid token
- **THEN** `storage.rooms.documents` is `1200`, `storage.events.documents` is `20000`, every entry has `storageBytes`
and `indexBytes`, and `database.version` is the server version string
#### Scenario: Oldest per collection
- **GIVEN** a database with documents in every analytics collection
- **WHEN** the client requests `GET /api/v1/info` with a valid token
- **THEN** `oldest` holds the earliest `createdAt` of `rooms` and `ingresses`, `joinedAt` of `participants`,
`startedAt` of `egresses` and `ingress_sessions`, `ts` of `events`, `bucket` of the 5-minute and hourly rollups and
of the `global` scope of the daily rollups, and `firstSeenAt` of `nodes`, while `tracks`, `track_subscriptions` and
`track_stats` have no `oldest`

View File

@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Egress summary tiles
Edition: CE. The Egress page SHALL show four KPI tiles from the summary endpoint above the filters: Active, Started
in range (hint: count by type, in the fixed order room composite, web, participant, track composite, track, with type
labels and without zero counts), Failed in range (hint: aborted and limit reached) and Total duration (with an info
tip); approximate values carry a tooltip saying the range exceeds the exact-count limit, and the tiles keep their last
value while refreshing.
#### Scenario: Tiles after load
- **GIVEN** the summary answers `{ active: 3, startedInRange: 120, byType: { track: 20, room_composite: 100 },
byEndStatus: { complete: 110, failed: 7, aborted: 2 }, totalDurationMs: 36000000, approximate: false }`
- **WHEN** the page renders
- **THEN** the tiles read 3, 120 with hint "Room composite 100 · Track 20", 7 with hint "Aborted 2", and "10 h 00 m"
under "Total duration"
#### Scenario: Total duration explained
- **WHEN** the user hovers or focuses the info icon of the Total duration tile
- **THEN** it reads "Time from each egress request to its end, failed egress included."
#### Scenario: Stable hint order
- **GIVEN** a refresh that answers `byType` with its keys in another order
- **WHEN** the tile updates
- **THEN** the hint keeps the order "Room composite · Track"

View File

@ -0,0 +1,29 @@
## MODIFIED Requirements
### Requirement: Retention and storage
Edition: CE. About SHALL show the retention per data class (events, stats, tracks and subscriptions, entities, 5-minute
rollups, hourly rollups, daily rollups) as a table with one row per class in the shared retention length wording, and a
storage table with collection, document count, size and oldest document as the info endpoint reports them, closed by a
Total row; sizes use the shared byte formatter.
#### Scenario: Retention line
- **GIVEN** the defaults
- **WHEN** About renders
- **THEN** the retention table has the rows Events 7 days, Stats 7 days, Tracks and subscriptions 7 days, Entities 90
days, 5-minute rollups 7 days, Hourly rollups 400 days and Daily rollups "Kept forever"
#### Scenario: Retention that is not a whole number of days
- **GIVEN** `retention.tracksMs` of 36 hours and `retention.rollupsDailyMs` of 1 825 days
- **WHEN** About renders
- **THEN** the row Tracks and subscriptions reads "36 hours" and the row Daily rollups reads "1,825 days"
#### Scenario: Storage total
- **GIVEN** storage entries for `rooms` (4 documents, 381 kB with indexes) and `events` (285 documents, 356 kB with
indexes)
- **WHEN** About renders
- **THEN** the last row of the storage table reads "Total", "289", "737 kB" and an empty Oldest cell
#### Scenario: Collection without an oldest date
- **GIVEN** the `tracks` entry without `oldest`
- **WHEN** About renders
- **THEN** its Oldest cell reads "—" with the tooltip "Not shown: no index starts with this collection's time field"

View File

@ -0,0 +1,166 @@
## MODIFIED Requirements
### Requirement: Time-series chart fixed to the range
Edition: CE. The time-series chart SHALL render the given series between `from` and `to` in the given timezone with
granularity buckets; the x-axis never extends beyond the range nor ticks closer than the unit of its labels, wheel and
drag scroll the page, and no zoom component is enabled. It uses a shared tooltip keyed by x across the charts of a
page, 2 px lines, one y-axis per chart, and the shared formatters for bytes, bitrates, durations and percentages.
#### Scenario: Wheel over a chart
- **GIVEN** a chart in the page
- **WHEN** the user scrolls the mouse wheel over it
- **THEN** the page scrolls and the chart range is unchanged
#### Scenario: Sparse data
- **GIVEN** buckets exist only for half of the range
- **WHEN** the chart renders
- **THEN** the x-axis spans `from` to `to` and missing buckets show gaps, not a shortened axis
#### Scenario: Labels of a short range
- **GIVEN** a chart from 10:06:20 to 10:09:50 with one-minute buckets
- **WHEN** it renders
- **THEN** the x-axis labels read whole minutes, each one once (`10:07`, `10:08`, `10:09`), and a range of several
days labelled `MM-dd` ticks at most once a day
#### Scenario: Integer values
- **GIVEN** a series whose values are all integers, such as active participants 2, 3 and 2
- **WHEN** the chart renders
- **THEN** the y-axis ticks are whole numbers, never 0.5 or 2.5, and a chart holding a fractional value keeps
fractional ticks
#### Scenario: Last step of a step series
- **GIVEN** a step series whose last bucket starts at 10:06 with the value 1, in a range ending at 10:06:55
- **WHEN** the chart renders
- **THEN** the line holds 1 from 10:06 to 10:06:55 instead of ending at 10:06
### Requirement: Empty, skeleton and error states
Edition: CE. Empty state SHALL show icon, title, hint and up to two actions ("Clear filters", "Widen range");
skeletons match the height of what they replace, stay invisible for their first 300 ms and render only until the first
response of what they replace; the error notice shows the message, a Retry action and the request id, inline in a
card or at page level.
#### Scenario: No rooms match
- **GIVEN** a filtered list returns no rows
- **WHEN** the empty state renders
- **THEN** it reads "No rooms match" with the hint "Change the filters or widen the time range.", offers "Clear filters" and "Widen range", and the layout height does not jump
#### Scenario: Fast first response
- **GIVEN** a page whose first response arrives 120 ms after it opens
- **WHEN** the page loads
- **THEN** no skeleton bar is painted, the space of what it replaces is kept and the content appears without a layout
jump
#### Scenario: Slow first response
- **GIVEN** a page whose first response takes 2 s
- **WHEN** the page loads
- **THEN** the skeleton becomes visible 300 ms after the page opened and the content replaces it when the response
arrives
### Requirement: Global search
Edition: CE. The global search SHALL debounce input and send no request below 2 characters, showing the hint "Type at
least 2 characters". An exact ID (`RM_`, `PA_`, `EG_`, `IN_`, `TR_` prefix) shows "Open <entity> <ID>" first; Enter
opens that detail route (locked in CE) keeping the current range. Other text shows grouped prefix results (rooms,
participants, egress, ingress; up to five each) with "See all in …" links to the list filtered by `q`.
#### Scenario: Exact SID
- **GIVEN** the user types `PA_x1y2` with the range `from=now-6h&to=now&tz=UTC`
- **WHEN** the results panel opens and the user presses Enter
- **THEN** its first option reads "Open participant PA_x1y2", the application navigates to `/participants/PA_x1y2`, and the Participants breadcrumb links to `/participants?from=now-6h&to=now&tz=UTC`
#### Scenario: Exact track ID
- **GIVEN** the user types `TR_a2b3`
- **WHEN** the results panel opens and the user presses Enter
- **THEN** its first option reads "Open track TR_a2b3" and the application navigates to `/tracks/TR_a2b3`
#### Scenario: Prefix search
- **GIVEN** the user types `dem`
- **WHEN** results arrive
- **THEN** rooms starting with "dem" are listed under Rooms with "See all in Rooms" linking to `/rooms?q=dem`
#### Scenario: One character
- **GIVEN** the user types `d`
- **WHEN** the debounce elapses
- **THEN** no search request is sent and the hint reads "Type at least 2 characters"
#### Scenario: Clear the search
- **GIVEN** the global search holding `dem` with its results open
- **WHEN** the user activates the "Clear search" button
- **THEN** the box is empty, the results close, the focus stays in the box and the button disappears
#### Scenario: Keyboard, placeholder and clear button
- **WHEN** the global search renders at any header width
- **THEN** it is keyboard navigable, its placeholder shows whole, without truncation, and while it holds text a clear
button empties it and closes the results
### Requirement: Formatters
Edition: CE. Shared formatters SHALL render numbers with `Intl.NumberFormat('en')`, percentages with one decimal and a
space before the sign ("96.4 %", donut labels included), bytes in decimal units with three significant digits and
durations as `850 ms`, `42 s`, `12 m 05 s`, `1 h 02 m`, `3 d 4 h`, rounded to the smallest unit shown; timestamps
SHALL read as the timestamp format scenario states.
#### Scenario: Bytes and duration
- **GIVEN** 12345678 bytes and 3725000 ms
- **WHEN** formatted
- **THEN** they read "12.3 MB" and "1 h 02 m"
#### Scenario: Rounded durations
- **GIVEN** durations of 2 700 ms, 59 600 ms and 3 569 600 ms
- **WHEN** formatted
- **THEN** they read "3 s", "1 m 00 s" and "59 m 30 s"
#### Scenario: Percent in a donut
- **GIVEN** a connection success ratio of 0.9643 and a donut slice of 0.25
- **WHEN** they are shown
- **THEN** they read "96.4 %" and "25.0 %"
#### Scenario: Timestamp format
- **WHEN** a timestamp is formatted
- **THEN** it reads `2026-10-13 12:31:05` (date omitted inside the current day of a 24 h range) with a `UTC` suffix
when `tz=UTC`
### Requirement: Enum labels
Edition: CE. Every enum value outside a status badge SHALL show its friendly sentence-case label, in kind badges,
chart categories and filters: egress type, SDK, OS, browser, connection type and participant kind; the browser key
`none` reads "No browser". A value without a label shows the raw value, and an OS string a client reported reads as
its family label when it names one. Status badges keep their lowercase words.
#### Scenario: Chart category and filter
- **GIVEN** egresses of type `room_composite`
- **WHEN** the egress type breakdown and the Type filter render
- **THEN** both read "Room composite"
#### Scenario: Browser keys
- **GIVEN** a browser breakdown with the keys `chrome`, `none` and `other`
- **WHEN** its donut renders
- **THEN** the slices read "Chrome", "No browser" and "Other"
#### Scenario: Reported OS
- **GIVEN** the client OS strings `linux`, `Linux`, `macos` and `Chrome OS`
- **WHEN** they are shown through the shared OS label
- **THEN** they read "Linux", "Linux", "macOS" and "Chrome OS"
### Requirement: Live durations
Edition: CE. Every duration of something still running SHALL count up live from its start between refreshes, in whole
elapsed units and ignoring any stored duration it carries; a total that includes a running part adds that part's
elapsed time. An ended item shows its stored duration, or its end minus its start without one, and an item with
neither a stored duration nor the times to compute one shows "—".
#### Scenario: Running total
- **GIVEN** an ingress with a stored total duration of 1 h and a session publishing for 5 minutes
- **WHEN** its total duration renders
- **THEN** it reads "1 h 05 m" and keeps counting while the session runs
#### Scenario: Running and then ended
- **GIVEN** an egress active for 2 700 ms
- **WHEN** its duration renders
- **THEN** it reads "2 s", and once the egress ends with a stored duration of 2 700 ms it reads "3 s"
#### Scenario: Nothing to compute
- **GIVEN** a row with no stored duration and no start time
- **WHEN** it renders
- **THEN** its duration reads "—"

View File

@ -0,0 +1,42 @@
## MODIFIED Requirements
### Requirement: Footer version line
Edition: CE. The bottom of the sidenav SHALL show only the OpenVidu version ("OpenVidu {version}", the dashboard
version, as plain text), the edition badge, a Documentation link and, when the server enables it, an OpenAPI link.
In rail mode the sidenav bottom shows the short edition badge and a documentation icon link with a tooltip.
#### Scenario: Version line reflects the info endpoint
- **GIVEN** `GET /api/v1/info` reports dashboard 3.10.0, edition CE, server 3.10.0, schema 1 and `openapiDocsEnabled: false`
- **WHEN** the shell renders on a Large viewport
- **THEN** the sidenav bottom reads "OpenVidu 3.10.0", not a link, with the COMMUNITY badge and a Documentation link, and shows neither "server" nor "schema"
#### Scenario: OpenAPI link in development
- **GIVEN** `openapiDocsEnabled: true`
- **WHEN** the shell renders
- **THEN** an OpenAPI link to `api/v1/docs` follows the Documentation link
### Requirement: Locked PRO features in CE
Edition: CE. PRO entries, links and sections SHALL stay visible in CE and render a lock with the feature title, a short
explanation and a "Learn about OpenVidu PRO" link. The lock is decided from the feature flags of `GET /api/v1/info` and
makes no request to the locked endpoint. Full-page locks keep header and breadcrumbs over a dimmed, non-interactive
silhouette of three tiles and one block, with no refresh control in the header; in-card locks size to their message.
#### Scenario: Room detail in CE
- **GIVEN** the info endpoint does not report the feature `room-detail`
- **WHEN** the user follows a room SID link of the Rooms list
- **THEN** `/rooms/RM_ab12cd` shows the room header from the list data and the full-page lock, and no detail request is sent
#### Scenario: Track route in CE
- **GIVEN** the info endpoint does not report the feature `participant-detail`
- **WHEN** the user opens `/tracks/TR_a2b3`, as the global search does for an exact track ID
- **THEN** the page shows the full-page lock titled `TR_a2b3`, and no request about the track is sent
#### Scenario: Locked sidenav entry
- **GIVEN** a sidenav entry whose route declares a feature that `GET /api/v1/info` does not report
- **WHEN** the sidenav renders
- **THEN** the entry shows a lock icon with a tooltip and opens the full-page lock; no sidenav entry of the CE routes declares a feature, so the CE sidenav shows no lock
#### Scenario: Compact in-card lock
- **GIVEN** the CE Analytics page
- **WHEN** its PRO cards render
- **THEN** each locked card is as tall as its lock message, not as the chart it replaces

View File

@ -0,0 +1,45 @@
# Tasks
## 1. Backend
- [x] 1.1 Replace the index discovery of `InfoService.storageEntry()` with the time field map of design D6 (the
`global` scope for `analytics_daily`, `firstSeenAt` for `nodes`, no entry for `tracks`,
`track_subscriptions` and `track_stats`). Verify with the info unit tests and an integration test of "Oldest per collection" that
runs `explain()` on every query: one document examined, `COLLSCAN` only on `nodes`.
## 2. UI library
- [x] 2.1 Set the x-axis `minInterval` from the label unit, the y-axis `minInterval: 1` for all-integer data and the
closing point of step series in `timeSeriesOption()`. Verify with chart-options unit tests of the three scenarios
of "Time-series chart fixed to the range".
- [x] 2.2 Make `ovd-skeleton` invisible for its first 300 ms while keeping its height, and drop the fade under
`prefers-reduced-motion`. Verify with a component test of the delay and an e2e check that a fast mocked response
paints no skeleton bar.
- [x] 2.3 Round `formatDuration()` to the smallest unit shown, add the `floor` mode and use it in `ovd-duration` while
running. Verify with formatter unit tests of "Rounded durations" and a duration component test of "Running and
then ended".
- [x] 2.4 Add `BROWSER_LABELS` and `clientOsLabel()` to `format/labels.ts`, export them, and label the browser donut
and its table with them. Verify with label unit tests of "Browser keys" and "Reported OS" and an Analytics
component test of "Sessions without a browser".
- [x] 2.5 Render the footer version as plain text. Verify with the sidenav footer test.
- [x] 2.6 Hint "No room ended in this range" on both room averages when `roomsEnded` is 0. Verify with Analytics
component tests of "No room ended yet" and "Missing average".
- [x] 2.7 Rename the Egress page tile "Total duration" with `INFO_TEXTS.egressTotalDuration`. Verify with the Egress
page test of the tiles and the info tip.
- [x] 2.8 Add the Total row and the "—" tooltip to the About storage table. Verify with About tab tests of "Storage
total" and "Collection without an oldest date".
- [x] 2.9 Add the locked `tracks/:trackSid` route, and map `track` in `exactIdTarget()` to it. Verify with a global
search test of "Exact track ID" and an e2e test of "Track route in CE".
## 3. End-to-end and docs
- [x] 3.1 Update the e2e fixtures and screenshots whose durations or labels change, and run `./dashboard.sh e2e`.
Verify that it passes.
- [x] 3.2 Update the library README and `AGENTS.md` notes that list the exported labels or the routes, if any. Verify by
reading them against the change.
## 4. Integration check
- [x] 4.1 Run `./dashboard.sh check` and `./dashboard.sh test-integration`. Verify that both pass.
- [ ] 4.2 On a local deployment with `./dashboard.sh dev`, check an Analytics chart of a 15-minute custom range, the
About table and a fast page load against the scenarios. Verify by screenshots.