From d1b3063b382c8848cc33ac42969cf44f2a5b486a Mon Sep 17 00:00:00 2001 From: juancarmore Date: Wed, 4 Nov 2026 12:13:58 +0100 Subject: [PATCH] openvidu-dashboard: media node labels, and nothing about nodes in CE The contract gains mediaNode on nodes, rooms and participants, stoppedAt on nodes and the partial mediaNode index that replaces the nodeId one; the scenario's two servers are labelled. The status reports the writer without node counts and with stoppedAt when the server stopped, the rooms list filters by mediaNode, list items carry it, the overview groups by it for editions and a node scope's media statistics coverage follows it. CE renders nothing about nodes without per-node-breakdown: no nodes line, By node card, Node select, scope selector or Node columns, not even locked; the Overview reads "Server stopped at" for a stopped server. The generator gains --media-nodes, which node rollups now require. --- openvidu-dashboard/AGENTS.md | 5 +- openvidu-dashboard/CHANGELOG.md | 6 +- .../dashboard-ce/backend/AGENTS.md | 3 +- .../src/analytics/analytics-repository.ts | 7 +- .../src/overview/overview-repository.ts | 10 +-- .../src/participants/participants-mappers.ts | 1 + .../src/participants/participants-query.ts | 1 + .../participants/participants-responses.ts | 4 + .../backend/src/rooms/rooms-mappers.ts | 1 + .../backend/src/rooms/rooms-query.ts | 16 ++-- .../backend/src/rooms/rooms-responses.ts | 3 +- .../backend/src/rooms/rooms-schemas.ts | 7 +- .../backend/src/status/freshness.ts | 9 +- .../backend/src/status/status-routes.ts | 5 +- .../backend/src/status/status-service.ts | 17 ++-- .../test/integration/analytics-api.test.ts | 14 ++- .../integration/analytics-query-plan.test.ts | 3 +- .../test/integration/data-generator.test.ts | 89 ++++++++++++++++++- .../test/integration/info-query-plan.test.ts | 3 +- .../test/integration/participants-api.test.ts | 26 +++++- .../test/integration/rooms-api.test.ts | 26 ++++-- .../test/integration/rooms-query-plan.test.ts | 30 ++++--- .../test/integration/status-api.test.ts | 30 +++++-- .../backend/test/unit/overview.test.ts | 2 +- .../backend/test/unit/rooms-query.test.ts | 16 ++-- .../backend/test/unit/rooms-schemas.test.ts | 6 +- .../backend/test/unit/status.test.ts | 35 ++++++-- .../dashboard-ce/e2e/fixtures/overview.ts | 10 +-- .../e2e/tests-backend/overview.spec.ts | 6 +- .../dashboard-ce/e2e/tests/analytics.spec.ts | 7 +- .../dashboard-ce/e2e/tests/info-tips.spec.ts | 6 +- .../dashboard-ce/e2e/tests/overview.spec.ts | 9 +- .../frontend/projects/dashboard-ui/README.md | 8 +- .../frontend/projects/dashboard-ui/icons.json | 1 + .../lib/core/refresh/server-status.spec.ts | 4 +- .../features/analytics/analytics-page.html | 17 ---- .../features/analytics/analytics-page.scss | 14 --- .../features/analytics/analytics-page.spec.ts | 8 +- .../lib/features/analytics/analytics-page.ts | 8 +- .../lib/features/analytics/analytics-seams.ts | 2 +- .../lib/features/overview/overview-data.ts | 5 +- .../lib/features/overview/overview-page.html | 28 ++---- .../lib/features/overview/overview-page.scss | 8 ++ .../features/overview/overview-page.spec.ts | 58 ++++++++---- .../lib/features/overview/overview-page.ts | 22 ++--- .../participant-sessions-table.html | 2 +- .../participants/participant-sessions.ts | 5 +- .../participants/participants-page.spec.ts | 32 +++++++ .../lib/features/rooms/room-locked-page.ts | 2 +- .../src/lib/features/rooms/rooms-data.spec.ts | 6 +- .../src/lib/features/rooms/rooms-data.ts | 9 +- .../src/lib/features/rooms/rooms-page.html | 19 +--- .../src/lib/features/rooms/rooms-page.scss | 14 --- .../src/lib/features/rooms/rooms-page.spec.ts | 37 ++++---- .../src/lib/features/rooms/rooms-page.ts | 21 ++--- .../dashboard-ui/src/lib/format/info-texts.ts | 3 +- .../dashboard-ui/src/testing/fixtures.ts | 6 +- .../tools/data-generator/README.md | 18 ++-- .../tools/data-generator/src/derive.ts | 5 +- .../tools/data-generator/src/generate.ts | 4 +- .../tools/data-generator/src/generator.ts | 57 +++++++++++- .../tools/data-generator/src/keep-alive.ts | 12 +-- .../tools/data-generator/src/model.ts | 16 +++- .../tools/data-generator/src/schema.ts | 15 ++++ .../dashboard-ce/tools/demo-lib.sh | 2 +- .../typings/contract/fixtures/participants.ts | 20 ++++- .../typings/contract/fixtures/rooms.ts | 4 + .../typings/contract/fixtures/scenario.ts | 3 + .../contract/fixtures/stats-and-rollups.ts | 14 +-- .../typings/contract/fixtures/system.ts | 18 +++- .../dashboard-ce/typings/contract/indexes.ts | 17 ++-- .../dashboard-ce/typings/contract/schemas.ts | 12 ++- .../typings/fixtures/analytics_5min.json | 4 +- .../typings/fixtures/analytics_daily.json | 4 +- .../typings/fixtures/analytics_hourly.json | 4 +- .../dashboard-ce/typings/fixtures/nodes.json | 6 +- .../typings/fixtures/participants.json | 12 +++ .../dashboard-ce/typings/fixtures/rooms.json | 2 + .../dashboard-ce/typings/schema/indexes.json | 6 +- .../typings/schema/nodes.schema.json | 8 +- .../typings/schema/participants.schema.json | 5 ++ .../typings/schema/rooms.schema.json | 5 ++ .../dashboard-ce/typings/src/api/analytics.ts | 2 +- .../typings/src/api/participants.ts | 2 + .../dashboard-ce/typings/src/api/rooms.ts | 6 +- .../dashboard-ce/typings/src/api/status.ts | 5 +- .../typings/src/data-model/entities.ts | 4 + .../typings/src/data-model/system.ts | 6 +- 88 files changed, 702 insertions(+), 348 deletions(-) diff --git a/openvidu-dashboard/AGENTS.md b/openvidu-dashboard/AGENTS.md index a8a38ad40..0560731dc 100644 --- a/openvidu-dashboard/AGENTS.md +++ b/openvidu-dashboard/AGENTS.md @@ -68,8 +68,9 @@ published by `docker-release.yaml` in openvidu-deployment. - UI: every CE page and building block lives in the `dashboard-ui` library; the applications only compose routes. An extending edition adds its pages over locked CE routes and fills explicit seams (see the library README). Keep seams open in CE (injectable services, route configs, content projection) and never put logic in the shells. -- `GET /api/v1/info` reports the edition and its features. CE shows PRO features locked, never hidden; PRO API routes - answer `403 PRO_FEATURE` in CE. +- `GET /api/v1/info` reports the edition and its features. CE shows PRO features locked, never hidden, except + `per-node-breakdown`: CE runs on one machine, so nothing about nodes renders without it. PRO API routes answer + `403 PRO_FEATURE` in CE. ## Data contract diff --git a/openvidu-dashboard/CHANGELOG.md b/openvidu-dashboard/CHANGELOG.md index 3e067ea17..ebcd91f6e 100644 --- a/openvidu-dashboard/CHANGELOG.md +++ b/openvidu-dashboard/CHANGELOG.md @@ -36,7 +36,8 @@ queried) and `Deployment` (running the service). One line per observable change, #### UI - Overview page with live counters of rooms, participants, publishers, egress, ingress and agents linked to their - lists, today's figures, recent rooms, the data pipeline health (writer and nodes reporting) and stale-data notices. + lists, today's figures, recent rooms, the data pipeline health (last batch, or when the server stopped) and stale-data + notices. - Participants page with live refresh: active participants, sessions in range and active services; search participant sessions by identity or SID, filter by kind (standard participants by default), status, room and publishers, sort by join, leave and duration, and see the sources each session published. @@ -131,6 +132,7 @@ queried) and `Deployment` (running the service). One line per observable change, their rooms with the last of their sessions once LiveKit's dead node cleanup released them, with an end time accurate to 30 s (`openvidu.analytics.reconciler` options `enabled`, `interval` and `max_closures_per_run`); a recording lost with its node is reported as failed. -- Analytics events and statistics queued in a node are written before it stops on a graceful shutdown. +- Analytics events and statistics queued in a node are written before it stops on a graceful shutdown, and the node + records when it stopped. - Credentials are never stored in the analytics database; metadata is stored only with `store_metadata`, and client IP addresses are left out unless `anonymize_ip: false`. diff --git a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md index f920476af..6dd91973f 100644 --- a/openvidu-dashboard/dashboard-ce/backend/AGENTS.md +++ b/openvidu-dashboard/dashboard-ce/backend/AGENTS.md @@ -26,7 +26,8 @@ Behaviour is specified in `openspec/specs/*-api`, `api-conventions`, `auth`, `st lock's place (mount order kept) and swaps a CE route for its extension with `registry.replace()`; a `pro` startup that leaves a lock without a definition exits with code 1, naming it. - Extension tokens that `configure` can rebind: `Features`, `AnalyticsCatalogue` (metrics and dimensions the - analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`). A + analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`, folded from the + node documents and the active rooms and sessions grouped by `mediaNode`). A `configure` that throws stops the startup with code 1. - A resource slice is `src//`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts` (MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers diff --git a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts index 6ef3e5283..15295ef1f 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/analytics/analytics-repository.ts @@ -31,12 +31,15 @@ export class AnalyticsRepository { .sort({ bucket: 1 }); } - /** The `mediaStats` of the nodes of `scope` whose heartbeats span overlaps `[from, to]`, on `lastSeenAt`. */ + /** + * The `mediaStats` of the node documents of `scope` (every one for `global`, those with the media node label of a node + * scope) whose heartbeats span overlaps `[from, to]`, on `lastSeenAt`. + */ nodesInRange(scope: string, from: Date, to: Date): FindCursor> { const filter: Document = { lastSeenAt: { $gte: from }, firstSeenAt: { $lte: to } }; if (scope.startsWith(NODE_SCOPE_PREFIX)) { - filter._id = scope.slice(NODE_SCOPE_PREFIX.length); + filter.mediaNode = scope.slice(NODE_SCOPE_PREFIX.length); } return this.db diff --git a/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-repository.ts b/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-repository.ts index 7ec02a146..e1836d6c1 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-repository.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/overview/overview-repository.ts @@ -25,16 +25,16 @@ export const TODAY_FIELDS = [ 'ingress.sessionsStarted' ] as const; -/** Active rooms of a node. */ +/** Active rooms of a media node; `_id` is the label, `null` for the rooms without one. */ export interface RoomGroup { _id: string | null; rooms: number; stale: number; } -/** Active participants of one kind on a node. */ +/** Active participants of one kind on a media node; `mediaNode` is `null` for the sessions without a label. */ export interface ParticipantGroup { - _id: { nodeId: string | null; kind: ParticipantKind }; + _id: { mediaNode: string | null; kind: ParticipantKind }; count: number; publishers: number; stale: number; @@ -52,7 +52,7 @@ function staleCount(threshold: Date): Document { export function activeRoomsPipeline(threshold: Date): Document[] { return [ { $match: { status: RoomStatus.ACTIVE } }, - { $group: { _id: '$nodeId', rooms: { $sum: 1 }, stale: staleCount(threshold) } } + { $group: { _id: '$mediaNode', rooms: { $sum: 1 }, stale: staleCount(threshold) } } ]; } @@ -62,7 +62,7 @@ export function activeParticipantsPipeline(threshold: Date): Document[] { { $match: { kind: { $in: Object.values(ParticipantKind) }, status: ParticipantStatus.ACTIVE } }, { $group: { - _id: { nodeId: '$nodeId', kind: '$kind' }, + _id: { mediaNode: '$mediaNode', kind: '$kind' }, count: { $sum: 1 }, publishers: { $sum: { $cond: ['$isPublisher', 1, 0] } }, stale: staleCount(threshold) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-mappers.ts b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-mappers.ts index 07c7217d7..8d3427590 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-mappers.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-mappers.ts @@ -41,6 +41,7 @@ export function toParticipantListItem(doc: ParticipantDocument): ParticipantList durationMs: doc.durationMs, connectionType: doc.connectionType, nodeId: doc.nodeId, + mediaNode: doc.mediaNode, isPublisher: doc.isPublisher, publishedSources: publishedSources(doc), client: clientSummary(doc) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-query.ts b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-query.ts index 2d2b56dd3..97272465a 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-query.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-query.ts @@ -19,6 +19,7 @@ export const PARTICIPANT_LIST_PROJECTION: Readonly = { durationMs: 1, connectionType: 1, nodeId: 1, + mediaNode: 1, isPublisher: 1, lastStatsAt: 1, 'tracks.bySource': 1, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-responses.ts index 420fea84b..3c2962781 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/participants/participants-responses.ts @@ -31,6 +31,10 @@ export const participantListFields = { durationMs: count.optional(), connectionType: connectionType.optional(), nodeId: z.string().optional(), + mediaNode: z + .string() + .optional() + .meta({ description: 'Media node label of the hosting server, absent without one' }), isPublisher: z.boolean(), publishedSources: z .array(z.enum(enumValues(TrackSource))) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts index 1b5567675..6fbfb2a45 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-mappers.ts @@ -13,6 +13,7 @@ export function toRoomListItem(doc: RoomDocument): RoomListItem { durationMs: doc.durationMs, closedBy: doc.closedBy, nodeId: doc.nodeId, + mediaNode: doc.mediaNode, participants: { sessions: doc.participants.sessions, unique: doc.participants.unique, diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-query.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-query.ts index c273f7a58..481292e2a 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-query.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-query.ts @@ -15,6 +15,7 @@ export const ROOM_LIST_PROJECTION: Readonly = { durationMs: 1, closedBy: 1, nodeId: 1, + mediaNode: 1, 'participants.sessions': 1, 'participants.unique': 1, 'participants.active': 1, @@ -81,8 +82,8 @@ function anyFeature(flags: Document[]): Document { /** * Filter, sort and keyset condition of a rooms list request, following the filter-to-index table of the design: a - * SID is a point lookup; a name prefix drives `{ nameLower, createdAt }` (node, status and features residual); a node - * drives `{ nodeId, status, createdAt, _id }`; features are an `$or` of branches, one per partial index; otherwise + * SID is a point lookup; a name prefix drives `{ nameLower, createdAt }` (media node, status and features residual); a + * media node drives `{ mediaNode, status, createdAt, _id }`; features are an `$or` of branches, one per partial index; otherwise * the `status` picks `{ status, createdAt, _id }` or `{ createdAt, _id }`. The range is always on `createdAt`; a * ranked sort key scans it whole and ranks in memory. */ @@ -102,14 +103,19 @@ export function roomsListPlan(params: RoomsListParams, cursor?: CursorPayload): return { nameLower: prefixRegex(params.q), ...(status ? { status } : {}), - ...(params.nodeId === undefined ? {} : { nodeId: params.nodeId }), + ...(params.mediaNode === undefined ? {} : { mediaNode: params.mediaNode }), [field]: fieldFilter, ...anyFeature(flags) }; } - if (params.nodeId !== undefined) { - return { nodeId: params.nodeId, status: status ?? ANY_STATUS, [field]: fieldFilter, ...anyFeature(flags) }; + if (params.mediaNode !== undefined) { + return { + mediaNode: params.mediaNode, + status: status ?? ANY_STATUS, + [field]: fieldFilter, + ...anyFeature(flags) + }; } if (flags.length > 0) { diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts index f7cc858fd..1b9044022 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-responses.ts @@ -17,7 +17,8 @@ export const roomListFields = { endedAt: isoDate.optional(), durationMs: count.optional(), closedBy: z.enum(enumValues(ClosedBy)).optional(), - nodeId: z.string().optional(), + nodeId: z.string().optional().meta({ description: 'Id of the server process that hosts the room' }), + mediaNode: z.string().optional().meta({ description: 'Media node label of that server, absent without one' }), participants: z.object({ sessions: count, unique: count, active: count, peakActive: count }), egress: z.object({ total: count, active: count }), ingress: z.object({ sessions: count, active: count }), diff --git a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts index fd903da59..8380199dc 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/rooms/rooms-schemas.ts @@ -28,7 +28,12 @@ export function roomsListQuery(now: () => Date) { features: csvParam(enumValues(RoomFeature)) .optional() .meta({ description: 'Rooms that used any of these features' }), - nodeId: z.string().min(1).max(128).optional().meta({ description: 'Rooms hosted by this node' }), + mediaNode: z + .string() + .min(1) + .max(128) + .optional() + .meta({ description: 'Rooms hosted by the server with this media node label' }), sort: z .enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' }) .default(RoomSort.CREATED_AT) diff --git a/openvidu-dashboard/dashboard-ce/backend/src/status/freshness.ts b/openvidu-dashboard/dashboard-ce/backend/src/status/freshness.ts index 10c5d8128..4ed9dd7e0 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/status/freshness.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/status/freshness.ts @@ -8,7 +8,7 @@ const INTERVAL_WINDOW_MS = 3_600_000; /** How long the windows read from `nodes` are reused. */ const WINDOWS_CACHE_MS = 10_000; -export type NodeHeartbeat = Pick; +export type NodeHeartbeat = Pick; /** The largest batch interval among the nodes seen within the last hour of `now`; undefined when none reports one. */ export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date): number | undefined { @@ -23,9 +23,12 @@ export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date): return largest; } -/** A node is alive while its last batch is within the fresh window of its own batch interval. */ +/** A node is alive while it has not stopped and its last batch is within the fresh window of its own batch interval. */ export function nodeAlive(node: NodeHeartbeat, now: Date): boolean { - return now.getTime() - node.lastSeenAt.getTime() <= freshnessWindows(node.batchIntervalMs).freshMs; + return ( + node.stoppedAt === undefined && + now.getTime() - node.lastSeenAt.getTime() <= freshnessWindows(node.batchIntervalMs).freshMs + ); } /** The stale window and the server clock of one response. */ diff --git a/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts b/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts index 63cb21626..7a193fdad 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/status/status-routes.ts @@ -14,9 +14,8 @@ export const statusResponse = z migration: healthFactsShape.migration, writer: z.object({ lastSeenAt: isoDate.optional(), - nodes: count, - aliveNodes: count.meta({ - description: 'Nodes whose last batch is within the fresh window of their own batch interval' + stoppedAt: isoDate.optional().meta({ + description: 'Graceful stop of the newest node, present only when no node is alive' }), batchIntervalMs: count.optional().meta({ description: 'Largest batch interval of the nodes seen in the last hour; absent reads as 10 s' diff --git a/openvidu-dashboard/dashboard-ce/backend/src/status/status-service.ts b/openvidu-dashboard/dashboard-ce/backend/src/status/status-service.ts index ad494e877..80c300a53 100644 --- a/openvidu-dashboard/dashboard-ce/backend/src/status/status-service.ts +++ b/openvidu-dashboard/dashboard-ce/backend/src/status/status-service.ts @@ -12,17 +12,20 @@ export interface StatusServiceOptions { now: () => Date; } -/** The newest batch of the nodes, their count, the alive ones and the writer's batch interval at `now`. */ +/** + * The newest batch of the nodes, the writer's batch interval and, when no node is alive at `now` and the newest one + * stopped gracefully, its stop. + */ export function foldWriter(nodes: readonly NodeHeartbeat[], now: Date): StatusWriter { - const lastSeen = nodes.reduce( - (newest, node) => (newest === undefined || node.lastSeenAt > newest ? node.lastSeenAt : newest), + const newest = nodes.reduce( + (found, node) => (found === undefined || node.lastSeenAt > found.lastSeenAt ? node : found), undefined ); + const stopped = newest?.stoppedAt !== undefined && !nodes.some((node) => nodeAlive(node, now)); return compact({ - lastSeenAt: lastSeen?.toISOString(), - nodes: nodes.length, - aliveNodes: nodes.filter((node) => nodeAlive(node, now)).length, + lastSeenAt: newest?.lastSeenAt.toISOString(), + stoppedAt: stopped ? newest.stoppedAt?.toISOString() : undefined, batchIntervalMs: writerBatchInterval(nodes, now) }); } @@ -39,7 +42,7 @@ export class StatusService { this.clock(), this.db .collection(Collection.NODES) - .find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1 } }) + .find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1, stoppedAt: 1 } }) .toArray(), this.options.info.health() ]); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts index ebedf5039..7df9d3941 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-api.test.ts @@ -300,16 +300,22 @@ describe.skipIf(mongoUri === '')('analytics API', () => { expect(await coverage()).toBeUndefined(); }); - it('reads the node of a node scope only', async () => { + it('reads the node documents of the media node of a node scope only', async () => { const nodes = ce.dashboard.db.collection<{ _id: string } & Document>(Collection.NODES); const repository = new AnalyticsRepository(ce.dashboard.db); const from = new Date(Date.now() - HOUR); await nodes.deleteMany({}); - await nodes.insertMany([node('ND_a', false), node('ND_b', true)]); + await nodes.insertMany([ + { ...node('ND_a', false), mediaNode: 'mn-a' }, + { ...node('ND_b', true), mediaNode: 'mn-b' }, + { ...node('ND_b2', false), mediaNode: 'mn-b' } + ]); - expect(await repository.nodesMediaStats('node:ND_b', from, new Date())).toEqual([true]); - expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, true]); + expect(await repository.nodesMediaStats('node:mn-a', from, new Date())).toEqual([false]); + expect((await repository.nodesMediaStats('node:mn-b', from, new Date())).sort()).toEqual([false, true]); + expect(await repository.nodesMediaStats('node:ND_b', from, new Date())).toEqual([]); + expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, false, true]); }); }); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts index 1518da06b..95d34f802 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/analytics-query-plan.test.ts @@ -88,7 +88,8 @@ describe.skipIf(mongoUri === '')(`analytics query plan at scenario ${scenario}`, seed: 1, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, timeseries: true, - rollups: { fiveMinute: true, nodes: true, roomSums: true } + rollups: { fiveMinute: true, nodes: true, roomSums: true }, + mediaNodes: true }); repository = new AnalyticsRepository(db); service = new AnalyticsService(db, { diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts index 8a1c16b79..00641a72c 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/data-generator.test.ts @@ -1,11 +1,12 @@ import { CONTRACT_SCHEMAS } from '@openvidu-dashboard/data-generator/contract'; import { generate } from '@openvidu-dashboard/data-generator/generator'; -import { COLLECTION_NAMES } from '@openvidu-dashboard/data-generator/schema'; +import { COLLECTION_NAMES, keepLive } from '@openvidu-dashboard/data-generator/schema'; import type { EgressDocument, EventDocument, HourlyRollupDocument, - IngressSessionDocument + IngressSessionDocument, + NodeDocument } from '@openvidu-dashboard/typings'; import { type Document, type Filter, MongoClient } from 'mongodb'; import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; @@ -41,6 +42,7 @@ describe.skipIf(mongoUri === '')('data generator', () => { retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, timeseries: true, rollups: { fiveMinute: true, nodes: true, roomSums: true }, + mediaNodes: true, activeWindowMinutes: 60, minActiveRooms: 10 }); @@ -200,6 +202,88 @@ describe.skipIf(mongoUri === '')('data generator', () => { } }); + it('labels the nodes and adds an earlier stopped process with media nodes', async () => { + const db = client.db('dashboard_test_generator_media_nodes'); + await db.dropDatabase(); + + try { + await generate(db, { + scenario: 'S', + days: 2, + statsMode: 'participant', + seed: 3, + retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, + timeseries: true, + rollups: { fiveMinute: true, nodes: true, roomSums: true }, + mediaNodes: true + }); + + const nodes = await db.collection('nodes').find().toArray(); + const first = nodes.filter((node) => node.mediaNode === 'media-node-10-0-0-1'); + const stopped = first.find((node) => node.stoppedAt !== undefined); + const current = first.find((node) => node.stoppedAt === undefined); + + expect(nodes).toHaveLength(4); + expect(new Set(nodes.map((node) => node.mediaNode))).toEqual( + new Set(['media-node-10-0-0-1', 'media-node-10-0-0-2', 'media-node-10-0-0-3']) + ); + expect(first).toHaveLength(2); + expect(stopped?.lastSeenAt.getTime()).toBeLessThan(current?.firstSeenAt.getTime() ?? 0); + expect(await db.collection('rooms').countDocuments({ nodeId: stopped?._id })).toBeGreaterThan(0); + + for (const node of nodes) { + expect( + await db + .collection('rooms') + .countDocuments({ nodeId: node._id, mediaNode: { $ne: node.mediaNode } }) + ).toBe(0); + expect( + await db + .collection('participants') + .countDocuments({ nodeId: node._id, mediaNode: { $ne: node.mediaNode } }) + ).toBe(0); + } + + const scopes = (await db.collection('analytics_hourly').distinct('scope')) as string[]; + + expect(scopes.filter((scope) => scope !== 'global').sort()).toEqual([ + 'node:media-node-10-0-0-1', + 'node:media-node-10-0-0-2', + 'node:media-node-10-0-0-3' + ]); + expect( + await db.collection('nodes').countDocuments({ $nor: [{ $jsonSchema: CONTRACT_SCHEMAS.nodes }] }) + ).toBe(0); + + const later = new Date(Date.now() + 10 * 60_000); + + await keepLive(db, later); + const kept = await db.collection('nodes').find().toArray(); + + expect(kept.filter((node) => node.lastSeenAt.getTime() === later.getTime())).toHaveLength(3); + expect(kept.find((node) => node._id === stopped?._id)?.lastSeenAt).toEqual(stopped?.lastSeenAt); + } finally { + await db.dropDatabase(); + } + }); + + it('rejects node rollups without media nodes', async () => { + const db = client.db('dashboard_test_generator_node_rollups'); + + await expect( + generate(db, { + scenario: 'S', + days: 1, + statsMode: 'participant', + seed: 3, + retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, + timeseries: true, + rollups: { fiveMinute: false, nodes: true, roomSums: false } + }) + ).rejects.toThrow('mediaNodes'); + await db.dropDatabase(); + }); + it('generates a node without media statistics on request', async () => { const db = client.db('dashboard_test_generator_media_stats'); await db.dropDatabase(); @@ -213,6 +297,7 @@ describe.skipIf(mongoUri === '')('data generator', () => { retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, timeseries: true, rollups: { fiveMinute: true, nodes: true, roomSums: true }, + mediaNodes: true, nodesWithoutMediaStats: 1 }); 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 index 69c1365a0..3f301b913 100644 --- 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 @@ -38,7 +38,8 @@ describe.skipIf(mongoUri === '')('info oldest-document queries', () => { seed: 1, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, timeseries: true, - rollups: { fiveMinute: true, nodes: true, roomSums: true } + rollups: { fiveMinute: true, nodes: true, roomSums: true }, + mediaNodes: true }); }, 600_000); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts index 33ed55d02..0c627742a 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/participants-api.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; -import { type ListResponse, type ParticipantListItem } from '@openvidu-dashboard/typings'; +import { Collection, type ListResponse, type ParticipantListItem } from '@openvidu-dashboard/typings'; import { openApiDocument } from '../../src/api/openapi.js'; import type { RouteRegistry } from '../../src/api/route-registry.js'; @@ -372,5 +372,29 @@ describe.skipIf(mongoUri === '')('participants API', () => { 'PA_ag3' ]); }); + + it('carries the media node label of the sessions that have one', async () => { + await reseed(ce.dashboard.db, { + participants: [ + participant({ + _id: 'PA_labelled', + identity: 'alice', + roomSid: 'RM_a', + joinedAt: minutesAgo(3), + mediaNode: 'mn-a' + }), + participant({ _id: 'PA_plain', identity: 'bob', roomSid: 'RM_a', joinedAt: minutesAgo(2) }) + ] + }); + + await ce.dashboard.db + .collection(Collection.PARTICIPANTS) + .updateOne({ _id: 'PA_plain' } as never, { $unset: { mediaNode: '' } }); + + const items = (await json>(ce, '/participants')).items; + + expect(items.find((item) => item.sid === 'PA_labelled')).toMatchObject({ mediaNode: 'mn-a' }); + expect(items.find((item) => item.sid === 'PA_plain')).not.toHaveProperty('mediaNode'); + }); }); }); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts index 6caa782d6..1fc7dfe9e 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-api.test.ts @@ -170,7 +170,7 @@ describe.skipIf(mongoUri === '')('rooms API', () => { expect(sids(exact)).toEqual(['RM_ab12cd']); }); - it('filters by features and node', async () => { + it('filters by features and media node', async () => { const plain = { egress: false, ingress: false, agent: false, sip: false }; await reseed(ce.dashboard.db, { @@ -178,14 +178,30 @@ describe.skipIf(mongoUri === '')('rooms API', () => { closedRoom({ _id: 'RM_eg', createdAt: minutesAgo(30), features: { ...plain, egress: true } }), closedRoom({ _id: 'RM_in', createdAt: minutesAgo(20), features: { ...plain, ingress: true } }), closedRoom({ _id: 'RM_plain', createdAt: minutesAgo(10), features: plain }), - activeRoom({ _id: 'RM_nodeA', createdAt: minutesAgo(5), nodeId: 'node-a', features: plain }), - activeRoom({ _id: 'RM_nodeB', createdAt: minutesAgo(4), nodeId: 'node-b', features: plain }) + activeRoom({ + _id: 'RM_nodeA', + createdAt: minutesAgo(5), + nodeId: 'ND_a', + mediaNode: 'mn-a', + features: plain + }), + activeRoom({ + _id: 'RM_nodeB', + createdAt: minutesAgo(4), + nodeId: 'ND_b', + mediaNode: 'mn-b', + features: plain + }) ] }); expect(sids(await json(ce, '/rooms?features=egress,ingress'))).toEqual(['RM_in', 'RM_eg']); - expect(sids(await json(ce, '/rooms?nodeId=node-a&status=active'))).toEqual(['RM_nodeA']); - expect(sids(await json(ce, '/rooms?q=rm_node&nodeId=node-a'))).toEqual(['RM_nodeA']); + const one = await json>(ce, '/rooms?mediaNode=mn-a&status=active'); + + expect(sids(one)).toEqual(['RM_nodeA']); + expect(one.items[0]).toMatchObject({ nodeId: 'ND_a', mediaNode: 'mn-a' }); + expect(sids(await json(ce, '/rooms?mediaNode=mn-z'))).toEqual([]); + expect(sids(await json(ce, '/rooms?q=rm_node&mediaNode=mn-a'))).toEqual(['RM_nodeA']); }); it('carries the media statistics mark of the rooms whose node reports none, sorted or ranked', async () => { diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-query-plan.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-query-plan.test.ts index 5dd488bc7..c4c1569f7 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-query-plan.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/rooms-query-plan.test.ts @@ -88,7 +88,8 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, () statsMode: 'participant', seed: 1, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, - timeseries: true + timeseries: true, + mediaNodes: true }); ctx = await queryContext(db); service = new RoomsService(db, { @@ -127,27 +128,28 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, () it.each(['endedAt', 'duration', 'uniqueIdentities', 'active'])( 'ranked by %s: the range index of the filters, at most the rooms in range', async (sort) => { - const { nodeId } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; + const { mediaNode } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; expectIndexes(await explainList({ sort }, page), ['createdAt_id']); expectIndexes(await explainList({ sort, status: 'closed' }, page), ['status_createdAt_id']); - expectIndexes(await explainList({ sort, nodeId: nodeId ?? 'none' }, page), [ - 'nodeId_status_createdAt_id' + expectIndexes(await explainList({ sort, mediaNode: mediaNode ?? 'none' }, page), [ + 'mediaNode_status_createdAt_id' ]); expectIndexes(await explainList({ sort, features: 'egress' }, page), ['status_createdAt_id_egress']); expectIndexes(await explainList({ sort, q: ctx.samples.namePrefix }, page), ['nameLower_createdAt']); } ); - it('name prefix within one node: nameLower_createdAt, node residual', async () => { - const { nodeId } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; + it('name prefix within one media node: nameLower_createdAt, media node residual', async () => { + const { mediaNode } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; const inPrefix = await db .collection(Collection.ROOMS) .countDocuments({ nameLower: prefixRegex(ctx.samples.namePrefix) }); - expectIndexes(await explainList({ q: ctx.samples.namePrefix, nodeId: nodeId ?? 'none' }, () => inPrefix), [ - 'nameLower_createdAt' - ]); + expectIndexes( + await explainList({ q: ctx.samples.namePrefix, mediaNode: mediaNode ?? 'none' }, () => inPrefix), + ['nameLower_createdAt'] + ); }); it('features: one partial index per flag', async () => { @@ -174,10 +176,12 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, () expectIdLookup(await explainList({ q: ctx.samples.roomSid }, () => 1)); }); - it('node: nodeId_status_createdAt_id', async () => { - const { nodeId } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; + it('media node: mediaNode_status_createdAt_id', async () => { + const { mediaNode } = (await db.collection(Collection.ROOMS).findOne({})) ?? {}; - expectIndexes(await explainList({ nodeId: nodeId ?? 'none' }, page), ['nodeId_status_createdAt_id']); + expectIndexes(await explainList({ mediaNode: mediaNode ?? 'none' }, page), [ + 'mediaNode_status_createdAt_id' + ]); }); it('a page after a cursor keeps the index and examines the page and the ties', async () => { @@ -300,7 +304,7 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, () ] ), ['List, two features', '150 ms', () => service.list(listParams({ features: 'egress,agent' }))], - ['List, node', '150 ms', () => service.list(listParams({ nodeId: biggest?.nodeId ?? 'none' }))], + ['List, node', '150 ms', () => service.list(listParams({ mediaNode: biggest?.mediaNode ?? 'none' }))], [`List, prefix "${common}"`, '150 ms', () => service.list(listParams({ q: common }))], [ 'Summary, exact (24 h)', diff --git a/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts index 50b21ebaa..41f33aecb 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/integration/status-api.test.ts @@ -62,12 +62,12 @@ describe.skipIf(mongoUri === '')('status API', () => { generatedAt: expect.any(String) as string, schema: { dashboard: SCHEMA_VERSION, status: 'uninitialized' }, migration: { state: 'none' }, - writer: { nodes: 0, aliveNodes: 0 } + writer: {} }); expect(Math.abs(Date.parse(body.generatedAt) - Date.now())).toBeLessThan(SECOND); }); - it('reports the newest batch, the nodes reporting and a running migration', async () => { + it('reports the newest batch and a running migration', async () => { const newest = node('node-a', 4 * SECOND); await replace(ce.dashboard.db, Collection.NODES, [ @@ -90,7 +90,7 @@ describe.skipIf(mongoUri === '')('status API', () => { const body = await json(ce, '/status'); - expect(body.writer).toEqual({ lastSeenAt: newest.lastSeenAt.toISOString(), nodes: 3, aliveNodes: 2 }); + expect(body.writer).toEqual({ lastSeenAt: newest.lastSeenAt.toISOString() }); expect(body.schema.status).toBe('ok'); expect(body.migration).toMatchObject({ state: 'running', @@ -99,7 +99,7 @@ describe.skipIf(mongoUri === '')('status API', () => { expect(Object.keys(body)).not.toContain('retention'); }); - it('judges each node by its own batch interval and reports the largest of the last hour', async () => { + it('reports the largest batch interval of the last hour', async () => { await replace(ce.dashboard.db, Collection.NODES, [ node('node-a', 80 * SECOND, 60_000), node('node-b', 40 * SECOND, 10_000), @@ -108,10 +108,30 @@ describe.skipIf(mongoUri === '')('status API', () => { const body = await json(ce, '/status'); - expect(body.writer).toMatchObject({ nodes: 3, aliveNodes: 1, batchIntervalMs: 60_000 }); + expect(body.writer).toMatchObject({ batchIntervalMs: 60_000 }); + expect(body.writer).not.toHaveProperty('stoppedAt'); expect(statusResponse.parse(body)).toEqual(body); }); + it('reports a server stopped gracefully until another node reports', async () => { + const stoppedAt = ago(6 * SECOND); + + await replace(ce.dashboard.db, Collection.NODES, [ + { ...node('node-a', 6 * SECOND), stoppedAt }, + node('node-old', 2 * HOUR) + ]); + expect((await json(ce, '/status')).writer).toEqual({ + lastSeenAt: stoppedAt.toISOString(), + stoppedAt: stoppedAt.toISOString() + }); + + await replace(ce.dashboard.db, Collection.NODES, [ + { ...node('node-a', 60 * SECOND), stoppedAt: ago(60 * SECOND) }, + node('node-b', 3 * SECOND) + ]); + expect((await json(ce, '/status')).writer).not.toHaveProperty('stoppedAt'); + }); + it('documents the route', () => { const document = openApiDocument(ce.dashboard.running.container.get(TOKENS.RouteRegistry), { version: 'test' diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/overview.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/overview.test.ts index 601b4250e..3890305db 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/overview.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/overview.test.ts @@ -10,7 +10,7 @@ import { todayWindow } from '../../src/overview/overview-window.js'; const at = (iso: string) => new Date(iso); function group(kind: ParticipantGroup['_id']['kind'], count: number, extra: Partial = {}) { - return { _id: { nodeId: 'node-a', kind }, count, publishers: 0, stale: 0, ...extra }; + return { _id: { mediaNode: 'mn-a', kind }, count, publishers: 0, stale: 0, ...extra }; } describe('overview today window', () => { diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-query.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-query.test.ts index 94a90035c..6e1ef3abd 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-query.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-query.test.ts @@ -93,10 +93,10 @@ describe('rooms list plan', () => { }); }); - it('name prefix within one node: the node stays a residual filter', () => { - expect(plan({ q: 'demo', nodeId: 'node-a' }).filter).toEqual({ + it('name prefix within one media node: the label stays a residual filter', () => { + expect(plan({ q: 'demo', mediaNode: 'mn-a' }).filter).toEqual({ nameLower: { $regex: '^demo' }, - nodeId: 'node-a', + mediaNode: 'mn-a', createdAt: range }); }); @@ -113,16 +113,16 @@ describe('rooms list plan', () => { }); it('SID: point lookup ignoring every other filter', () => { - expect(roomsListPlan(params({ q: 'RM_ab12cd', status: 'active', features: 'sip', nodeId: 'n' }))).toEqual({ + expect(roomsListPlan(params({ q: 'RM_ab12cd', status: 'active', features: 'sip', mediaNode: 'n' }))).toEqual({ kind: 'sid', filter: { _id: 'RM_ab12cd' } }); }); - it('node: { nodeId, status, createdAt, _id } with status all as $in', () => { - expect(plan({ nodeId: 'node-a' }).filter).toEqual({ nodeId: 'node-a', status: ANY, createdAt: range }); - expect(plan({ nodeId: 'node-a', status: 'active', features: 'egress' }).filter).toEqual({ - nodeId: 'node-a', + it('media node: { mediaNode, status, createdAt, _id } with status all as $in', () => { + expect(plan({ mediaNode: 'mn-a' }).filter).toEqual({ mediaNode: 'mn-a', status: ANY, createdAt: range }); + expect(plan({ mediaNode: 'mn-a', status: 'active', features: 'egress' }).filter).toEqual({ + mediaNode: 'mn-a', status: 'active', createdAt: range, 'features.egress': true diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-schemas.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-schemas.test.ts index 0f29654e9..d5fa58d2e 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-schemas.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/rooms-schemas.test.ts @@ -51,9 +51,9 @@ describe('rooms list query', () => { expect(list.parse({ features: 'egress, ingress' }).features).toEqual(['egress', 'ingress']); }); - it('bounds the node id and the page size', () => { - expect(firstIssue(list, { nodeId: '' })?.path).toEqual(['nodeId']); - expect(firstIssue(list, { nodeId: 'n'.repeat(129) })?.path).toEqual(['nodeId']); + it('bounds the media node label and the page size', () => { + expect(firstIssue(list, { mediaNode: '' })?.path).toEqual(['mediaNode']); + expect(firstIssue(list, { mediaNode: 'n'.repeat(129) })?.path).toEqual(['mediaNode']); expect(firstIssue(list, { limit: '101' })?.path).toEqual(['limit']); }); }); diff --git a/openvidu-dashboard/dashboard-ce/backend/test/unit/status.test.ts b/openvidu-dashboard/dashboard-ce/backend/test/unit/status.test.ts index 945c0638a..c04d03eb8 100644 --- a/openvidu-dashboard/dashboard-ce/backend/test/unit/status.test.ts +++ b/openvidu-dashboard/dashboard-ce/backend/test/unit/status.test.ts @@ -10,21 +10,38 @@ const seen = (secondsAgo: number, batchIntervalMs?: number) => ({ }); describe('writer status', () => { - it('takes the newest batch and counts the nodes seen within 30 s', () => { - expect(foldWriter([seen(50), seen(4), seen(30)], NOW)).toEqual({ - lastSeenAt: '2026-10-13T11:59:56.000Z', - nodes: 3, - aliveNodes: 2 - }); + const stopped = (secondsAgo: number) => { + const at = new Date(NOW.getTime() - secondsAgo * 1000); + + return { lastSeenAt: at, stoppedAt: at }; + }; + + it('takes the newest batch', () => { + expect(foldWriter([seen(50), seen(4), seen(30)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:56.000Z' }); }); it('reports no batch without nodes', () => { - expect(foldWriter([], NOW)).toEqual({ nodes: 0, aliveNodes: 0 }); + expect(foldWriter([], NOW)).toEqual({}); + }); + + it('reports the stop of the newest node when no node is alive', () => { + expect(foldWriter([stopped(6), seen(3600)], NOW)).toEqual({ + lastSeenAt: '2026-10-13T11:59:54.000Z', + stoppedAt: '2026-10-13T11:59:54.000Z' + }); + }); + + it('reports no stop after a restart', () => { + expect(foldWriter([stopped(60), seen(3)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:57.000Z' }); + }); + + it('reports no stop while another node is alive', () => { + expect(foldWriter([stopped(2), seen(6)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:58.000Z' }); }); it('judges each node by its own interval and reports the largest one', () => { - expect(foldWriter([seen(80, 60_000), seen(40, 10_000)], NOW)).toMatchObject({ - aliveNodes: 1, + expect(foldWriter([seen(80, 60_000), stopped(120)], NOW)).toEqual({ + lastSeenAt: '2026-10-13T11:58:40.000Z', batchIntervalMs: 60_000 }); }); diff --git a/openvidu-dashboard/dashboard-ce/e2e/fixtures/overview.ts b/openvidu-dashboard/dashboard-ce/e2e/fixtures/overview.ts index 0db1456c9..9c15a74fa 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/fixtures/overview.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/fixtures/overview.ts @@ -92,7 +92,7 @@ export function freshOverview(now = Date.now()): OverviewResponse { }; } -/** The server status at `now`: two nodes reporting, the newest batch `writerAgo` old, and the migration given. */ +/** The server status at `now`: the newest batch `writerAgo` old, and the migration given. */ export function activeStatus( now = Date.now(), writerAgo = 4 * SECOND, @@ -102,11 +102,7 @@ export function activeStatus( generatedAt: new Date(now).toISOString(), schema: { dashboard: 1, writer: 1, status: 'ok' }, migration, - writer: { - lastSeenAt: new Date(now - writerAgo).toISOString(), - nodes: 2, - aliveNodes: writerAgo <= 30 * SECOND ? 2 : 0 - } + writer: { lastSeenAt: new Date(now - writerAgo).toISOString() } }; } @@ -116,6 +112,6 @@ export function freshStatus(now = Date.now()): StatusResponse { generatedAt: new Date(now).toISOString(), schema: { dashboard: 1, status: 'uninitialized' }, migration: { state: 'none' }, - writer: { nodes: 0, aliveNodes: 0 } + writer: {} }; } diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests-backend/overview.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests-backend/overview.spec.ts index c850da9ec..b08571d9e 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests-backend/overview.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests-backend/overview.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -test('overview against the real backend: live tiles, stale hint, writer health, recent rooms and the lock', async ({ +test('overview against the real backend: live tiles, stale hint, writer health, recent rooms and nothing per node', async ({ page }) => { await page.goto('/login?redirect=%2Foverview%3Ftz%3DUTC'); @@ -18,9 +18,9 @@ test('overview against the real backend: live tiles, stale hint, writer health, await expect(page.getByRole('alert').filter({ hasText: 'may be stale' })).toContainText('1 active room'); await expect(page.locator('.page-header ovd-live-chip')).toHaveText('LIVE'); await expect(card('Data pipeline')).toContainText(/Last batch at \d\d:\d\d:\d\d/); - await expect(card('Data pipeline')).toContainText(/\d+ of \d+ nodes? reporting/); + await expect(card('Data pipeline')).not.toContainText('node'); await expect(card('Today (UTC, since 00:00)')).toContainText('3'); await expect(card('Recent rooms').getByRole('row', { name: /Webinar-Q4/ })).toBeVisible(); await expect(card('Recent rooms').locator('ovd-status-badge mat-icon.stale')).toHaveCount(1); - await expect(card('By node')).toContainText('Per-node breakdown is available in OpenVidu PRO'); + await expect(card('By node')).toHaveCount(0); }); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts index 74dccf2d8..1ccc45fd0 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/analytics.spec.ts @@ -109,10 +109,9 @@ test('the PRO strip is locked on CE and sends no PRO request', async ({ page, ap await page.goto('/analytics?from=now-24h&to=now&tz=UTC'); await expect(page.locator('ovd-pro-lock-overlay')).toHaveCount(3); - await expect(page.locator('.scope-select mat-select')).toBeDisabled(); - await expect( - page.locator('.scope-select').getByRole('img', { name: 'Per-node analytics is available in OpenVidu PRO' }) - ).toBeVisible(); + // Nothing per node on CE: no scope selector, lock or text about nodes. + await expect(page.locator('.scope-select')).toHaveCount(0); + await expect(page.getByText('All nodes')).toHaveCount(0); await expect(card(page, 'Quality')).toContainText('Quality analytics is available in OpenVidu PRO'); await expect(card(page, 'Top rooms')).toContainText('Top rooms is available in OpenVidu PRO'); await expect(card(page, 'Quality').getByRole('button', { name: 'Export CSV' })).toBeDisabled(); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/info-tips.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/info-tips.spec.ts index 39589e968..c7bfb2119 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/info-tips.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/info-tips.spec.ts @@ -28,8 +28,10 @@ test('an info tip shows on hover without opening the list its tile links to', as test('an info tip shows on keyboard focus and hides on Escape', async ({ page }) => { await page.goto('/overview?tz=UTC'); // The pointer rests at the top-left corner, where the overlay renders the tooltip before positioning it: the - // mouseleave that follows would close a tooltip still opening, as on a slow runner. - await page.mouse.move(640, 700); + // mouseleave that follows would close a tooltip still opening, as on a slow runner. The page title has no tooltip. + const title = await page.getByRole('heading', { level: 1, name: 'Overview' }).boundingBox(); + + await page.mouse.move((title?.x ?? 0) + 4, (title?.y ?? 0) + 4); const tiles = page.getByRole('region', { name: 'Live' }); diff --git a/openvidu-dashboard/dashboard-ce/e2e/tests/overview.spec.ts b/openvidu-dashboard/dashboard-ce/e2e/tests/overview.spec.ts index 23db33112..0dc593e68 100644 --- a/openvidu-dashboard/dashboard-ce/e2e/tests/overview.spec.ts +++ b/openvidu-dashboard/dashboard-ce/e2e/tests/overview.spec.ts @@ -24,12 +24,15 @@ test('the overview shows the live tiles with links, the hint, the status card an '1 active room and 4 participants have had no stats for over 90 s and may be stale' ); await expect(card(page, 'Data pipeline')).toContainText(/Last batch at \d\d:\d\d:\d\d/); - await expect(card(page, 'Data pipeline')).toContainText(/\d+ of \d+ nodes? reporting/); + await expect(card(page, 'Data pipeline')).not.toContainText('node'); await expect(card(page, 'Data pipeline')).not.toContainText('schema'); await expect(card(page, 'Data pipeline')).not.toContainText('retention:'); await expect(card(page, 'Data pipeline').getByRole('link', { name: 'Storage and retention' })).toBeVisible(); await expect(card(page, 'Today (UTC, since 00:00)')).toContainText('143'); - await expect(card(page, 'By node')).toContainText('Per-node breakdown is available in OpenVidu PRO'); + // CE has a single machine: no per-node card, and Recent rooms takes the full width. + await expect(page.locator('ovd-section-card').filter({ hasText: 'By node' })).toHaveCount(0); + await expect(page.getByText('Per-node breakdown')).toHaveCount(0); + await expect(card(page, 'Recent rooms')).toHaveClass(/\bfull\b/); const recent = card(page, 'Recent rooms'); @@ -105,7 +108,7 @@ test.describe('a silent writer', () => { await page.getByRole('navigation', { name: 'Main' }).getByRole('link', { name: 'Overview' }).click(); await expect(page.locator('.page-header ovd-live-chip')).toHaveText('stale'); - await expect(card(page, 'Data pipeline')).toContainText('0 of 2 nodes reporting'); + await expect(card(page, 'Data pipeline').locator('mat-icon.warn')).toBeVisible(); }); }); 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 0a3f29028..fdb0cfd88 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/README.md @@ -48,8 +48,12 @@ loadComponent: … }] }] }`. The locked `tracks/:trackSid` route, outside the si inject `AnalyticsPageContext` (state, zone, refresh count, series, breakdowns, chart ranges and notes, CSV export, widen and retry) and render an `ovd-analytics-card` with `CardContent`. - `ANALYTICS_SCOPE_SELECT` (Analytics header) and `ROOMS_NODE_FILTER` (Rooms filter bar): loaders of the node - selects, which send their own scopes requests. - - `OVERVIEW_NODES_CARD`: the "By node" card of the Overview, given `overview`, the last overview response. + selects, which send their own scopes requests; the Rooms one binds a media node label to the `node` URL parameter, + sent to the list as `mediaNode`. + - `OVERVIEW_NODES_CARD`: the content of the "By node" card of the Overview, given `overview`, the last overview + response. + - These three, and the Node columns of the Rooms, Participants and Agents tables (the `mediaNode` of each item), + render only with `per-node-breakdown`; without it nothing about nodes renders, not even locked. - **Status styles.** `provideStatusStyles()` registers status domains beyond the CE ones of `ovd-status-badge`, with the styles of `STATUS_FAMILY_STYLES`. - **Chart colours.** `ChartColorsService.colors()` resolves the theme's chart tokens; `resolved(tokens)` resolves an diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/icons.json b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/icons.json index 8373d7729..e3c152f61 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/icons.json +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/icons.json @@ -60,6 +60,7 @@ "settings-fill", "smart_toy", "smart_toy-fill", + "stop_circle", "sync", "video_file", "videocam", diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/refresh/server-status.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/refresh/server-status.spec.ts index 0803cfaa9..fa1517c69 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/refresh/server-status.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/refresh/server-status.spec.ts @@ -72,8 +72,6 @@ describe('ServerStatusService', () => { { writer: { lastSeenAt: new Date(Date.parse('2026-10-13T12:00:00.000Z') - age).toISOString(), - nodes: 1, - aliveNodes: 1, batchIntervalMs: 60_000 } }, @@ -88,7 +86,7 @@ describe('ServerStatusService', () => { expect(status.writerFresh()).toBe(false); expect(status.writerSilent()).toBe(false); - await answer(statusResponse({ writer: { nodes: 1, aliveNodes: 1, batchIntervalMs: 1_000 } }, 4_000)); + await answer(statusResponse({ writer: { batchIntervalMs: 1_000 } }, 4_000)); expect(status.windows()).toMatchObject({ freshMs: 30_000, silentMs: 60_000 }); }); 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 2703dcd86..98450d689 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 @@ -14,23 +14,6 @@ @if (scopeSelect(); as select) { } - } @else { - - Scope - - All nodes - - - - } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.scss index b53ebf197..686e834f9 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.scss +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.scss @@ -25,10 +25,6 @@ width: 170px; } -.scope-select { - width: 240px; -} - .sections { display: flex; flex-direction: column; @@ -55,13 +51,3 @@ color: var(--mat-sys-on-surface-variant); text-align: center; } - -.lock { - display: inline-flex; - margin-inline-end: 8px; - - mat-icon { - width: 20px; - height: 20px; - } -} diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts index 119f1080c..8357e8bdd 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-page.spec.ts @@ -420,11 +420,9 @@ describe('AnalyticsPage', () => { expect(requests.map((request) => request.request.url)).not.toContain('api/v1/analytics/top'); expect(requests.map((request) => request.request.url)).not.toContain('api/v1/analytics/scopes'); expect(seriesRequests(requests).flatMap((request) => request.metrics)).not.toContain('scoreAvg'); - expect(element.querySelector('.scope-select mat-select')?.getAttribute('aria-disabled')).toBe('true'); - expect(text(element.querySelector('.scope-select .mat-mdc-select-value'))).toBe('All nodes'); - expect(element.querySelector('.scope-select .lock')?.getAttribute('aria-label')).toBe( - 'Per-node analytics is available in OpenVidu PRO' - ); + // Nothing per node on CE: no scope selector, lock or text about nodes. + expect(element.querySelector('.scope-select')).toBeNull(); + expect(text(element.querySelector('.page-header'))).not.toContain('All nodes'); expect(card(element, 'By node')).toBeUndefined(); http.verify(); }); 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 5ff3c7f7a..ef19bf6a8 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 @@ -1,10 +1,8 @@ import { NgComponentOutlet } from '@angular/common'; import { Component, computed, effect, forwardRef, inject, signal, type Type, untracked } from '@angular/core'; import { MatButton } from '@angular/material/button'; -import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; -import { MatIcon } from '@angular/material/icon'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatOption, MatSelect } from '@angular/material/select'; -import { MatTooltip } from '@angular/material/tooltip'; import { ANALYTICS_SCOPE_PATTERN, type AnalyticsBreakdown, @@ -166,12 +164,9 @@ function count(value: number | undefined): string | null { KpiTile, MatButton, MatFormField, - MatIcon, MatLabel, MatOption, MatSelect, - MatSuffix, - MatTooltip, NgComponentOutlet, Notice, TileRow, @@ -204,7 +199,6 @@ export class AnalyticsPage implements AnalyticsPageContext { private readonly features = this.info.features; protected readonly perNode = computed(() => this.features().has('per-node-breakdown')); - protected readonly perNodeLockHint = 'Per-node analytics is available in OpenVidu PRO'; private readonly retention = computed(() => this.info.info()?.retention); /** The range resolved when the URL changes; the clock does not move it. */ diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts index 7b99baf59..8071ec5d3 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/analytics/analytics-seams.ts @@ -60,7 +60,7 @@ export const ANALYTICS_PRO_CARDS = new InjectionToken ({}) } ); -/** Loads the scope select of the page header, shown with `per-node-breakdown`; CE renders the locked select. */ +/** Loads the scope select of the page header, shown with `per-node-breakdown`; CE provides none and shows no select. */ export const ANALYTICS_SCOPE_SELECT = new InjectionToken<(() => Promise>) | null>( 'ANALYTICS_SCOPE_SELECT', { factory: () => null } diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-data.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-data.ts index 00ea58ef8..bec6d113f 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-data.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-data.ts @@ -28,7 +28,10 @@ function elapsed(snapshot: OverviewSnapshot, now: number): number { return Math.max(0, now - snapshot.receivedAt); } -/** Component of the PRO "By node" card, given `overview`, the last overview response; CE provides none and renders the lock. */ +/** + * Content of the "By node" card, given `overview`, the last overview response; rendered only with `per-node-breakdown`. + * CE provides none and shows no card. + */ export const OVERVIEW_NODES_CARD = new InjectionToken | null>('OVERVIEW_NODES_CARD', { factory: () => null }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.html index fa074a26b..73422fcfa 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.html @@ -96,21 +96,15 @@ @if (status(); as status) {
  • - @if (writerFresh()) { + @if (status.writer.stoppedAt !== undefined) { +
  • -
  • - @if (status.writer.aliveNodes < status.writer.nodes || status.writer.nodes === 0) { -
  • @if (migrationProgress(); as progress) {
  • Migrating legacy analytics @@ -129,7 +123,7 @@ } - + diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.scss index 30a49cb29..1ce733f71 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.scss +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.scss @@ -62,6 +62,14 @@ .warn { color: var(--ovd-status-warning-fg); } + + .neutral { + color: var(--mat-sys-on-surface-variant); + } +} + +.full { + grid-column: 1 / -1; } .progress { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.spec.ts index 64ec09d88..74a3c6eae 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.spec.ts @@ -1,6 +1,6 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { Component } from '@angular/core'; +import { Component, input } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { RouterTestingHarness } from '@angular/router/testing'; @@ -13,6 +13,7 @@ import { InfoService } from '../../core/info.js'; import { PreferencesService } from '../../core/preferences/preferences.js'; import { LiveCountersService } from '../../core/refresh/live-counters.js'; import { ServerStatusService } from '../../core/refresh/server-status.js'; +import { OVERVIEW_NODES_CARD } from './overview-data.js'; import { OverviewPage } from './overview-page.js'; @Component({ selector: 'ovd-blank', template: '' }) @@ -92,10 +93,7 @@ function status(overrides: Partial = {}, writerAgeMs: number | n generatedAt: new Date(reference).toISOString(), schema: { dashboard: 2, writer: 2, status: 'ok' }, migration: { state: 'completed', progress: { processed: 10, total: 10 } }, - writer: - writerAgeMs === null - ? { nodes: 0, aliveNodes: 0 } - : { lastSeenAt: ago(writerAgeMs), nodes: 2, aliveNodes: 2 }, + writer: writerAgeMs === null ? {} : { lastSeenAt: ago(writerAgeMs) }, ...overrides }; } @@ -107,13 +105,21 @@ async function loadStatus(http: HttpTestingController, body: StatusResponse): Pr await load; } +/** Content an edition gives the By node card. */ +@Component({ selector: 'ovd-test-nodes-card', template: 'media nodes' }) +class NodesCard { + readonly overview = input(null); +} + async function open( body: OverviewResponse | 'fail' = overview(), features: string[] = [], - server: StatusResponse | null = status() + server: StatusResponse | null = status(), + nodesCard: typeof NodesCard | null = null ) { TestBed.configureTestingModule({ providers: [ + { provide: OVERVIEW_NODES_CARD, useValue: nodesCard }, provideRouter([ { path: 'overview', component: OverviewPage }, { path: '**', component: Blank } @@ -236,7 +242,7 @@ describe('OverviewPage', () => { it('turns the chip and the writer line to stale from the last server status', async () => { const { element } = await open(overview(), [], { ...status({}, 3 * MINUTE), - writer: { lastSeenAt: ago(3 * MINUTE), nodes: 1, aliveNodes: 0 } + writer: { lastSeenAt: ago(3 * MINUTE) } }); const chip = element.querySelector('.page-header ovd-live-chip'); @@ -244,10 +250,21 @@ describe('OverviewPage', () => { const pipeline = section(element, 'Data pipeline'); const lines = [...(pipeline?.querySelectorAll('li.line') ?? [])]; + expect(lines).toHaveLength(1); expect(text(lines[0])).toMatch(/^Last batch at \d\d:\d\d:\d\d/); expect(lines[0]?.querySelector('mat-icon.warn')).not.toBeNull(); - expect(text(lines[1])).toBe('0 of 1 node reporting'); - expect(lines[1]?.querySelector('mat-icon.warn')).not.toBeNull(); + }); + + it('reads a stopped server with the neutral icon, not as a warning', async () => { + const { element } = await open(overview(), [], { + ...status({}, 10 * MINUTE), + writer: { lastSeenAt: ago(10 * MINUTE), stoppedAt: ago(10 * MINUTE) } + }); + const line = section(element, 'Data pipeline')?.querySelector('li.line'); + + expect(text(line)).toMatch(/^Server stopped at \d\d:\d\d:\d\d/); + expect(line?.querySelector('mat-icon.neutral')).not.toBeNull(); + expect(line?.querySelector('mat-icon.warn')).toBeNull(); }); it('keeps LIVE and fresh rooms under a long interval, judged at the responses', async () => { @@ -281,12 +298,13 @@ describe('OverviewPage', () => { expect(text(section(element, 'Data pipeline')?.querySelector('li.line'))).not.toBe(before); }); - it('explains node liveness on the By node heading in PRO', async () => { - const { element } = await open(overview(), ['per-node-breakdown']); + it('renders the edition card under By node with per-node-breakdown, explained by its info tip', async () => { + const { element } = await open(overview(), ['per-node-breakdown'], status(), NodesCard); + const card = section(element, 'By node'); - expect( - section(element, 'By node')?.querySelector('header ovd-info-tip button')?.getAttribute('aria-label') - ).toBe('About By node'); + expect(text(card)).toContain('media nodes'); + expect(card?.querySelector('header ovd-info-tip button')?.getAttribute('aria-label')).toBe('About By node'); + expect(section(element, 'Recent rooms')?.classList.contains('full')).toBe(false); }); it('marks an approximate today and an empty one', async () => { @@ -387,8 +405,8 @@ describe('OverviewPage', () => { const status = text(pipeline); expect(status).toMatch(/Last batch at \d\d:\d\d:\d\d/); - expect(status).toContain('2 of 2 nodes reporting'); - expect(pipeline?.querySelectorAll('mat-icon.ok')).toHaveLength(2); + expect(status).not.toContain('node'); + expect(pipeline?.querySelectorAll('mat-icon.ok')).toHaveLength(1); expect(pipeline?.querySelector('mat-progress-bar')).toBeNull(); expect(status).not.toContain('schema'); expect(status).not.toContain('openvidu-livekit'); @@ -442,7 +460,7 @@ describe('OverviewPage', () => { [...element.querySelectorAll('.tiles ovd-kpi-tile')].map((tile) => text(tile).split(' ').at(-1)) ).toEqual(['0', '0', '0', '0', '0', '0']); expect(text(section(element, 'Data pipeline'))).toContain('Last batch: never'); - expect(text(section(element, 'Data pipeline'))).toContain('no node has reported yet'); + expect(text(section(element, 'Data pipeline'))).not.toContain('node'); expect(text(section(element, 'Today'))).toContain('No rooms today yet'); expect(section(element, 'Recent rooms')?.querySelectorAll('.links a')).toHaveLength(0); expect(element.querySelector('ovd-error-banner')).toBeNull(); @@ -460,10 +478,12 @@ describe('OverviewPage', () => { expect(text(element.querySelector('ovd-error-banner'))).toContain('req-9'); }); - it('locks the per-node card on CE with the request of the overview only', async () => { + it('shows nothing per node on CE, with Recent rooms at full width and the request of the overview only', async () => { const { element, http } = await open(); - expect(text(section(element, 'By node'))).toContain('Per-node breakdown is available in OpenVidu PRO'); + expect(section(element, 'By node')).toBeUndefined(); + expect(text(element)).not.toContain('Per-node breakdown'); + expect(section(element, 'Recent rooms')?.classList.contains('full')).toBe(true); http.verify(); }); }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.ts index ca0cb5bb6..b874d0758 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/overview/overview-page.ts @@ -14,7 +14,6 @@ import { EmptyState } from '../../components/feedback/empty-state.js'; import { ErrorBanner } from '../../components/feedback/error-banner.js'; import { Notice } from '../../components/feedback/notice.js'; import { Skeleton } from '../../components/feedback/skeleton.js'; -import { FeatureContent, FeatureGate } from '../../components/lock/feature-gate.js'; import { KpiTile } from '../../components/tiles/kpi-tile.js'; import { SectionCard } from '../../components/tiles/section-card.js'; import { TileRow } from '../../components/tiles/tile-row.js'; @@ -35,8 +34,8 @@ const APPROXIMATE_HINT = const STALE_RESPONSES = 2; /** - * The landing page: live tiles, the stale entities hint, today's rollups, the server status, the recent rooms and the - * per-node card (PRO, locked in CE), polled with `If-None-Match` at the live interval. + * The landing page: live tiles, the stale entities hint, today's rollups, the server status, the recent rooms and, with + * `per-node-breakdown`, the edition's per-node card, polled with `If-None-Match` at the live interval. */ @Component({ selector: 'ovd-overview-page', @@ -46,8 +45,6 @@ const STALE_RESPONSES = 2; Duration, EmptyState, ErrorBanner, - FeatureContent, - FeatureGate, KpiTile, LiveChip, MatIcon, @@ -73,8 +70,9 @@ export class OverviewPage { protected readonly nodesCard = inject(OVERVIEW_NODES_CARD); protected readonly estimate = RECONCILER_ESTIMATE; protected readonly info = INFO_TEXTS; - /** The By node heading explains node liveness only where the card shows nodes. */ - protected readonly perNode = inject(FeatureService).enabledSignal('per-node-breakdown'); + private readonly perNode = inject(FeatureService).enabledSignal('per-node-breakdown'); + /** The By node card, only with `per-node-breakdown` and a card from the edition; nothing about nodes otherwise. */ + protected readonly nodesCardShown = computed(() => this.perNode() && this.nodesCard !== null); protected readonly approximateText = APPROXIMATE_HINT; /** The column order of the Rooms list. */ protected readonly recentColumns = computed(() => [ @@ -208,17 +206,15 @@ export class OverviewPage { } protected writerLine(writer: StatusWriter): string { + if (writer.stoppedAt !== undefined) { + return `Server stopped at ${formatTimestamp(writer.stoppedAt, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS })}`; + } + return writer.lastSeenAt === undefined ? 'Last batch: never' : `Last batch at ${formatTimestamp(writer.lastSeenAt, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS })}`; } - protected nodesLine(writer: StatusWriter): string { - return writer.nodes === 0 - ? 'no node has reported yet' - : `${formatNumber(writer.aliveNodes)} of ${formatNumber(writer.nodes)} node${writer.nodes === 1 ? '' : 's'} reporting`; - } - /** Time only for today's rooms, the date as well for older ones. */ protected time(value: string): string { return formatTimestamp(value, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html index 0c7dcbe4c..686231219 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html @@ -67,7 +67,7 @@ } - {{ item.nodeId ?? '—' }} + {{ item.mediaNode ?? '—' }} @if (exactId(); as sid) { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts index bd73bec4a..115fd9670 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts @@ -4,6 +4,7 @@ import { type ParticipantListItem, ParticipantSort, ParticipantStatus } from '@o import type { SortChange, TableColumn } from '../../components/data-table/data-table.js'; import { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js'; import { exactListId, listSearchQuery } from '../../components/search/list-search.js'; +import { FeatureService } from '../../core/info.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { ListStateService } from '../../core/state/list-state.js'; import { QueryStateService } from '../../core/state/query-state.js'; @@ -34,6 +35,7 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) { const listState = inject(ListStateService); const previews = inject(EntityPreviewService); const rangePicker = inject(RangePickerService); + const perNode = inject(FeatureService).enabledSignal('per-node-breakdown'); const q = computed(() => listSearchQuery(query.q(), 'participant')); const room = computed(() => { @@ -86,7 +88,8 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) { ...ranked.column('duration') }, { key: 'published', header: 'Published', card: 'metric' }, - { key: 'node', header: 'Node', defaultHidden: true } + // The media node label, only where there are several media nodes. + ...(perNode() ? [{ key: 'node', header: 'Node', defaultHidden: true }] : []) ]), /** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */ time: (value: string): string => diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.spec.ts index dff2782dd..16afc5f73 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.spec.ts @@ -11,6 +11,7 @@ import { infoResponse, settle } from '../../../testing/fixtures.js'; import { routeElement } from '../../../testing/harness.js'; import { LIST_SEARCH_DEBOUNCE_MS } from '../../components/search/list-search.js'; import { InfoService } from '../../core/info.js'; +import { PreferencesService } from '../../core/preferences/preferences.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { ParticipantLockedPage } from './participant-locked-page.js'; import { ParticipantsPage } from './participants-page.js'; @@ -119,6 +120,37 @@ function text(element: Element | null | undefined): string { const debounce = () => new Promise((resolve) => setTimeout(resolve, LIST_SEARCH_DEBOUNCE_MS + 50)); describe('ParticipantsPage', () => { + it('offers no Node column in CE', async () => { + const { harness } = await open('/participants'); + const columns = ( + harness.routeDebugElement?.componentInstance as { sessions: { columns(): { key: string }[] } } + ).sessions.columns(); + + expect(columns.map((column) => column.key)).not.toContain('node'); + }); + + it('shows the media node label in the Node column with per-node breakdown', async () => { + const http = setup(); + const info = TestBed.inject(InfoService).load(); + + http.expectOne('api/v1/info').flush(infoResponse({ features: ['per-node-breakdown'] as never[] })); + await info; + TestBed.inject(PreferencesService).setHiddenColumns('participants', []); + + const harness = await RouterTestingHarness.create('/participants'); + + await load(http, harness, page([item({ mediaNode: 'mn-a' }), item({ sid: 'PA_plain' })]), summary()); + await harness.fixture.whenStable(); + + const element = routeElement(harness); + const nodeIndex = [...element.querySelectorAll('thead th')].findIndex((th) => text(th) === 'Node'); + + expect(nodeIndex).toBeGreaterThan(-1); + expect( + [...element.querySelectorAll('tbody tr')].map((row) => text(row.querySelectorAll('td')[nodeIndex])) + ).toEqual(['mn-a', '—']); + }); + it('reproduces the URL state in the controls and the request', async () => { const { element, request } = await open( '/participants?from=now-7d&to=now&tz=UTC&q=ali&kind=STANDARD,EGRESS&status=left&limit=50' diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/room-locked-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/room-locked-page.ts index d9938b56f..a6929801c 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/room-locked-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/room-locked-page.ts @@ -32,7 +32,7 @@ export class RoomLockedPage { status: RoomStatusFilter.ALL, q: sid, features: [], - nodeId: null, + mediaNode: null, sort: RoomSort.CREATED_AT, order: 'desc', cursor: null, diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.spec.ts index b2bcf4e72..b5ec3ee5b 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.spec.ts @@ -29,7 +29,7 @@ describe('RoomsDataService', () => { status: 'closed', q: 'demo', features: ['egress', 'agent'], - nodeId: 'node-a', + mediaNode: 'mn-a', sort: 'endedAt', order: 'asc', cursor: 'abc', @@ -52,11 +52,11 @@ describe('RoomsDataService', () => { (req) => req.urlWithParams === 'api/v1/rooms?from=2026-10-12T12:00:00.000Z&to=2026-10-13T12:00:00.000Z&tz=Europe/Madrid&status=closed' + - '&q=demo&features=egress,agent&nodeId=node-a&sort=endedAt&order=asc&cursor=abc&limit=50' + '&q=demo&features=egress,agent&mediaNode=mn-a&sort=endedAt&order=asc&cursor=abc&limit=50' ).flush({ items: [], nextCursor: null, prevCursor: null }); await full; - const bare = rooms.list({ ...list, q: null, features: [], nodeId: null, cursor: null }); + const bare = rooms.list({ ...list, q: null, features: [], mediaNode: null, cursor: null }); http.expectOne( (req) => diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.ts index 6dffae98b..5a2dacd1a 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-data.ts @@ -13,7 +13,10 @@ import { HttpService, type QueryValue } from '../../core/http/http.js'; import type { RangeParams } from '../../core/state/query-state.js'; import { resolveRange } from '../../core/state/relative-time.js'; -/** Loads the Node select of the Rooms filter bar, shown with `per-node-breakdown`; CE renders the locked select. */ +/** + * Loads the Node select of the Rooms filter bar, shown with `per-node-breakdown`: it binds a media node label to the + * `node` URL parameter. CE provides none and shows no select. + */ export const ROOMS_NODE_FILTER = new InjectionToken<(() => Promise>) | null>('ROOMS_NODE_FILTER', { factory: () => null }); @@ -23,7 +26,7 @@ export interface RoomsListRequest { status: RoomStatusFilter; q: string | null; features: readonly RoomFeature[]; - nodeId: string | null; + mediaNode: string | null; sort: RoomSort; order: SortOrder; cursor: string | null; @@ -50,7 +53,7 @@ export class RoomsDataService { status: request.status, q: request.q, features: request.features, - nodeId: request.nodeId, + mediaNode: request.mediaNode, sort: request.sort, order: request.order, cursor: request.cursor, diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html index 7ae47cbe4..b44fe9d90 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.html @@ -42,23 +42,6 @@ @if (nodeSelect(); as select) { } - } @else { - - Node - - All nodes - - - - }
    @@ -168,7 +151,7 @@ - {{ room.nodeId ?? '—' }} + {{ room.mediaNode ?? '—' }} @if (exactId(); as sid) { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.scss index 09415710c..39dfb2b89 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.scss +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.scss @@ -2,20 +2,6 @@ display: block; } -.node { - width: 200px; -} - -.lock { - display: inline-flex; - margin-inline-end: 8px; - - mat-icon { - width: 20px; - height: 20px; - } -} - .cell, .features { display: inline-flex; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts index fa97665db..e8ee067ef 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.spec.ts @@ -38,7 +38,7 @@ function room(overrides: Partial = {}): RoomListItem { name: 'demo-room', status: 'active', createdAt: new Date(Date.now() - 42 * 60_000).toISOString(), - nodeId: 'node-a', + nodeId: 'ND_a', participants: { sessions: 7, unique: 6, active: 5, peakActive: 6 }, egress: { total: 1, active: 1 }, ingress: { sessions: 0, active: 0 }, @@ -296,32 +296,39 @@ describe('RoomsPage', () => { expect(text(document.querySelector('.mat-mdc-tooltip'))).toBe('Narrow the range to 7 days to sort by Duration'); }); - it('locks the Node select in CE, ignores nodeId and asks for no scopes', async () => { - const { element, request, http } = await open('/rooms?nodeId=a'); - const node = element.querySelector('.node'); + it('shows no Node select, lock or column in CE, ignores node and asks for no scopes', async () => { + const { element, request, http, harness } = await open('/rooms?node=a'); + const columns = (harness.routeDebugElement?.componentInstance as { columns(): { key: string }[] }).columns(); - expect(request.params.has('nodeId')).toBe(false); - expect(node?.querySelector('mat-select')?.getAttribute('aria-disabled')).toBe('true'); - expect(text(node?.querySelector('.mat-mdc-select-value'))).toBe('All nodes'); - expect(node?.querySelector('.lock')?.getAttribute('aria-label')).toBe( - 'Filtering by node is available in OpenVidu PRO' - ); + expect(request.params.has('mediaNode')).toBe(false); + expect(request.params.has('node')).toBe(false); + expect(text(element)).not.toContain('All nodes'); + expect(text(element)).not.toContain('Filtering by node'); + expect(columns.map((column) => column.key)).not.toContain('node'); http.expectNone((req) => req.url === 'api/v1/analytics/scopes'); }); - it('renders the ROOMS_NODE_FILTER select with per-node breakdown and filters by the URL node', async () => { + it('renders the ROOMS_NODE_FILTER select and the Node column with per-node breakdown', async () => { const http = setup([{ provide: ROOMS_NODE_FILTER, useValue: () => Promise.resolve(NodeFilterStub) }]); await withInfo(http, ['per-node-breakdown']); + TestBed.inject(PreferencesService).setHiddenColumns('rooms', []); - const harness = await RouterTestingHarness.create('/rooms?nodeId=a&q=demo'); - const request = await load(http, harness, page([room()])); + const harness = await RouterTestingHarness.create('/rooms?node=mn-a&q=demo'); + const request = await load(http, harness, page([room({ mediaNode: 'mn-a' }), room({ sid: 'RM_plain' })])); await harness.fixture.whenStable(); - expect(request.params.get('nodeId')).toBe('a'); + expect(request.params.get('mediaNode')).toBe('mn-a'); expect(request.params.get('q')).toBe('demo'); expect(routeElement(harness).querySelector('ovd-node-filter-stub')).not.toBeNull(); - expect(routeElement(harness).querySelector('.node .lock')).toBeNull(); + + const rows = [...routeElement(harness).querySelectorAll('tbody tr')]; + const nodeIndex = [...routeElement(harness).querySelectorAll('thead th')].findIndex( + (th) => text(th) === 'Node' + ); + + expect(nodeIndex).toBeGreaterThan(-1); + expect(rows.map((row) => text(row.querySelectorAll('td')[nodeIndex]))).toEqual(['mn-a', '—']); }); it('marks a reconciler-closed room as estimated and an active room the API marks stale', async () => { diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts index e62f5dbd3..e17b34d2f 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts @@ -3,9 +3,6 @@ import { Component, computed, effect, inject, signal, type Type, untracked } fro import { MatButton } from '@angular/material/button'; import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { MatChipListbox, MatChipOption } from '@angular/material/chips'; -import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; -import { MatIcon } from '@angular/material/icon'; -import { MatOption, MatSelect } from '@angular/material/select'; import { MatTooltip } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; import { @@ -72,12 +69,6 @@ interface RoomsData { MatButtonToggleGroup, MatChipListbox, MatChipOption, - MatFormField, - MatIcon, - MatLabel, - MatOption, - MatSelect, - MatSuffix, MatTooltip, NgComponentOutlet, RouterLink, @@ -102,17 +93,17 @@ export class RoomsPage { protected readonly now = injectTicker(); protected readonly tz = this.query.tz; protected readonly limit = this.query.limit; - /** The `ROOMS_NODE_FILTER` select with `per-node-breakdown`, the locked select without it. */ + /** The `ROOMS_NODE_FILTER` select and the Node column with `per-node-breakdown`; nothing about nodes without it. */ protected readonly nodeFilter = inject(FeatureService).enabledSignal('per-node-breakdown'); private readonly loadNodeFilter = inject(ROOMS_NODE_FILTER); protected readonly nodeSelect = signal | null>(null); - protected readonly nodeLockHint = 'Filtering by node is available in OpenVidu PRO'; protected readonly status = computed(() => this.query.oneOf('status', STATUSES, RoomStatusFilter.ALL)); protected readonly q = computed(() => listSearchQuery(this.query.q(), 'room')); protected readonly exactId = computed(() => exactListId(this.q(), 'room')); protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS)); - protected readonly nodeId = computed(() => (this.nodeFilter() ? this.query.value('nodeId') : null)); + /** The media node label of the `node` parameter, ignored without `per-node-breakdown`. */ + protected readonly node = computed(() => (this.nodeFilter() ? this.query.value('node') : null)); private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), this.now())); /** Range span when the range last changed, so the ticker never re-renders the headers. */ private readonly requestRangeMs = computed(() => { @@ -134,7 +125,7 @@ export class RoomsPage { status: this.status(), q: this.q(), features: this.selectedFeatures(), - nodeId: this.nodeId(), + mediaNode: this.node(), sort: this.sort(), order: this.order(), cursor: this.query.cursor(), @@ -206,7 +197,7 @@ export class RoomsPage { }, { key: 'bytes', header: 'Data up / down', align: 'end' }, { key: 'features', header: 'Features', info: INFO_TEXTS.roomFeatures }, - { key: 'node', header: 'Node', defaultHidden: true } + ...(this.nodeFilter() ? [{ key: 'node', header: 'Node', defaultHidden: true }] : []) ]); protected readonly trackRoom = (room: RoomListItem): string => room.sid; @@ -264,7 +255,7 @@ export class RoomsPage { } protected clearFilters(): void { - void this.query.navigate({ status: null, q: null, features: null, nodeId: null }); + void this.query.navigate({ status: null, q: null, features: null, node: null }); } protected clearSearch(): void { 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 988052ca8..0c4006cbe 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 @@ -17,8 +17,7 @@ export const INFO_TEXTS = { 'Agent sessions connected now. Agents join rooms as participants of kind Agent but are not counted as participants.', todayParticipants: 'Participant sessions that started today. Only regular participants count.', todayMinutes: 'Minutes of the participant sessions that ended today.', - dataPipeline: - 'The OpenVidu server writes analytics in batches. This card shows when the last batch arrived and how many nodes are reporting.', + dataPipeline: 'The OpenVidu server writes analytics in batches. This card shows when the last batch arrived.', byNode: 'Nodes that reported analytics recently. A node is alive while its analytics batches keep arriving.', dailyUniqueIdentities: diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/testing/fixtures.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/testing/fixtures.ts index 1b157be78..d4c1db813 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/testing/fixtures.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/testing/fixtures.ts @@ -29,11 +29,9 @@ export function statusResponse( migration: { state: 'completed', progress: { processed: 10, total: 10 } }, writer: writerAgeMs === null - ? { nodes: 0, aliveNodes: 0 } + ? {} : { - lastSeenAt: new Date(Date.parse(generatedAt) - writerAgeMs).toISOString(), - nodes: 1, - aliveNodes: 1 + lastSeenAt: new Date(Date.parse(generatedAt) - writerAgeMs).toISOString() }, ...overrides }; diff --git a/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md b/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md index 4d1f3d758..a496c71a7 100644 --- a/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md +++ b/openvidu-dashboard/dashboard-ce/tools/data-generator/README.md @@ -41,11 +41,12 @@ pnpm generate --uri "$MONGO_URI" --db ov_data_l --scenario L --days 1 --drop --s | `--drop` | off | Drop the database first | | `--plain-stats` | off | Force the plain-collection fallback for `track_stats` | | `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window | -| `--node-rollups` | off | Write the `node:` scope beside `global` in every rollup | +| `--node-rollups` | off | With `--media-nodes`: write the `node: