mirror of https://github.com/OpenVidu/openvidu.git
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
parent
88d7a941e3
commit
efba660a59
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -48,3 +48,9 @@ td {
|
|||
.end {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
tfoot th,
|
||||
tfoot td {
|
||||
border-bottom: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'—'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-11-03
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 %"
|
||||
|
|
@ -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`
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 "—"
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue