From efba660a59dfcadc813357860b561d585881862b Mon Sep 17 00:00:00 2001 From: juancarmore Date: Tue, 3 Nov 2026 11:26:40 +0100 Subject: [PATCH] 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. --- .../backend/src/info/info-service.ts | 53 ++++-- .../test/integration/info-query-plan.test.ts | 80 ++++++++ .../e2e/tests-backend/egress.spec.ts | 2 +- .../dashboard-ce/e2e/tests/egress.spec.ts | 23 +++ .../dashboard-ce/e2e/tests/locks.spec.ts | 11 ++ .../frontend/projects/dashboard-ui/README.md | 3 +- .../src/lib/charts/chart-options.ts | 31 +++- .../src/lib/charts/charts.spec.ts | 49 +++++ .../lib/components/data-table/data-table.html | 2 +- .../lib/components/duration/duration.spec.ts | 17 ++ .../src/lib/components/duration/duration.ts | 7 +- .../src/lib/components/feedback/skeleton.ts | 6 +- .../src/lib/components/lock/locked-page.ts | 13 +- .../components/search/global-search.spec.ts | 16 +- .../lib/components/search/global-search.ts | 11 +- .../src/lib/components/tiles/kpi-tile.ts | 2 +- .../features/analytics/analytics-page.html | 4 +- .../features/analytics/analytics-page.spec.ts | 19 +- .../lib/features/analytics/analytics-page.ts | 10 +- .../src/lib/features/egress/egress-page.html | 7 +- .../lib/features/egress/egress-page.spec.ts | 7 + .../src/lib/features/egress/egress-page.ts | 2 + .../src/lib/features/settings/about-tab.html | 18 +- .../src/lib/features/settings/about-tab.scss | 6 + .../src/lib/features/settings/about-tab.ts | 26 ++- .../features/settings/settings-page.spec.ts | 30 +++ .../src/lib/format/formatters.spec.ts | 12 ++ .../dashboard-ui/src/lib/format/formatters.ts | 50 +++-- .../dashboard-ui/src/lib/format/info-texts.ts | 4 +- .../src/lib/format/labels.spec.ts | 23 ++- .../dashboard-ui/src/lib/format/labels.ts | 31 ++++ .../projects/dashboard-ui/src/lib/routes.ts | 9 + .../dashboard-ui/src/lib/shell/shell.spec.ts | 4 +- .../src/lib/shell/sidenav-footer.ts | 14 +- .../projects/dashboard-ui/styles/_index.scss | 15 ++ .../changes/ui-review-fixes/.openspec.yaml | 2 + .../changes/ui-review-fixes/design.md | 171 ++++++++++++++++++ .../changes/ui-review-fixes/proposal.md | 79 ++++++++ .../specs/analytics-ui/spec.md | 78 ++++++++ .../specs/editions-info/spec.md | 24 +++ .../ui-review-fixes/specs/egress-ui/spec.md | 28 +++ .../specs/settings-about/spec.md | 29 +++ .../specs/ui-building-blocks/spec.md | 166 +++++++++++++++++ .../ui-review-fixes/specs/ui-shell/spec.md | 42 +++++ .../openspec/changes/ui-review-fixes/tasks.md | 45 +++++ 45 files changed, 1214 insertions(+), 67 deletions(-) create mode 100644 openvidu-dashboard/dashboard-ce/backend/test/integration/info-query-plan.test.ts create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/.openspec.yaml create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/design.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/proposal.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/analytics-ui/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/editions-info/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/egress-ui/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/settings-about/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-building-blocks/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-shell/spec.md create mode 100644 openvidu-dashboard/openspec/changes/ui-review-fixes/tasks.md diff --git a/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts b/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts index df614fa80..7d5278c24 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/info/info-service.ts @@ -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> = { + [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; 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(); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/info-query-plan.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/info-query-plan.test.ts new file mode 100644 index 000000000..69c1365a0 --- /dev/null +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/info-query-plan.test.ts @@ -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(); + }); +}); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests-backend/egress.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests-backend/egress.spec.ts index e8f40a3fa..d7eedd3e7 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests-backend/egress.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests-backend/egress.spec.ts @@ -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(); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/egress.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/egress.spec.ts index 155ee4904..ad10de0ff 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/egress.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/egress.spec.ts @@ -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((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(); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/locks.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/locks.spec.ts index af48a58cb..841836f70 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/locks.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/locks.spec.ts @@ -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' } }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md index 25ecbe6c8..0a3f29028 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md @@ -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`), diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/chart-options.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/chart-options.ts index 0a3636450..05aa4347c 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/chart-options.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/chart-options.ts @@ -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, diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/charts.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/charts.spec.ts index 74d776749..8c358a2e9 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/charts.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/charts/charts.spec.ts @@ -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) => ({ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/data-table/data-table.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/data-table/data-table.html index 6b40967b1..9d9ae60a5 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/data-table/data-table.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/data-table/data-table.html @@ -202,7 +202,7 @@ @if (skeletonRows()) { @for (index of skeletonIndexes(); track index) { - + @for (column of visible(); track column.key) { } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.spec.ts index 58d4f35d6..28c72baa8 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.spec.ts @@ -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'] }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.ts index 3e908b3c7..a5bcda03f 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/duration/duration.ts @@ -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); }); } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/feedback/skeleton.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/feedback/skeleton.ts index 1ed4b291a..2a58bad9d 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/feedback/skeleton.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/feedback/skeleton.ts @@ -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: `
Loading
@switch (variant()) { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/locked-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/locked-page.ts index d0ce6f0d0..857b05b16 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/locked-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/lock/locked-page.ts @@ -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; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.spec.ts index 10f7e419f..c9044d649 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.spec.ts @@ -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( diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.ts index 1b9df8d9d..542ca5c49 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/search/global-search.ts @@ -108,12 +108,17 @@ export const SEARCH_SOURCES = new InjectionToken('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`. */ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/tiles/kpi-tile.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/tiles/kpi-tile.ts index 85c5bd91c..0b41cd3c1 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/tiles/kpi-tile.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/components/tiles/kpi-tile.ts @@ -42,7 +42,7 @@ export const TICKING_MS = 5_000; @if (shown() === null) { @if (loading()) { - + Loading {{ label() }} } @else {
—
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html index 747db0bfa..2703dcd86 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.html @@ -100,12 +100,12 @@ { 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' } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts index 6e80e430e..5ff3c7f7a 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.ts @@ -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>>> = { 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. */ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.html index fff8bc1d1..939bb8398 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.html @@ -16,7 +16,12 @@ [loading]="summary() === null" [approximate]="approximate()" /> - +
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.spec.ts index 6b13e7d40..95a704971 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.spec.ts @@ -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 () => { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts index f4f2dd94d..602e76ddf 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts @@ -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; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.html index 00d1f26d7..1b1c78e03 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.html @@ -65,7 +65,13 @@ {{ row.collection }} {{ row.documents }} {{ row.size }} - {{ row.oldest }} + + @if (row.undatedHint) { + {{ row.oldest }} + } @else { + {{ row.oldest }} + } + } @empty { @@ -73,6 +79,16 @@ } + @if (storageTotal(); as total) { + + + Total + {{ total.documents }} + {{ total.size }} + + + + }
} @else { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.scss index 1d0861060..d9c0bbe9a 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.scss +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.scss @@ -48,3 +48,9 @@ td { .end { text-align: end; } + +tfoot th, +tfoot td { + border-bottom: none; + font-weight: 500; +} diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.ts index 7334b8190..bde26e407 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/about-tab.ts @@ -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 = 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(() => { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts index d93cef8f0..a037938f1 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/settings/settings-page.spec.ts @@ -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; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.spec.ts index 0d6abb3e4..26844b2aa 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.spec.ts @@ -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'; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.ts index b8a467508..935f974bb 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/formatters.ts @@ -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 { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/info-texts.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/info-texts.ts index d5f9a4f7c..988052ca8 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/info-texts.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/info-texts.ts @@ -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. */ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.spec.ts index e68e7a7cb..1835387a0 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.spec.ts @@ -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', + '—' + ]); + }); +}); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts index 7b601024d..a452fbed2 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/format/labels.ts @@ -1,4 +1,5 @@ import type { + BrowserFamily, ClientSdk, ConnectionType, EgressStatus, @@ -95,6 +96,36 @@ export const OS_LABELS: Readonly> = { other: 'Other' }; +/** Family names a client may report as its OS, matched whole and case-insensitively, and their family. */ +const REPORTED_OS: Readonly> = { + 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> = { + chrome: 'Chrome', + firefox: 'Firefox', + safari: 'Safari', + edge: 'Edge', + none: 'No browser', + other: 'Other' +}; + export const CONNECTION_TYPE_LABELS: Readonly> = { udp: 'UDP', tcp: 'TCP', diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/routes.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/routes.ts index ce325624b..48b3fcd1e 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/routes.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/routes.ts @@ -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', diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts index 27f624942..f0b347102 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/shell.spec.ts @@ -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) => diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/sidenav-footer.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/sidenav-footer.ts index 03e8dee54..50845fe25 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/sidenav-footer.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/shell/sidenav-footer.ts @@ -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()) { @@ -31,7 +30,7 @@ import { EditionBadge } from './edition-badge.js'; } @else {
- {{ version() }} + {{ version() }}
Documentation @@ -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 { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/styles/_index.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/styles/_index.scss index 8ce235c7c..4879d75e9 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/styles/_index.scss +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/styles/_index.scss @@ -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; diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/.openspec.yaml b/openvidu-dashboard/openspec/changes/ui-review-fixes/.openspec.yaml new file mode 100644 index 000000000..ac359ee85 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-03 diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/design.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/design.md new file mode 100644 index 000000000..3e20ae0a9 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/design.md @@ -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..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. diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/proposal.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/proposal.md new file mode 100644 index 000000000..7a1870549 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/proposal.md @@ -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 " 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..oldest` already exists. diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/analytics-ui/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/analytics-ui/spec.md new file mode 100644 index 000000000..55c30de36 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/analytics-ui/spec.md @@ -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 %" diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/editions-info/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/editions-info/spec.md new file mode 100644 index 000000000..5d650c805 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/editions-info/spec.md @@ -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` diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/egress-ui/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/egress-ui/spec.md new file mode 100644 index 000000000..c63f5df8b --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/egress-ui/spec.md @@ -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" diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/settings-about/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/settings-about/spec.md new file mode 100644 index 000000000..0013449d9 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/settings-about/spec.md @@ -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" diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-building-blocks/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-building-blocks/spec.md new file mode 100644 index 000000000..ae6ebf3b8 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-building-blocks/spec.md @@ -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 " 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 "—" diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-shell/spec.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-shell/spec.md new file mode 100644 index 000000000..db47d2b62 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/specs/ui-shell/spec.md @@ -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 diff --git a/openvidu-dashboard/openspec/changes/ui-review-fixes/tasks.md b/openvidu-dashboard/openspec/changes/ui-review-fixes/tasks.md new file mode 100644 index 000000000..90d38a034 --- /dev/null +++ b/openvidu-dashboard/openspec/changes/ui-review-fixes/tasks.md @@ -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.