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.
master
juancarmore 2026-11-04 12:13:58 +01:00
parent 90edceac29
commit d1b3063b38
88 changed files with 702 additions and 348 deletions

View File

@ -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 - 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 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. 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 - `GET /api/v1/info` reports the edition and its features. CE shows PRO features locked, never hidden, except
answer `403 PRO_FEATURE` in CE. `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 ## Data contract

View File

@ -36,7 +36,8 @@ queried) and `Deployment` (running the service). One line per observable change,
#### UI #### UI
- Overview page with live counters of rooms, participants, publishers, egress, ingress and agents linked to their - 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 - 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 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. 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 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 to 30 s (`openvidu.analytics.reconciler` options `enabled`, `interval` and `max_closures_per_run`); a recording lost
with its node is reported as failed. 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 - 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`. IP addresses are left out unless `anonymize_ip: false`.

View File

@ -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 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. 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 - 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. `configure` that throws stops the startup with code 1.
- A resource slice is `src/<resource>/`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts` - A resource slice is `src/<resource>/`: `*-schemas.ts` (Zod), `*-query.ts` (pure filter builders), `*-repository.ts`
(MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers (MongoDB), `*-service.ts` (paging, mapping), `*-responses.ts` (OpenAPI response schemas, checked against the mappers

View File

@ -31,12 +31,15 @@ export class AnalyticsRepository {
.sort({ bucket: 1 }); .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<Pick<NodeDocument, 'mediaStats'>> { nodesInRange(scope: string, from: Date, to: Date): FindCursor<Pick<NodeDocument, 'mediaStats'>> {
const filter: Document = { lastSeenAt: { $gte: from }, firstSeenAt: { $lte: to } }; const filter: Document = { lastSeenAt: { $gte: from }, firstSeenAt: { $lte: to } };
if (scope.startsWith(NODE_SCOPE_PREFIX)) { 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 return this.db

View File

@ -25,16 +25,16 @@ export const TODAY_FIELDS = [
'ingress.sessionsStarted' 'ingress.sessionsStarted'
] as const; ] 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 { export interface RoomGroup {
_id: string | null; _id: string | null;
rooms: number; rooms: number;
stale: 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 { export interface ParticipantGroup {
_id: { nodeId: string | null; kind: ParticipantKind }; _id: { mediaNode: string | null; kind: ParticipantKind };
count: number; count: number;
publishers: number; publishers: number;
stale: number; stale: number;
@ -52,7 +52,7 @@ function staleCount(threshold: Date): Document {
export function activeRoomsPipeline(threshold: Date): Document[] { export function activeRoomsPipeline(threshold: Date): Document[] {
return [ return [
{ $match: { status: RoomStatus.ACTIVE } }, { $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 } }, { $match: { kind: { $in: Object.values(ParticipantKind) }, status: ParticipantStatus.ACTIVE } },
{ {
$group: { $group: {
_id: { nodeId: '$nodeId', kind: '$kind' }, _id: { mediaNode: '$mediaNode', kind: '$kind' },
count: { $sum: 1 }, count: { $sum: 1 },
publishers: { $sum: { $cond: ['$isPublisher', 1, 0] } }, publishers: { $sum: { $cond: ['$isPublisher', 1, 0] } },
stale: staleCount(threshold) stale: staleCount(threshold)

View File

@ -41,6 +41,7 @@ export function toParticipantListItem(doc: ParticipantDocument): ParticipantList
durationMs: doc.durationMs, durationMs: doc.durationMs,
connectionType: doc.connectionType, connectionType: doc.connectionType,
nodeId: doc.nodeId, nodeId: doc.nodeId,
mediaNode: doc.mediaNode,
isPublisher: doc.isPublisher, isPublisher: doc.isPublisher,
publishedSources: publishedSources(doc), publishedSources: publishedSources(doc),
client: clientSummary(doc) client: clientSummary(doc)

View File

@ -19,6 +19,7 @@ export const PARTICIPANT_LIST_PROJECTION: Readonly<Document> = {
durationMs: 1, durationMs: 1,
connectionType: 1, connectionType: 1,
nodeId: 1, nodeId: 1,
mediaNode: 1,
isPublisher: 1, isPublisher: 1,
lastStatsAt: 1, lastStatsAt: 1,
'tracks.bySource': 1, 'tracks.bySource': 1,

View File

@ -31,6 +31,10 @@ export const participantListFields = {
durationMs: count.optional(), durationMs: count.optional(),
connectionType: connectionType.optional(), connectionType: connectionType.optional(),
nodeId: z.string().optional(), nodeId: z.string().optional(),
mediaNode: z
.string()
.optional()
.meta({ description: 'Media node label of the hosting server, absent without one' }),
isPublisher: z.boolean(), isPublisher: z.boolean(),
publishedSources: z publishedSources: z
.array(z.enum(enumValues(TrackSource))) .array(z.enum(enumValues(TrackSource)))

View File

@ -13,6 +13,7 @@ export function toRoomListItem(doc: RoomDocument): RoomListItem {
durationMs: doc.durationMs, durationMs: doc.durationMs,
closedBy: doc.closedBy, closedBy: doc.closedBy,
nodeId: doc.nodeId, nodeId: doc.nodeId,
mediaNode: doc.mediaNode,
participants: { participants: {
sessions: doc.participants.sessions, sessions: doc.participants.sessions,
unique: doc.participants.unique, unique: doc.participants.unique,

View File

@ -15,6 +15,7 @@ export const ROOM_LIST_PROJECTION: Readonly<Document> = {
durationMs: 1, durationMs: 1,
closedBy: 1, closedBy: 1,
nodeId: 1, nodeId: 1,
mediaNode: 1,
'participants.sessions': 1, 'participants.sessions': 1,
'participants.unique': 1, 'participants.unique': 1,
'participants.active': 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 * 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 * SID is a point lookup; a name prefix drives `{ nameLower, createdAt }` (media node, status and features residual); a
* drives `{ nodeId, status, createdAt, _id }`; features are an `$or` of branches, one per partial index; otherwise * 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 * 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. * ranked sort key scans it whole and ranks in memory.
*/ */
@ -102,14 +103,19 @@ export function roomsListPlan(params: RoomsListParams, cursor?: CursorPayload):
return { return {
nameLower: prefixRegex(params.q), nameLower: prefixRegex(params.q),
...(status ? { status } : {}), ...(status ? { status } : {}),
...(params.nodeId === undefined ? {} : { nodeId: params.nodeId }), ...(params.mediaNode === undefined ? {} : { mediaNode: params.mediaNode }),
[field]: fieldFilter, [field]: fieldFilter,
...anyFeature(flags) ...anyFeature(flags)
}; };
} }
if (params.nodeId !== undefined) { if (params.mediaNode !== undefined) {
return { nodeId: params.nodeId, status: status ?? ANY_STATUS, [field]: fieldFilter, ...anyFeature(flags) }; return {
mediaNode: params.mediaNode,
status: status ?? ANY_STATUS,
[field]: fieldFilter,
...anyFeature(flags)
};
} }
if (flags.length > 0) { if (flags.length > 0) {

View File

@ -17,7 +17,8 @@ export const roomListFields = {
endedAt: isoDate.optional(), endedAt: isoDate.optional(),
durationMs: count.optional(), durationMs: count.optional(),
closedBy: z.enum(enumValues(ClosedBy)).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 }), participants: z.object({ sessions: count, unique: count, active: count, peakActive: count }),
egress: z.object({ total: count, active: count }), egress: z.object({ total: count, active: count }),
ingress: z.object({ sessions: count, active: count }), ingress: z.object({ sessions: count, active: count }),

View File

@ -28,7 +28,12 @@ export function roomsListQuery(now: () => Date) {
features: csvParam(enumValues(RoomFeature)) features: csvParam(enumValues(RoomFeature))
.optional() .optional()
.meta({ description: 'Rooms that used any of these features' }), .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 sort: z
.enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' }) .enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' })
.default(RoomSort.CREATED_AT) .default(RoomSort.CREATED_AT)

View File

@ -8,7 +8,7 @@ const INTERVAL_WINDOW_MS = 3_600_000;
/** How long the windows read from `nodes` are reused. */ /** How long the windows read from `nodes` are reused. */
const WINDOWS_CACHE_MS = 10_000; const WINDOWS_CACHE_MS = 10_000;
export type NodeHeartbeat = Pick<NodeDocument, 'lastSeenAt' | 'batchIntervalMs'>; export type NodeHeartbeat = Pick<NodeDocument, 'lastSeenAt' | 'batchIntervalMs' | 'stoppedAt'>;
/** The largest batch interval among the nodes seen within the last hour of `now`; undefined when none reports one. */ /** 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 { export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date): number | undefined {
@ -23,9 +23,12 @@ export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date):
return largest; 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 { 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. */ /** The stale window and the server clock of one response. */

View File

@ -14,9 +14,8 @@ export const statusResponse = z
migration: healthFactsShape.migration, migration: healthFactsShape.migration,
writer: z.object({ writer: z.object({
lastSeenAt: isoDate.optional(), lastSeenAt: isoDate.optional(),
nodes: count, stoppedAt: isoDate.optional().meta({
aliveNodes: count.meta({ description: 'Graceful stop of the newest node, present only when no node is alive'
description: 'Nodes whose last batch is within the fresh window of their own batch interval'
}), }),
batchIntervalMs: count.optional().meta({ batchIntervalMs: count.optional().meta({
description: 'Largest batch interval of the nodes seen in the last hour; absent reads as 10 s' description: 'Largest batch interval of the nodes seen in the last hour; absent reads as 10 s'

View File

@ -12,17 +12,20 @@ export interface StatusServiceOptions {
now: () => Date; 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 { export function foldWriter(nodes: readonly NodeHeartbeat[], now: Date): StatusWriter {
const lastSeen = nodes.reduce<Date | undefined>( const newest = nodes.reduce<NodeHeartbeat | undefined>(
(newest, node) => (newest === undefined || node.lastSeenAt > newest ? node.lastSeenAt : newest), (found, node) => (found === undefined || node.lastSeenAt > found.lastSeenAt ? node : found),
undefined undefined
); );
const stopped = newest?.stoppedAt !== undefined && !nodes.some((node) => nodeAlive(node, now));
return compact<StatusWriter>({ return compact<StatusWriter>({
lastSeenAt: lastSeen?.toISOString(), lastSeenAt: newest?.lastSeenAt.toISOString(),
nodes: nodes.length, stoppedAt: stopped ? newest.stoppedAt?.toISOString() : undefined,
aliveNodes: nodes.filter((node) => nodeAlive(node, now)).length,
batchIntervalMs: writerBatchInterval(nodes, now) batchIntervalMs: writerBatchInterval(nodes, now)
}); });
} }
@ -39,7 +42,7 @@ export class StatusService {
this.clock(), this.clock(),
this.db this.db
.collection<NodeDocument>(Collection.NODES) .collection<NodeDocument>(Collection.NODES)
.find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1 } }) .find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1, stoppedAt: 1 } })
.toArray(), .toArray(),
this.options.info.health() this.options.info.health()
]); ]);

View File

@ -300,16 +300,22 @@ describe.skipIf(mongoUri === '')('analytics API', () => {
expect(await coverage()).toBeUndefined(); 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 nodes = ce.dashboard.db.collection<{ _id: string } & Document>(Collection.NODES);
const repository = new AnalyticsRepository(ce.dashboard.db); const repository = new AnalyticsRepository(ce.dashboard.db);
const from = new Date(Date.now() - HOUR); const from = new Date(Date.now() - HOUR);
await nodes.deleteMany({}); 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('node:mn-a', from, new Date())).toEqual([false]);
expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, true]); 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]);
}); });
}); });

View File

@ -88,7 +88,8 @@ describe.skipIf(mongoUri === '')(`analytics query plan at scenario ${scenario}`,
seed: 1, seed: 1,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true, timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true } rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true
}); });
repository = new AnalyticsRepository(db); repository = new AnalyticsRepository(db);
service = new AnalyticsService(db, { service = new AnalyticsService(db, {

View File

@ -1,11 +1,12 @@
import { CONTRACT_SCHEMAS } from '@openvidu-dashboard/data-generator/contract'; import { CONTRACT_SCHEMAS } from '@openvidu-dashboard/data-generator/contract';
import { generate } from '@openvidu-dashboard/data-generator/generator'; 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 { import type {
EgressDocument, EgressDocument,
EventDocument, EventDocument,
HourlyRollupDocument, HourlyRollupDocument,
IngressSessionDocument IngressSessionDocument,
NodeDocument
} from '@openvidu-dashboard/typings'; } from '@openvidu-dashboard/typings';
import { type Document, type Filter, MongoClient } from 'mongodb'; import { type Document, type Filter, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; 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 }, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true, timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true }, rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true,
activeWindowMinutes: 60, activeWindowMinutes: 60,
minActiveRooms: 10 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<NodeDocument>('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<NodeDocument>('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 () => { it('generates a node without media statistics on request', async () => {
const db = client.db('dashboard_test_generator_media_stats'); const db = client.db('dashboard_test_generator_media_stats');
await db.dropDatabase(); await db.dropDatabase();
@ -213,6 +297,7 @@ describe.skipIf(mongoUri === '')('data generator', () => {
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true, timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true }, rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true,
nodesWithoutMediaStats: 1 nodesWithoutMediaStats: 1
}); });

View File

@ -38,7 +38,8 @@ describe.skipIf(mongoUri === '')('info oldest-document queries', () => {
seed: 1, seed: 1,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true, timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true } rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true
}); });
}, 600_000); }, 600_000);

View File

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; 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 { openApiDocument } from '../../src/api/openapi.js';
import type { RouteRegistry } from '../../src/api/route-registry.js'; import type { RouteRegistry } from '../../src/api/route-registry.js';
@ -372,5 +372,29 @@ describe.skipIf(mongoUri === '')('participants API', () => {
'PA_ag3' '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<ListResponse<ParticipantListItem>>(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');
});
}); });
}); });

View File

@ -170,7 +170,7 @@ describe.skipIf(mongoUri === '')('rooms API', () => {
expect(sids(exact)).toEqual(['RM_ab12cd']); 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 }; const plain = { egress: false, ingress: false, agent: false, sip: false };
await reseed(ce.dashboard.db, { 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_eg', createdAt: minutesAgo(30), features: { ...plain, egress: true } }),
closedRoom({ _id: 'RM_in', createdAt: minutesAgo(20), features: { ...plain, ingress: true } }), closedRoom({ _id: 'RM_in', createdAt: minutesAgo(20), features: { ...plain, ingress: true } }),
closedRoom({ _id: 'RM_plain', createdAt: minutesAgo(10), features: plain }), closedRoom({ _id: 'RM_plain', createdAt: minutesAgo(10), features: plain }),
activeRoom({ _id: 'RM_nodeA', createdAt: minutesAgo(5), nodeId: 'node-a', features: plain }), activeRoom({
activeRoom({ _id: 'RM_nodeB', createdAt: minutesAgo(4), nodeId: 'node-b', features: plain }) _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?features=egress,ingress'))).toEqual(['RM_in', 'RM_eg']);
expect(sids(await json(ce, '/rooms?nodeId=node-a&status=active'))).toEqual(['RM_nodeA']); const one = await json<ListResponse<RoomListItem>>(ce, '/rooms?mediaNode=mn-a&status=active');
expect(sids(await json(ce, '/rooms?q=rm_node&nodeId=node-a'))).toEqual(['RM_nodeA']);
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 () => { it('carries the media statistics mark of the rooms whose node reports none, sorted or ranked', async () => {

View File

@ -88,7 +88,8 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, ()
statsMode: 'participant', statsMode: 'participant',
seed: 1, seed: 1,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 }, retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true timeseries: true,
mediaNodes: true
}); });
ctx = await queryContext(db); ctx = await queryContext(db);
service = new RoomsService(db, { service = new RoomsService(db, {
@ -127,27 +128,28 @@ describe.skipIf(mongoUri === '')(`rooms query plan at scenario ${scenario}`, ()
it.each(['endedAt', 'duration', 'uniqueIdentities', 'active'])( it.each(['endedAt', 'duration', 'uniqueIdentities', 'active'])(
'ranked by %s: the range index of the filters, at most the rooms in range', 'ranked by %s: the range index of the filters, at most the rooms in range',
async (sort) => { async (sort) => {
const { nodeId } = (await db.collection<RoomDocument>(Collection.ROOMS).findOne({})) ?? {}; const { mediaNode } = (await db.collection<RoomDocument>(Collection.ROOMS).findOne({})) ?? {};
expectIndexes(await explainList({ sort }, page), ['createdAt_id']); expectIndexes(await explainList({ sort }, page), ['createdAt_id']);
expectIndexes(await explainList({ sort, status: 'closed' }, page), ['status_createdAt_id']); expectIndexes(await explainList({ sort, status: 'closed' }, page), ['status_createdAt_id']);
expectIndexes(await explainList({ sort, nodeId: nodeId ?? 'none' }, page), [ expectIndexes(await explainList({ sort, mediaNode: mediaNode ?? 'none' }, page), [
'nodeId_status_createdAt_id' 'mediaNode_status_createdAt_id'
]); ]);
expectIndexes(await explainList({ sort, features: 'egress' }, page), ['status_createdAt_id_egress']); expectIndexes(await explainList({ sort, features: 'egress' }, page), ['status_createdAt_id_egress']);
expectIndexes(await explainList({ sort, q: ctx.samples.namePrefix }, page), ['nameLower_createdAt']); expectIndexes(await explainList({ sort, q: ctx.samples.namePrefix }, page), ['nameLower_createdAt']);
} }
); );
it('name prefix within one node: nameLower_createdAt, node residual', async () => { it('name prefix within one media node: nameLower_createdAt, media node residual', async () => {
const { nodeId } = (await db.collection<RoomDocument>(Collection.ROOMS).findOne({})) ?? {}; const { mediaNode } = (await db.collection<RoomDocument>(Collection.ROOMS).findOne({})) ?? {};
const inPrefix = await db const inPrefix = await db
.collection(Collection.ROOMS) .collection(Collection.ROOMS)
.countDocuments({ nameLower: prefixRegex(ctx.samples.namePrefix) }); .countDocuments({ nameLower: prefixRegex(ctx.samples.namePrefix) });
expectIndexes(await explainList({ q: ctx.samples.namePrefix, nodeId: nodeId ?? 'none' }, () => inPrefix), [ expectIndexes(
'nameLower_createdAt' await explainList({ q: ctx.samples.namePrefix, mediaNode: mediaNode ?? 'none' }, () => inPrefix),
]); ['nameLower_createdAt']
);
}); });
it('features: one partial index per flag', async () => { 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)); expectIdLookup(await explainList({ q: ctx.samples.roomSid }, () => 1));
}); });
it('node: nodeId_status_createdAt_id', async () => { it('media node: mediaNode_status_createdAt_id', async () => {
const { nodeId } = (await db.collection<RoomDocument>(Collection.ROOMS).findOne({})) ?? {}; const { mediaNode } = (await db.collection<RoomDocument>(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 () => { 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, 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 }))], [`List, prefix "${common}"`, '150 ms', () => service.list(listParams({ q: common }))],
[ [
'Summary, exact (24 h)', 'Summary, exact (24 h)',

View File

@ -62,12 +62,12 @@ describe.skipIf(mongoUri === '')('status API', () => {
generatedAt: expect.any(String) as string, generatedAt: expect.any(String) as string,
schema: { dashboard: SCHEMA_VERSION, status: 'uninitialized' }, schema: { dashboard: SCHEMA_VERSION, status: 'uninitialized' },
migration: { state: 'none' }, migration: { state: 'none' },
writer: { nodes: 0, aliveNodes: 0 } writer: {}
}); });
expect(Math.abs(Date.parse(body.generatedAt) - Date.now())).toBeLessThan(SECOND); 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); const newest = node('node-a', 4 * SECOND);
await replace(ce.dashboard.db, Collection.NODES, [ await replace(ce.dashboard.db, Collection.NODES, [
@ -90,7 +90,7 @@ describe.skipIf(mongoUri === '')('status API', () => {
const body = await json<StatusResponse>(ce, '/status'); const body = await json<StatusResponse>(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.schema.status).toBe('ok');
expect(body.migration).toMatchObject({ expect(body.migration).toMatchObject({
state: 'running', state: 'running',
@ -99,7 +99,7 @@ describe.skipIf(mongoUri === '')('status API', () => {
expect(Object.keys(body)).not.toContain('retention'); 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, [ await replace(ce.dashboard.db, Collection.NODES, [
node('node-a', 80 * SECOND, 60_000), node('node-a', 80 * SECOND, 60_000),
node('node-b', 40 * SECOND, 10_000), node('node-b', 40 * SECOND, 10_000),
@ -108,10 +108,30 @@ describe.skipIf(mongoUri === '')('status API', () => {
const body = await json<StatusResponse>(ce, '/status'); const body = await json<StatusResponse>(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); 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<StatusResponse>(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<StatusResponse>(ce, '/status')).writer).not.toHaveProperty('stoppedAt');
});
it('documents the route', () => { it('documents the route', () => {
const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), { const document = openApiDocument(ce.dashboard.running.container.get<RouteRegistry>(TOKENS.RouteRegistry), {
version: 'test' version: 'test'

View File

@ -10,7 +10,7 @@ import { todayWindow } from '../../src/overview/overview-window.js';
const at = (iso: string) => new Date(iso); const at = (iso: string) => new Date(iso);
function group(kind: ParticipantGroup['_id']['kind'], count: number, extra: Partial<ParticipantGroup> = {}) { function group(kind: ParticipantGroup['_id']['kind'], count: number, extra: Partial<ParticipantGroup> = {}) {
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', () => { describe('overview today window', () => {

View File

@ -93,10 +93,10 @@ describe('rooms list plan', () => {
}); });
}); });
it('name prefix within one node: the node stays a residual filter', () => { it('name prefix within one media node: the label stays a residual filter', () => {
expect(plan({ q: 'demo', nodeId: 'node-a' }).filter).toEqual({ expect(plan({ q: 'demo', mediaNode: 'mn-a' }).filter).toEqual({
nameLower: { $regex: '^demo' }, nameLower: { $regex: '^demo' },
nodeId: 'node-a', mediaNode: 'mn-a',
createdAt: range createdAt: range
}); });
}); });
@ -113,16 +113,16 @@ describe('rooms list plan', () => {
}); });
it('SID: point lookup ignoring every other filter', () => { 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', kind: 'sid',
filter: { _id: 'RM_ab12cd' } filter: { _id: 'RM_ab12cd' }
}); });
}); });
it('node: { nodeId, status, createdAt, _id } with status all as $in', () => { it('media node: { mediaNode, status, createdAt, _id } with status all as $in', () => {
expect(plan({ nodeId: 'node-a' }).filter).toEqual({ nodeId: 'node-a', status: ANY, createdAt: range }); expect(plan({ mediaNode: 'mn-a' }).filter).toEqual({ mediaNode: 'mn-a', status: ANY, createdAt: range });
expect(plan({ nodeId: 'node-a', status: 'active', features: 'egress' }).filter).toEqual({ expect(plan({ mediaNode: 'mn-a', status: 'active', features: 'egress' }).filter).toEqual({
nodeId: 'node-a', mediaNode: 'mn-a',
status: 'active', status: 'active',
createdAt: range, createdAt: range,
'features.egress': true 'features.egress': true

View File

@ -51,9 +51,9 @@ describe('rooms list query', () => {
expect(list.parse({ features: 'egress, ingress' }).features).toEqual(['egress', 'ingress']); expect(list.parse({ features: 'egress, ingress' }).features).toEqual(['egress', 'ingress']);
}); });
it('bounds the node id and the page size', () => { it('bounds the media node label and the page size', () => {
expect(firstIssue(list, { nodeId: '' })?.path).toEqual(['nodeId']); expect(firstIssue(list, { mediaNode: '' })?.path).toEqual(['mediaNode']);
expect(firstIssue(list, { nodeId: 'n'.repeat(129) })?.path).toEqual(['nodeId']); expect(firstIssue(list, { mediaNode: 'n'.repeat(129) })?.path).toEqual(['mediaNode']);
expect(firstIssue(list, { limit: '101' })?.path).toEqual(['limit']); expect(firstIssue(list, { limit: '101' })?.path).toEqual(['limit']);
}); });
}); });

View File

@ -10,21 +10,38 @@ const seen = (secondsAgo: number, batchIntervalMs?: number) => ({
}); });
describe('writer status', () => { describe('writer status', () => {
it('takes the newest batch and counts the nodes seen within 30 s', () => { const stopped = (secondsAgo: number) => {
expect(foldWriter([seen(50), seen(4), seen(30)], NOW)).toEqual({ const at = new Date(NOW.getTime() - secondsAgo * 1000);
lastSeenAt: '2026-10-13T11:59:56.000Z',
nodes: 3, return { lastSeenAt: at, stoppedAt: at };
aliveNodes: 2 };
});
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', () => { 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', () => { 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({ expect(foldWriter([seen(80, 60_000), stopped(120)], NOW)).toEqual({
aliveNodes: 1, lastSeenAt: '2026-10-13T11:58:40.000Z',
batchIntervalMs: 60_000 batchIntervalMs: 60_000
}); });
}); });

View File

@ -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( export function activeStatus(
now = Date.now(), now = Date.now(),
writerAgo = 4 * SECOND, writerAgo = 4 * SECOND,
@ -102,11 +102,7 @@ export function activeStatus(
generatedAt: new Date(now).toISOString(), generatedAt: new Date(now).toISOString(),
schema: { dashboard: 1, writer: 1, status: 'ok' }, schema: { dashboard: 1, writer: 1, status: 'ok' },
migration, migration,
writer: { writer: { lastSeenAt: new Date(now - writerAgo).toISOString() }
lastSeenAt: new Date(now - writerAgo).toISOString(),
nodes: 2,
aliveNodes: writerAgo <= 30 * SECOND ? 2 : 0
}
}; };
} }
@ -116,6 +112,6 @@ export function freshStatus(now = Date.now()): StatusResponse {
generatedAt: new Date(now).toISOString(), generatedAt: new Date(now).toISOString(),
schema: { dashboard: 1, status: 'uninitialized' }, schema: { dashboard: 1, status: 'uninitialized' },
migration: { state: 'none' }, migration: { state: 'none' },
writer: { nodes: 0, aliveNodes: 0 } writer: {}
}; };
} }

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; 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 page
}) => { }) => {
await page.goto('/login?redirect=%2Foverview%3Ftz%3DUTC'); 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.getByRole('alert').filter({ hasText: 'may be stale' })).toContainText('1 active room');
await expect(page.locator('.page-header ovd-live-chip')).toHaveText('LIVE'); 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(/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('Today (UTC, since 00:00)')).toContainText('3');
await expect(card('Recent rooms').getByRole('row', { name: /Webinar-Q4/ })).toBeVisible(); 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('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);
}); });

View File

@ -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 page.goto('/analytics?from=now-24h&to=now&tz=UTC');
await expect(page.locator('ovd-pro-lock-overlay')).toHaveCount(3); await expect(page.locator('ovd-pro-lock-overlay')).toHaveCount(3);
await expect(page.locator('.scope-select mat-select')).toBeDisabled(); // Nothing per node on CE: no scope selector, lock or text about nodes.
await expect( await expect(page.locator('.scope-select')).toHaveCount(0);
page.locator('.scope-select').getByRole('img', { name: 'Per-node analytics is available in OpenVidu PRO' }) await expect(page.getByText('All nodes')).toHaveCount(0);
).toBeVisible();
await expect(card(page, 'Quality')).toContainText('Quality analytics is available in OpenVidu PRO'); 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, 'Top rooms')).toContainText('Top rooms is available in OpenVidu PRO');
await expect(card(page, 'Quality').getByRole('button', { name: 'Export CSV' })).toBeDisabled(); await expect(card(page, 'Quality').getByRole('button', { name: 'Export CSV' })).toBeDisabled();

View File

@ -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 }) => { test('an info tip shows on keyboard focus and hides on Escape', async ({ page }) => {
await page.goto('/overview?tz=UTC'); await page.goto('/overview?tz=UTC');
// The pointer rests at the top-left corner, where the overlay renders the tooltip before positioning it: the // 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. // mouseleave that follows would close a tooltip still opening, as on a slow runner. The page title has no tooltip.
await page.mouse.move(640, 700); 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' }); const tiles = page.getByRole('region', { name: 'Live' });

View File

@ -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' '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(/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('schema');
await expect(card(page, 'Data pipeline')).not.toContainText('retention:'); 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, 'Data pipeline').getByRole('link', { name: 'Storage and retention' })).toBeVisible();
await expect(card(page, 'Today (UTC, since 00:00)')).toContainText('143'); 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'); 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 page.getByRole('navigation', { name: 'Main' }).getByRole('link', { name: 'Overview' }).click();
await expect(page.locator('.page-header ovd-live-chip')).toHaveText('stale'); 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();
}); });
}); });

View File

@ -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, 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`. 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 - `ANALYTICS_SCOPE_SELECT` (Analytics header) and `ROOMS_NODE_FILTER` (Rooms filter bar): loaders of the node
selects, which send their own scopes requests. selects, which send their own scopes requests; the Rooms one binds a media node label to the `node` URL parameter,
- `OVERVIEW_NODES_CARD`: the "By node" card of the Overview, given `overview`, the last overview response. 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 - **Status styles.** `provideStatusStyles()` registers status domains beyond the CE ones of `ovd-status-badge`, with
the styles of `STATUS_FAMILY_STYLES`. the styles of `STATUS_FAMILY_STYLES`.
- **Chart colours.** `ChartColorsService.colors()` resolves the theme's chart tokens; `resolved(tokens)` resolves an - **Chart colours.** `ChartColorsService.colors()` resolves the theme's chart tokens; `resolved(tokens)` resolves an

View File

@ -60,6 +60,7 @@
"settings-fill", "settings-fill",
"smart_toy", "smart_toy",
"smart_toy-fill", "smart_toy-fill",
"stop_circle",
"sync", "sync",
"video_file", "video_file",
"videocam", "videocam",

View File

@ -72,8 +72,6 @@ describe('ServerStatusService', () => {
{ {
writer: { writer: {
lastSeenAt: new Date(Date.parse('2026-10-13T12:00:00.000Z') - age).toISOString(), lastSeenAt: new Date(Date.parse('2026-10-13T12:00:00.000Z') - age).toISOString(),
nodes: 1,
aliveNodes: 1,
batchIntervalMs: 60_000 batchIntervalMs: 60_000
} }
}, },
@ -88,7 +86,7 @@ describe('ServerStatusService', () => {
expect(status.writerFresh()).toBe(false); expect(status.writerFresh()).toBe(false);
expect(status.writerSilent()).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 }); expect(status.windows()).toMatchObject({ freshMs: 30_000, silentMs: 60_000 });
}); });

View File

@ -14,23 +14,6 @@
@if (scopeSelect(); as select) { @if (scopeSelect(); as select) {
<ng-container *ngComponentOutlet="select" /> <ng-container *ngComponentOutlet="select" />
} }
} @else {
<mat-form-field class="select scope-select locked" subscriptSizing="dynamic">
<mat-label>Scope</mat-label>
<mat-select value="global" disabled>
<mat-option value="global">All nodes</mat-option>
</mat-select>
<span
matSuffix
class="lock"
role="img"
tabindex="0"
[attr.aria-label]="perNodeLockHint"
[matTooltip]="perNodeLockHint"
>
<mat-icon svgIcon="lock" aria-hidden="true" />
</span>
</mat-form-field>
} }
</div> </div>
</div> </div>

View File

@ -25,10 +25,6 @@
width: 170px; width: 170px;
} }
.scope-select {
width: 240px;
}
.sections { .sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -55,13 +51,3 @@
color: var(--mat-sys-on-surface-variant); color: var(--mat-sys-on-surface-variant);
text-align: center; text-align: center;
} }
.lock {
display: inline-flex;
margin-inline-end: 8px;
mat-icon {
width: 20px;
height: 20px;
}
}

View File

@ -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/top');
expect(requests.map((request) => request.request.url)).not.toContain('api/v1/analytics/scopes'); expect(requests.map((request) => request.request.url)).not.toContain('api/v1/analytics/scopes');
expect(seriesRequests(requests).flatMap((request) => request.metrics)).not.toContain('scoreAvg'); expect(seriesRequests(requests).flatMap((request) => request.metrics)).not.toContain('scoreAvg');
expect(element.querySelector('.scope-select mat-select')?.getAttribute('aria-disabled')).toBe('true'); // Nothing per node on CE: no scope selector, lock or text about nodes.
expect(text(element.querySelector('.scope-select .mat-mdc-select-value'))).toBe('All nodes'); expect(element.querySelector('.scope-select')).toBeNull();
expect(element.querySelector('.scope-select .lock')?.getAttribute('aria-label')).toBe( expect(text(element.querySelector('.page-header'))).not.toContain('All nodes');
'Per-node analytics is available in OpenVidu PRO'
);
expect(card(element, 'By node')).toBeUndefined(); expect(card(element, 'By node')).toBeUndefined();
http.verify(); http.verify();
}); });

View File

@ -1,10 +1,8 @@
import { NgComponentOutlet } from '@angular/common'; import { NgComponentOutlet } from '@angular/common';
import { Component, computed, effect, forwardRef, inject, signal, type Type, untracked } from '@angular/core'; import { Component, computed, effect, forwardRef, inject, signal, type Type, untracked } from '@angular/core';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field'; import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatOption, MatSelect } from '@angular/material/select'; import { MatOption, MatSelect } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { import {
ANALYTICS_SCOPE_PATTERN, ANALYTICS_SCOPE_PATTERN,
type AnalyticsBreakdown, type AnalyticsBreakdown,
@ -166,12 +164,9 @@ function count(value: number | undefined): string | null {
KpiTile, KpiTile,
MatButton, MatButton,
MatFormField, MatFormField,
MatIcon,
MatLabel, MatLabel,
MatOption, MatOption,
MatSelect, MatSelect,
MatSuffix,
MatTooltip,
NgComponentOutlet, NgComponentOutlet,
Notice, Notice,
TileRow, TileRow,
@ -204,7 +199,6 @@ export class AnalyticsPage implements AnalyticsPageContext {
private readonly features = this.info.features; private readonly features = this.info.features;
protected readonly perNode = computed(() => this.features().has('per-node-breakdown')); 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); private readonly retention = computed(() => this.info.info()?.retention);
/** The range resolved when the URL changes; the clock does not move it. */ /** The range resolved when the URL changes; the clock does not move it. */

View File

@ -60,7 +60,7 @@ export const ANALYTICS_PRO_CARDS = new InjectionToken<Readonly<Partial<Record<An
{ factory: () => ({}) } { factory: () => ({}) }
); );
/** 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<Type<unknown>>) | null>( export const ANALYTICS_SCOPE_SELECT = new InjectionToken<(() => Promise<Type<unknown>>) | null>(
'ANALYTICS_SCOPE_SELECT', 'ANALYTICS_SCOPE_SELECT',
{ factory: () => null } { factory: () => null }

View File

@ -28,7 +28,10 @@ function elapsed(snapshot: OverviewSnapshot, now: number): number {
return Math.max(0, now - snapshot.receivedAt); 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<Type<unknown> | null>('OVERVIEW_NODES_CARD', { export const OVERVIEW_NODES_CARD = new InjectionToken<Type<unknown> | null>('OVERVIEW_NODES_CARD', {
factory: () => null factory: () => null
}); });

View File

@ -96,21 +96,15 @@
@if (status(); as status) { @if (status(); as status) {
<ul class="status"> <ul class="status">
<li class="line"> <li class="line">
@if (writerFresh()) { @if (status.writer.stoppedAt !== undefined) {
<mat-icon svgIcon="stop_circle" class="neutral" aria-hidden="true" />
} @else if (writerFresh()) {
<mat-icon svgIcon="check_circle" class="ok" aria-hidden="true" /> <mat-icon svgIcon="check_circle" class="ok" aria-hidden="true" />
} @else { } @else {
<mat-icon svgIcon="warning" class="warn" aria-hidden="true" /> <mat-icon svgIcon="warning" class="warn" aria-hidden="true" />
} }
<span>{{ writerLine(status.writer) }}</span> <span>{{ writerLine(status.writer) }}</span>
</li> </li>
<li class="line">
@if (status.writer.aliveNodes < status.writer.nodes || status.writer.nodes === 0) {
<mat-icon svgIcon="warning" class="warn" aria-hidden="true" />
} @else {
<mat-icon svgIcon="check_circle" class="ok" aria-hidden="true" />
}
<span>{{ nodesLine(status.writer) }}</span>
</li>
@if (migrationProgress(); as progress) { @if (migrationProgress(); as progress) {
<li class="progress"> <li class="progress">
<span>Migrating legacy analytics</span> <span>Migrating legacy analytics</span>
@ -129,7 +123,7 @@
} }
</ovd-section-card> </ovd-section-card>
<ovd-section-card heading="Recent rooms" sectionId="recent-rooms"> <ovd-section-card heading="Recent rooms" sectionId="recent-rooms" [class.full]="!nodesCardShown()">
<div ovdSectionActions class="links"> <div ovdSectionActions class="links">
@if ((live()?.rooms ?? 0) > 0) { @if ((live()?.rooms ?? 0) > 0) {
<a routerLink="/rooms" [queryParams]="{ status: 'active' }">View active rooms</a> <a routerLink="/rooms" [queryParams]="{ status: 'active' }">View active rooms</a>
@ -194,13 +188,9 @@
} }
</ovd-section-card> </ovd-section-card>
<ovd-section-card heading="By node" sectionId="nodes" [info]="perNode() ? info.byNode : undefined"> @if (nodesCardShown() && nodesCard; as card) {
<ovd-feature-gate feature="per-node-breakdown"> <ovd-section-card heading="By node" sectionId="nodes" [info]="info.byNode">
<ng-template ovdFeatureContent> <ng-container *ngComponentOutlet="card; inputs: { overview: overview() }" />
@if (nodesCard; as card) { </ovd-section-card>
<ng-container *ngComponentOutlet="card; inputs: { overview: overview() }" /> }
}
</ng-template>
</ovd-feature-gate>
</ovd-section-card>
</div> </div>

View File

@ -62,6 +62,14 @@
.warn { .warn {
color: var(--ovd-status-warning-fg); color: var(--ovd-status-warning-fg);
} }
.neutral {
color: var(--mat-sys-on-surface-variant);
}
}
.full {
grid-column: 1 / -1;
} }
.progress { .progress {

View File

@ -1,6 +1,6 @@
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; 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 { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing'; import { RouterTestingHarness } from '@angular/router/testing';
@ -13,6 +13,7 @@ import { InfoService } from '../../core/info.js';
import { PreferencesService } from '../../core/preferences/preferences.js'; import { PreferencesService } from '../../core/preferences/preferences.js';
import { LiveCountersService } from '../../core/refresh/live-counters.js'; import { LiveCountersService } from '../../core/refresh/live-counters.js';
import { ServerStatusService } from '../../core/refresh/server-status.js'; import { ServerStatusService } from '../../core/refresh/server-status.js';
import { OVERVIEW_NODES_CARD } from './overview-data.js';
import { OverviewPage } from './overview-page.js'; import { OverviewPage } from './overview-page.js';
@Component({ selector: 'ovd-blank', template: '' }) @Component({ selector: 'ovd-blank', template: '' })
@ -92,10 +93,7 @@ function status(overrides: Partial<StatusResponse> = {}, writerAgeMs: number | n
generatedAt: new Date(reference).toISOString(), generatedAt: new Date(reference).toISOString(),
schema: { dashboard: 2, writer: 2, status: 'ok' }, schema: { dashboard: 2, writer: 2, status: 'ok' },
migration: { state: 'completed', progress: { processed: 10, total: 10 } }, migration: { state: 'completed', progress: { processed: 10, total: 10 } },
writer: writer: writerAgeMs === null ? {} : { lastSeenAt: ago(writerAgeMs) },
writerAgeMs === null
? { nodes: 0, aliveNodes: 0 }
: { lastSeenAt: ago(writerAgeMs), nodes: 2, aliveNodes: 2 },
...overrides ...overrides
}; };
} }
@ -107,13 +105,21 @@ async function loadStatus(http: HttpTestingController, body: StatusResponse): Pr
await load; await load;
} }
/** Content an edition gives the By node card. */
@Component({ selector: 'ovd-test-nodes-card', template: 'media nodes' })
class NodesCard {
readonly overview = input<OverviewResponse | null>(null);
}
async function open( async function open(
body: OverviewResponse | 'fail' = overview(), body: OverviewResponse | 'fail' = overview(),
features: string[] = [], features: string[] = [],
server: StatusResponse | null = status() server: StatusResponse | null = status(),
nodesCard: typeof NodesCard | null = null
) { ) {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{ provide: OVERVIEW_NODES_CARD, useValue: nodesCard },
provideRouter([ provideRouter([
{ path: 'overview', component: OverviewPage }, { path: 'overview', component: OverviewPage },
{ path: '**', component: Blank } { 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 () => { it('turns the chip and the writer line to stale from the last server status', async () => {
const { element } = await open(overview(), [], { const { element } = await open(overview(), [], {
...status({}, 3 * MINUTE), ...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'); const chip = element.querySelector('.page-header ovd-live-chip');
@ -244,10 +250,21 @@ describe('OverviewPage', () => {
const pipeline = section(element, 'Data pipeline'); const pipeline = section(element, 'Data pipeline');
const lines = [...(pipeline?.querySelectorAll('li.line') ?? [])]; 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(text(lines[0])).toMatch(/^Last batch at \d\d:\d\d:\d\d/);
expect(lines[0]?.querySelector('mat-icon.warn')).not.toBeNull(); 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 () => { 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); expect(text(section(element, 'Data pipeline')?.querySelector('li.line'))).not.toBe(before);
}); });
it('explains node liveness on the By node heading in PRO', async () => { 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']); const { element } = await open(overview(), ['per-node-breakdown'], status(), NodesCard);
const card = section(element, 'By node');
expect( expect(text(card)).toContain('media nodes');
section(element, 'By node')?.querySelector('header ovd-info-tip button')?.getAttribute('aria-label') expect(card?.querySelector('header ovd-info-tip button')?.getAttribute('aria-label')).toBe('About By node');
).toBe('About By node'); expect(section(element, 'Recent rooms')?.classList.contains('full')).toBe(false);
}); });
it('marks an approximate today and an empty one', async () => { it('marks an approximate today and an empty one', async () => {
@ -387,8 +405,8 @@ describe('OverviewPage', () => {
const status = text(pipeline); const status = text(pipeline);
expect(status).toMatch(/Last batch at \d\d:\d\d:\d\d/); expect(status).toMatch(/Last batch at \d\d:\d\d:\d\d/);
expect(status).toContain('2 of 2 nodes reporting'); expect(status).not.toContain('node');
expect(pipeline?.querySelectorAll('mat-icon.ok')).toHaveLength(2); expect(pipeline?.querySelectorAll('mat-icon.ok')).toHaveLength(1);
expect(pipeline?.querySelector('mat-progress-bar')).toBeNull(); expect(pipeline?.querySelector('mat-progress-bar')).toBeNull();
expect(status).not.toContain('schema'); expect(status).not.toContain('schema');
expect(status).not.toContain('openvidu-livekit'); 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)) [...element.querySelectorAll('.tiles ovd-kpi-tile')].map((tile) => text(tile).split(' ').at(-1))
).toEqual(['0', '0', '0', '0', '0', '0']); ).toEqual(['0', '0', '0', '0', '0', '0']);
expect(text(section(element, 'Data pipeline'))).toContain('Last batch: never'); 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(text(section(element, 'Today'))).toContain('No rooms today yet');
expect(section(element, 'Recent rooms')?.querySelectorAll('.links a')).toHaveLength(0); expect(section(element, 'Recent rooms')?.querySelectorAll('.links a')).toHaveLength(0);
expect(element.querySelector('ovd-error-banner')).toBeNull(); expect(element.querySelector('ovd-error-banner')).toBeNull();
@ -460,10 +478,12 @@ describe('OverviewPage', () => {
expect(text(element.querySelector('ovd-error-banner'))).toContain('req-9'); 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(); 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(); http.verify();
}); });
}); });

View File

@ -14,7 +14,6 @@ import { EmptyState } from '../../components/feedback/empty-state.js';
import { ErrorBanner } from '../../components/feedback/error-banner.js'; import { ErrorBanner } from '../../components/feedback/error-banner.js';
import { Notice } from '../../components/feedback/notice.js'; import { Notice } from '../../components/feedback/notice.js';
import { Skeleton } from '../../components/feedback/skeleton.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 { KpiTile } from '../../components/tiles/kpi-tile.js';
import { SectionCard } from '../../components/tiles/section-card.js'; import { SectionCard } from '../../components/tiles/section-card.js';
import { TileRow } from '../../components/tiles/tile-row.js'; import { TileRow } from '../../components/tiles/tile-row.js';
@ -35,8 +34,8 @@ const APPROXIMATE_HINT =
const STALE_RESPONSES = 2; const STALE_RESPONSES = 2;
/** /**
* The landing page: live tiles, the stale entities hint, today's rollups, the server status, the recent rooms and the * The landing page: live tiles, the stale entities hint, today's rollups, the server status, the recent rooms and, with
* per-node card (PRO, locked in CE), polled with `If-None-Match` at the live interval. * `per-node-breakdown`, the edition's per-node card, polled with `If-None-Match` at the live interval.
*/ */
@Component({ @Component({
selector: 'ovd-overview-page', selector: 'ovd-overview-page',
@ -46,8 +45,6 @@ const STALE_RESPONSES = 2;
Duration, Duration,
EmptyState, EmptyState,
ErrorBanner, ErrorBanner,
FeatureContent,
FeatureGate,
KpiTile, KpiTile,
LiveChip, LiveChip,
MatIcon, MatIcon,
@ -73,8 +70,9 @@ export class OverviewPage {
protected readonly nodesCard = inject(OVERVIEW_NODES_CARD); protected readonly nodesCard = inject(OVERVIEW_NODES_CARD);
protected readonly estimate = RECONCILER_ESTIMATE; protected readonly estimate = RECONCILER_ESTIMATE;
protected readonly info = INFO_TEXTS; protected readonly info = INFO_TEXTS;
/** The By node heading explains node liveness only where the card shows nodes. */ private readonly perNode = inject(FeatureService).enabledSignal('per-node-breakdown');
protected 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; protected readonly approximateText = APPROXIMATE_HINT;
/** The column order of the Rooms list. */ /** The column order of the Rooms list. */
protected readonly recentColumns = computed<TableColumn[]>(() => [ protected readonly recentColumns = computed<TableColumn[]>(() => [
@ -208,17 +206,15 @@ export class OverviewPage {
} }
protected writerLine(writer: StatusWriter): string { 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 return writer.lastSeenAt === undefined
? 'Last batch: never' ? 'Last batch: never'
: `Last batch at ${formatTimestamp(writer.lastSeenAt, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS })}`; : `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. */ /** Time only for today's rooms, the date as well for older ones. */
protected time(value: string): string { protected time(value: string): string {
return formatTimestamp(value, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS }); return formatTimestamp(value, { tz: this.tz(), now: this.now(), rangeMs: DAY_MS });

View File

@ -67,7 +67,7 @@
} }
</ng-template> </ng-template>
<ng-template ovdCell="node" let-item> <ng-template ovdCell="node" let-item>
<span class="ovd-mono">{{ item.nodeId ?? '—' }}</span> <span class="ovd-mono">{{ item.mediaNode ?? '—' }}</span>
</ng-template> </ng-template>
@if (exactId(); as sid) { @if (exactId(); as sid) {

View File

@ -4,6 +4,7 @@ import { type ParticipantListItem, ParticipantSort, ParticipantStatus } from '@o
import type { SortChange, TableColumn } from '../../components/data-table/data-table.js'; 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 { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js';
import { exactListId, listSearchQuery } from '../../components/search/list-search.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 { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js'; import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-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 listState = inject(ListStateService);
const previews = inject(EntityPreviewService); const previews = inject(EntityPreviewService);
const rangePicker = inject(RangePickerService); const rangePicker = inject(RangePickerService);
const perNode = inject(FeatureService).enabledSignal('per-node-breakdown');
const q = computed(() => listSearchQuery(query.q(), 'participant')); const q = computed(() => listSearchQuery(query.q(), 'participant'));
const room = computed(() => { const room = computed(() => {
@ -86,7 +88,8 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
...ranked.column('duration') ...ranked.column('duration')
}, },
{ key: 'published', header: 'Published', card: 'metric' }, { 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. */ /** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */
time: (value: string): string => time: (value: string): string =>

View File

@ -11,6 +11,7 @@ import { infoResponse, settle } from '../../../testing/fixtures.js';
import { routeElement } from '../../../testing/harness.js'; import { routeElement } from '../../../testing/harness.js';
import { LIST_SEARCH_DEBOUNCE_MS } from '../../components/search/list-search.js'; import { LIST_SEARCH_DEBOUNCE_MS } from '../../components/search/list-search.js';
import { InfoService } from '../../core/info.js'; import { InfoService } from '../../core/info.js';
import { PreferencesService } from '../../core/preferences/preferences.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ParticipantLockedPage } from './participant-locked-page.js'; import { ParticipantLockedPage } from './participant-locked-page.js';
import { ParticipantsPage } from './participants-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)); const debounce = () => new Promise((resolve) => setTimeout(resolve, LIST_SEARCH_DEBOUNCE_MS + 50));
describe('ParticipantsPage', () => { 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 () => { it('reproduces the URL state in the controls and the request', async () => {
const { element, request } = await open( const { element, request } = await open(
'/participants?from=now-7d&to=now&tz=UTC&q=ali&kind=STANDARD,EGRESS&status=left&limit=50' '/participants?from=now-7d&to=now&tz=UTC&q=ali&kind=STANDARD,EGRESS&status=left&limit=50'

View File

@ -32,7 +32,7 @@ export class RoomLockedPage {
status: RoomStatusFilter.ALL, status: RoomStatusFilter.ALL,
q: sid, q: sid,
features: [], features: [],
nodeId: null, mediaNode: null,
sort: RoomSort.CREATED_AT, sort: RoomSort.CREATED_AT,
order: 'desc', order: 'desc',
cursor: null, cursor: null,

View File

@ -29,7 +29,7 @@ describe('RoomsDataService', () => {
status: 'closed', status: 'closed',
q: 'demo', q: 'demo',
features: ['egress', 'agent'], features: ['egress', 'agent'],
nodeId: 'node-a', mediaNode: 'mn-a',
sort: 'endedAt', sort: 'endedAt',
order: 'asc', order: 'asc',
cursor: 'abc', cursor: 'abc',
@ -52,11 +52,11 @@ describe('RoomsDataService', () => {
(req) => (req) =>
req.urlWithParams === req.urlWithParams ===
'api/v1/rooms?from=2026-10-12T12:00:00.000Z&to=2026-10-13T12:00:00.000Z&tz=Europe/Madrid&status=closed' + '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 }); ).flush({ items: [], nextCursor: null, prevCursor: null });
await full; 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( http.expectOne(
(req) => (req) =>

View File

@ -13,7 +13,10 @@ import { HttpService, type QueryValue } from '../../core/http/http.js';
import type { RangeParams } from '../../core/state/query-state.js'; import type { RangeParams } from '../../core/state/query-state.js';
import { resolveRange } from '../../core/state/relative-time.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<Type<unknown>>) | null>('ROOMS_NODE_FILTER', { export const ROOMS_NODE_FILTER = new InjectionToken<(() => Promise<Type<unknown>>) | null>('ROOMS_NODE_FILTER', {
factory: () => null factory: () => null
}); });
@ -23,7 +26,7 @@ export interface RoomsListRequest {
status: RoomStatusFilter; status: RoomStatusFilter;
q: string | null; q: string | null;
features: readonly RoomFeature[]; features: readonly RoomFeature[];
nodeId: string | null; mediaNode: string | null;
sort: RoomSort; sort: RoomSort;
order: SortOrder; order: SortOrder;
cursor: string | null; cursor: string | null;
@ -50,7 +53,7 @@ export class RoomsDataService {
status: request.status, status: request.status,
q: request.q, q: request.q,
features: request.features, features: request.features,
nodeId: request.nodeId, mediaNode: request.mediaNode,
sort: request.sort, sort: request.sort,
order: request.order, order: request.order,
cursor: request.cursor, cursor: request.cursor,

View File

@ -42,23 +42,6 @@
@if (nodeSelect(); as select) { @if (nodeSelect(); as select) {
<ng-container *ngComponentOutlet="select" /> <ng-container *ngComponentOutlet="select" />
} }
} @else {
<mat-form-field class="node" subscriptSizing="dynamic">
<mat-label>Node</mat-label>
<mat-select value="all" disabled>
<mat-option value="all">All nodes</mat-option>
</mat-select>
<span
matSuffix
class="lock"
role="img"
tabindex="0"
[attr.aria-label]="nodeLockHint"
[matTooltip]="nodeLockHint"
>
<mat-icon svgIcon="lock" aria-hidden="true" />
</span>
</mat-form-field>
} }
<div class="ovd-filter-slot"> <div class="ovd-filter-slot">
@ -168,7 +151,7 @@
</span> </span>
</ng-template> </ng-template>
<ng-template ovdCell="node" let-room> <ng-template ovdCell="node" let-room>
<span class="ovd-mono">{{ room.nodeId ?? '—' }}</span> <span class="ovd-mono">{{ room.mediaNode ?? '—' }}</span>
</ng-template> </ng-template>
@if (exactId(); as sid) { @if (exactId(); as sid) {

View File

@ -2,20 +2,6 @@
display: block; display: block;
} }
.node {
width: 200px;
}
.lock {
display: inline-flex;
margin-inline-end: 8px;
mat-icon {
width: 20px;
height: 20px;
}
}
.cell, .cell,
.features { .features {
display: inline-flex; display: inline-flex;

View File

@ -38,7 +38,7 @@ function room(overrides: Partial<RoomListItem> = {}): RoomListItem {
name: 'demo-room', name: 'demo-room',
status: 'active', status: 'active',
createdAt: new Date(Date.now() - 42 * 60_000).toISOString(), createdAt: new Date(Date.now() - 42 * 60_000).toISOString(),
nodeId: 'node-a', nodeId: 'ND_a',
participants: { sessions: 7, unique: 6, active: 5, peakActive: 6 }, participants: { sessions: 7, unique: 6, active: 5, peakActive: 6 },
egress: { total: 1, active: 1 }, egress: { total: 1, active: 1 },
ingress: { sessions: 0, active: 0 }, 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'); 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 () => { it('shows no Node select, lock or column in CE, ignores node and asks for no scopes', async () => {
const { element, request, http } = await open('/rooms?nodeId=a'); const { element, request, http, harness } = await open('/rooms?node=a');
const node = element.querySelector('.node'); const columns = (harness.routeDebugElement?.componentInstance as { columns(): { key: string }[] }).columns();
expect(request.params.has('nodeId')).toBe(false); expect(request.params.has('mediaNode')).toBe(false);
expect(node?.querySelector('mat-select')?.getAttribute('aria-disabled')).toBe('true'); expect(request.params.has('node')).toBe(false);
expect(text(node?.querySelector('.mat-mdc-select-value'))).toBe('All nodes'); expect(text(element)).not.toContain('All nodes');
expect(node?.querySelector('.lock')?.getAttribute('aria-label')).toBe( expect(text(element)).not.toContain('Filtering by node');
'Filtering by node is available in OpenVidu PRO' expect(columns.map((column) => column.key)).not.toContain('node');
);
http.expectNone((req) => req.url === 'api/v1/analytics/scopes'); 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) }]); const http = setup([{ provide: ROOMS_NODE_FILTER, useValue: () => Promise.resolve(NodeFilterStub) }]);
await withInfo(http, ['per-node-breakdown']); await withInfo(http, ['per-node-breakdown']);
TestBed.inject(PreferencesService).setHiddenColumns('rooms', []);
const harness = await RouterTestingHarness.create('/rooms?nodeId=a&q=demo'); const harness = await RouterTestingHarness.create('/rooms?node=mn-a&q=demo');
const request = await load(http, harness, page([room()])); const request = await load(http, harness, page([room({ mediaNode: 'mn-a' }), room({ sid: 'RM_plain' })]));
await harness.fixture.whenStable(); 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(request.params.get('q')).toBe('demo');
expect(routeElement(harness).querySelector('ovd-node-filter-stub')).not.toBeNull(); 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 () => { it('marks a reconciler-closed room as estimated and an active room the API marks stale', async () => {

View File

@ -3,9 +3,6 @@ import { Component, computed, effect, inject, signal, type Type, untracked } fro
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle'; import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle';
import { MatChipListbox, MatChipOption } from '@angular/material/chips'; 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 { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
@ -72,12 +69,6 @@ interface RoomsData {
MatButtonToggleGroup, MatButtonToggleGroup,
MatChipListbox, MatChipListbox,
MatChipOption, MatChipOption,
MatFormField,
MatIcon,
MatLabel,
MatOption,
MatSelect,
MatSuffix,
MatTooltip, MatTooltip,
NgComponentOutlet, NgComponentOutlet,
RouterLink, RouterLink,
@ -102,17 +93,17 @@ export class RoomsPage {
protected readonly now = injectTicker(); protected readonly now = injectTicker();
protected readonly tz = this.query.tz; protected readonly tz = this.query.tz;
protected readonly limit = this.query.limit; 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'); protected readonly nodeFilter = inject(FeatureService).enabledSignal('per-node-breakdown');
private readonly loadNodeFilter = inject(ROOMS_NODE_FILTER); private readonly loadNodeFilter = inject(ROOMS_NODE_FILTER);
protected readonly nodeSelect = signal<Type<unknown> | null>(null); protected readonly nodeSelect = signal<Type<unknown> | 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 status = computed(() => this.query.oneOf('status', STATUSES, RoomStatusFilter.ALL));
protected readonly q = computed(() => listSearchQuery(this.query.q(), 'room')); protected readonly q = computed(() => listSearchQuery(this.query.q(), 'room'));
protected readonly exactId = computed(() => exactListId(this.q(), 'room')); protected readonly exactId = computed(() => exactListId(this.q(), 'room'));
protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS)); 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())); 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. */ /** Range span when the range last changed, so the ticker never re-renders the headers. */
private readonly requestRangeMs = computed(() => { private readonly requestRangeMs = computed(() => {
@ -134,7 +125,7 @@ export class RoomsPage {
status: this.status(), status: this.status(),
q: this.q(), q: this.q(),
features: this.selectedFeatures(), features: this.selectedFeatures(),
nodeId: this.nodeId(), mediaNode: this.node(),
sort: this.sort(), sort: this.sort(),
order: this.order(), order: this.order(),
cursor: this.query.cursor(), cursor: this.query.cursor(),
@ -206,7 +197,7 @@ export class RoomsPage {
}, },
{ key: 'bytes', header: 'Data up / down', align: 'end' }, { key: 'bytes', header: 'Data up / down', align: 'end' },
{ key: 'features', header: 'Features', info: INFO_TEXTS.roomFeatures }, { 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; protected readonly trackRoom = (room: RoomListItem): string => room.sid;
@ -264,7 +255,7 @@ export class RoomsPage {
} }
protected clearFilters(): void { 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 { protected clearSearch(): void {

View File

@ -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.', '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.', todayParticipants: 'Participant sessions that started today. Only regular participants count.',
todayMinutes: 'Minutes of the participant sessions that ended today.', todayMinutes: 'Minutes of the participant sessions that ended today.',
dataPipeline: dataPipeline: 'The OpenVidu server writes analytics in batches. This card shows when the last batch arrived.',
'The OpenVidu server writes analytics in batches. This card shows when the last batch arrived and how many nodes are reporting.',
byNode: 'Nodes that reported analytics recently. A node is alive while its analytics batches keep arriving.', byNode: 'Nodes that reported analytics recently. A node is alive while its analytics batches keep arriving.',
dailyUniqueIdentities: dailyUniqueIdentities:

View File

@ -29,11 +29,9 @@ export function statusResponse(
migration: { state: 'completed', progress: { processed: 10, total: 10 } }, migration: { state: 'completed', progress: { processed: 10, total: 10 } },
writer: writer:
writerAgeMs === null writerAgeMs === null
? { nodes: 0, aliveNodes: 0 } ? {}
: { : {
lastSeenAt: new Date(Date.parse(generatedAt) - writerAgeMs).toISOString(), lastSeenAt: new Date(Date.parse(generatedAt) - writerAgeMs).toISOString()
nodes: 1,
aliveNodes: 1
}, },
...overrides ...overrides
}; };

View File

@ -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 | | `--drop` | off | Drop the database first |
| `--plain-stats` | off | Force the plain-collection fallback for `track_stats` | | `--plain-stats` | off | Force the plain-collection fallback for `track_stats` |
| `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window | | `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window |
| `--node-rollups` | off | Write the `node:<nodeId>` scope beside `global` in every rollup | | `--node-rollups` | off | With `--media-nodes`: write the `node:<label>` scope beside `global` in every rollup |
| `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end | | `--room-sums` | off | Write `rooms.participantsSum` and `rooms.durationMsSum` at room end |
| `--active-window <minutes>` | off | Rooms created in the last minutes stay active (two of three); without it, the most recent 10 % of the rooms are active | | `--active-window <minutes>` | off | Rooms created in the last minutes stay active (two of three); without it, the most recent 10 % of the rooms are active |
| `--min-active-rooms <n>` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms | | `--min-active-rooms <n>` | 0 | With `--active-window`: rooms added to the window when the hour of day leaves fewer than `n` active rooms |
| `--nodes-without-media-stats <n>` | 0 | The last `n` of the three nodes report no media statistics: `mediaStats: false`, no media `track_stats`, bytes or quality | | `--nodes-without-media-stats <n>` | 0 | The last `n` of the three nodes report no media statistics: `mediaStats: false`, no media `track_stats`, bytes or quality |
| `--media-nodes` | off | Label the nodes `media-node-10-0-0-1` to `-3` and add an earlier stopped process of the first one (a restart) |
| Scenario | Rooms per day | Sessions per day | Average room size | | Scenario | Rooms per day | Sessions per day | Average room size |
| -------- | ------------- | ---------------- | ----------------- | | -------- | ------------- | ---------------- | ----------------- |
@ -77,13 +78,14 @@ What it writes (`analytics_5min` stays empty without `--five-minute-rollups`):
direction (`track` mode), only inside the stats retention window, with plausible score/bytes/rtt/jitter. direction (`track` mode), only inside the stats retention window, with plausible score/bytes/rtt/jitter.
Time-series collection (`timeField: ts`, `metaField: meta`, `granularity: seconds`) with Time-series collection (`timeField: ts`, `metaField: meta`, `granularity: seconds`) with
`expireAfterSeconds`; plain collection with a TTL index on `ts` when the server rejects time-series. `expireAfterSeconds`; plain collection with a TTL index on `ts` when the server rejects time-series.
- `analytics_hourly` and `analytics_daily` (global scope, plus one scope per node with `--node-rollups`) - `analytics_hourly` and `analytics_daily` (global scope, plus one scope per media node label with
aggregated from the generated entities; bytes and quality are spread over the hours each session was connected. `--media-nodes --node-rollups`) aggregated from the generated entities; bytes and quality are spread over the
`--room-sums` adds the room size and duration sums; `--five-minute-rollups` adds `analytics_5min` (lifecycle hours each session was connected. `--room-sums` adds the room size and duration sums; `--five-minute-rollups`
counters, the `participants.connected` gauge, bytes and quality per five minutes) inside the 5-minute rollup adds `analytics_5min` (lifecycle counters, the `participants.connected` gauge, bytes and quality per five minutes)
window. `analytics_daily` gets no TTL index: daily rollups are kept forever. inside the 5-minute rollup window. `analytics_daily` gets no TTL index: daily rollups are kept forever.
`generate()` takes the same switches as `rollups: { fiveMinute, nodes, roomSums }`. `generate()` takes the same switches as `rollups: { fiveMinute, nodes, roomSums }` and `mediaNodes`.
- `nodes` (3 nodes) and `schema_meta` (`schema`, `retention`, `capabilities`, `config`). - `nodes` (3 nodes, plus the stopped earlier process of the first one with `--media-nodes`) and `schema_meta`
(`schema`, `retention`, `capabilities`, `config`).
Documents are inserted in batches of 5 000 with `insertMany(ordered: false)`. The output shows progress Documents are inserted in batches of 5 000 with `insertMany(ordered: false)`. The output shows progress
and a final summary with documents per collection, elapsed time and inserts per second. The generator and a final summary with documents per collection, elapsed time and inserts per second. The generator

View File

@ -719,7 +719,7 @@ export interface FiveMinuteRollupDoc extends Omit<FiveMinuteBucket, 'bytes'> {
export interface RollupOptions { export interface RollupOptions {
/** `analytics_5min` documents inside the 5-minute rollup window. */ /** `analytics_5min` documents inside the 5-minute rollup window. */
fiveMinute: boolean; fiveMinute: boolean;
/** `node:<nodeId>` documents beside `global` in every rollup collection. */ /** `node:<media node label>` documents beside `global` in every rollup collection (needs labelled nodes). */
nodes: boolean; nodes: boolean;
/** `rooms.participantsSum` and `rooms.durationMsSum` at room end. */ /** `rooms.participantsSum` and `rooms.durationMsSum` at room end. */
roomSums: boolean; roomSums: boolean;
@ -745,7 +745,8 @@ export class Rollups {
add(bundle: RoomBundle, windows: DeriveWindows): void { add(bundle: RoomBundle, windows: DeriveWindows): void {
const { room } = bundle; const { room } = bundle;
this.scopes = this.options.nodes ? ['global', `node:${room.nodeId}`] : ['global']; this.scopes =
this.options.nodes && room.mediaNode !== undefined ? ['global', `node:${room.mediaNode}`] : ['global'];
this.fiveMinuteFrom = windows.fiveMinuteFrom; this.fiveMinuteFrom = windows.fiveMinuteFrom;
this.at( this.at(
room.createdAt, room.createdAt,

View File

@ -44,7 +44,8 @@ async function main(): Promise<void> {
'room-sums': { type: 'boolean' }, 'room-sums': { type: 'boolean' },
'active-window': { type: 'string' }, 'active-window': { type: 'string' },
'min-active-rooms': { type: 'string' }, 'min-active-rooms': { type: 'string' },
'nodes-without-media-stats': { type: 'string' } 'nodes-without-media-stats': { type: 'string' },
'media-nodes': { type: 'boolean' }
}); });
const uri = requireString(values, 'uri'); const uri = requireString(values, 'uri');
const dbName = requireString(values, 'db'); const dbName = requireString(values, 'db');
@ -84,6 +85,7 @@ async function main(): Promise<void> {
? {} ? {}
: { minActiveRooms: intOption(values, 'min-active-rooms', 0) }), : { minActiveRooms: intOption(values, 'min-active-rooms', 0) }),
nodesWithoutMediaStats: intOption(values, 'nodes-without-media-stats', 0), nodesWithoutMediaStats: intOption(values, 'nodes-without-media-stats', 0),
mediaNodes: flag(values, 'media-nodes'),
log: (line) => { log: (line) => {
console.log(line); console.log(line);
} }

View File

@ -160,6 +160,8 @@ export interface GenerateOptions {
minActiveRooms?: number; minActiveRooms?: number;
/** Generated nodes, the last ones, that report no media statistics; none by default. */ /** Generated nodes, the last ones, that report no media statistics; none by default. */
nodesWithoutMediaStats?: number; nodesWithoutMediaStats?: number;
/** Labels the nodes `media-node-10-0-0-<n>` and adds an earlier, stopped process of the first one. */
mediaNodes?: boolean;
log?: (line: string) => void; log?: (line: string) => void;
} }
@ -187,6 +189,10 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
const nodesWithoutMediaStats = options.nodesWithoutMediaStats ?? 0; const nodesWithoutMediaStats = options.nodesWithoutMediaStats ?? 0;
if (options.rollups?.nodes === true && options.mediaNodes !== true) {
throw new Error('node rollups require mediaNodes: node scopes are keyed by the media node label');
}
if (nodesWithoutMediaStats > NODE_COUNT) { if (nodesWithoutMediaStats > NODE_COUNT) {
throw new Error(`nodesWithoutMediaStats must be at most ${String(NODE_COUNT)}`); throw new Error(`nodesWithoutMediaStats must be at most ${String(NODE_COUNT)}`);
} }
@ -208,6 +214,28 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
const windowStart = now - days * DAY; const windowStart = now - days * DAY;
const rng = new Rng(options.seed); const rng = new Rng(options.seed);
const nodeIds = Array.from({ length: NODE_COUNT }, () => rng.id('ND_', 8)); const nodeIds = Array.from({ length: NODE_COUNT }, () => rng.id('ND_', 8));
const labelled = options.mediaNodes === true;
// A separate generator keeps the rooms of a seed the same with and without labels.
const restart = labelled
? {
nodeId: nodeIds[0] ?? '',
previousNodeId: new Rng(options.seed + 1_000_003).id('ND_', 8),
at: Math.floor((windowStart + (now - windowStart) / 2) / MINUTE) * MINUTE
}
: undefined;
const mediaNodes = new Map<string, string>(
labelled ? nodeIds.map((id, i) => [id, `media-node-10-0-0-${String(i + 1)}`] as const) : []
);
const withoutMediaStats = new Set(nodeIds.slice(NODE_COUNT - nodesWithoutMediaStats));
if (restart !== undefined) {
mediaNodes.set(restart.previousNodeId, mediaNodes.get(restart.nodeId) ?? '');
if (withoutMediaStats.has(restart.nodeId)) {
withoutMediaStats.add(restart.previousNodeId);
}
}
const plans = planRooms( const plans = planRooms(
rng, rng,
scenario, scenario,
@ -224,7 +252,9 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
nodeIds, nodeIds,
retentionEntitiesMs: retentionDays.entities * DAY, retentionEntitiesMs: retentionDays.entities * DAY,
retentionTracksMs: retentionDays.tracks * DAY, retentionTracksMs: retentionDays.tracks * DAY,
withoutMediaStats: new Set(nodeIds.slice(NODE_COUNT - nodesWithoutMediaStats)) withoutMediaStats,
mediaNodes,
...(restart === undefined ? {} : { restart })
}; };
const windows: DeriveWindows = { const windows: DeriveWindows = {
now, now,
@ -304,15 +334,34 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
writer.add(COLLECTIONS.analytics5min, doc); writer.add(COLLECTIONS.analytics5min, doc);
} }
const regions = ['eu-west-1', 'eu-west-1', 'us-east-1'];
for (const [i, nodeId] of nodeIds.entries()) { for (const [i, nodeId] of nodeIds.entries()) {
const mediaNode = mediaNodes.get(nodeId);
writer.add(COLLECTIONS.nodes, { writer.add(COLLECTIONS.nodes, {
_id: nodeId, _id: nodeId,
firstSeenAt: new Date(windowStart - DAY), firstSeenAt: new Date(restart?.nodeId === nodeId ? restart.at : windowStart - DAY),
lastSeenAt: new Date(now), lastSeenAt: new Date(now),
serverVersion: '3.10.0', serverVersion: '3.10.0',
region: ['eu-west-1', 'eu-west-1', 'us-east-1'][i], region: regions[i],
batchIntervalMs: BATCH_INTERVAL_MS, batchIntervalMs: BATCH_INTERVAL_MS,
mediaStats: !ctx.withoutMediaStats.has(nodeId) mediaStats: !ctx.withoutMediaStats.has(nodeId),
...(mediaNode === undefined ? {} : { mediaNode })
});
}
if (restart !== undefined) {
const stoppedAt = new Date(restart.at - MINUTE);
writer.add(COLLECTIONS.nodes, {
_id: restart.previousNodeId,
firstSeenAt: new Date(windowStart - DAY),
lastSeenAt: stoppedAt,
stoppedAt,
serverVersion: '3.10.0',
region: regions[0],
batchIntervalMs: BATCH_INTERVAL_MS,
mediaStats: !ctx.withoutMediaStats.has(restart.previousNodeId),
mediaNode: mediaNodes.get(restart.previousNodeId)
}); });
} }

View File

@ -2,7 +2,7 @@ import { setTimeout as sleep } from 'node:timers/promises';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import { intOption, parseCli, requireString } from './args.js'; import { intOption, parseCli, requireString } from './args.js';
import { BATCH_INTERVAL_MS, COLLECTIONS } from './schema.js'; import { keepLive } from './schema.js';
/** /**
* Keeps a generated database live while a local demo runs: every interval, the node heartbeats and the stats time of * Keeps a generated database live while a local demo runs: every interval, the node heartbeats and the stats time of
@ -25,15 +25,7 @@ async function main(): Promise<void> {
try { try {
while (!stop.signal.aborted) { while (!stop.signal.aborted) {
const now = new Date(); await keepLive(db, new Date());
await db
.collection(COLLECTIONS.nodes)
.updateMany({}, { $set: { lastSeenAt: now, batchIntervalMs: BATCH_INTERVAL_MS } });
await db.collection(COLLECTIONS.rooms).updateMany({ status: 'active' }, { $set: { lastStatsAt: now } });
await db
.collection(COLLECTIONS.participants)
.updateMany({ status: 'active' }, { $set: { lastStatsAt: now } });
await sleep(intervalMs, undefined, { signal: stop.signal }).catch(() => undefined); await sleep(intervalMs, undefined, { signal: stop.signal }).catch(() => undefined);
} }
} finally { } finally {

View File

@ -33,6 +33,7 @@ export interface RoomDoc {
endReason: string | null; endReason: string | null;
closedBy: 'event' | 'reconciler' | null; closedBy: 'event' | 'reconciler' | null;
nodeId: string; nodeId: string;
mediaNode?: string;
config: { maxParticipants: number; emptyTimeoutMs: number; departureTimeoutMs: number }; config: { maxParticipants: number; emptyTimeoutMs: number; departureTimeoutMs: number };
participantSids: string[]; participantSids: string[];
activeParticipantSids: string[]; activeParticipantSids: string[];
@ -93,6 +94,7 @@ export interface ParticipantDoc {
connectionType: string | null; connectionType: string | null;
region: string; region: string;
nodeId: string; nodeId: string;
mediaNode?: string;
resumes: number; resumes: number;
client: ClientInfo; client: ClientInfo;
permission: { canPublish: boolean; canSubscribe: boolean; canPublishData: boolean; hidden: boolean }; permission: { canPublish: boolean; canSubscribe: boolean; canPublishData: boolean; hidden: boolean };
@ -290,6 +292,10 @@ export interface GeneratorContext {
retentionTracksMs: number; retentionTracksMs: number;
/** Nodes that report no media statistics: their sessions carry no bytes, quality or `track_stats`. */ /** Nodes that report no media statistics: their sessions carry no bytes, quality or `track_stats`. */
withoutMediaStats: ReadonlySet<string>; withoutMediaStats: ReadonlySet<string>;
/** Media node label of each node id (the previous process of a restarted node included); empty without labels. */
mediaNodes: ReadonlyMap<string, string>;
/** The restart of the first node: its rooms created before `at` were hosted by `previousNodeId`. */
restart?: { nodeId: string; previousNodeId: string; at: number };
} }
export interface RoomPlan { export interface RoomPlan {
@ -408,7 +414,10 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
const { rng, now } = ctx; const { rng, now } = ctx;
const roomSid = rng.id('RM_'); const roomSid = rng.id('RM_');
const name = `room-${String(plan.index + 1).padStart(6, '0')}`; const name = `room-${String(plan.index + 1).padStart(6, '0')}`;
const nodeId = rng.pick(ctx.nodeIds); const picked = rng.pick(ctx.nodeIds);
const nodeId =
picked === ctx.restart?.nodeId && plan.createdAt < ctx.restart.at ? ctx.restart.previousNodeId : picked;
const mediaNode = ctx.mediaNodes.get(nodeId);
const hasEgress = rng.chance(0.1); const hasEgress = rng.chance(0.1);
const hasIngress = rng.chance(0.02); const hasIngress = rng.chance(0.02);
const hasAgent = rng.chance(0.05); const hasAgent = rng.chance(0.05);
@ -420,7 +429,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
? 'reconciler' ? 'reconciler'
: 'event'; : 'event';
const base = { roomSid, roomName: name, nodeId, createdAt: plan.createdAt }; const base: RoomBase = { roomSid, roomName: name, nodeId, mediaNode, createdAt: plan.createdAt };
const sessions: Session[] = []; const sessions: Session[] = [];
const identityCount = Math.min(60, Math.max(1, rng.poisson(ctx.scenario.avgRoomSize))); const identityCount = Math.min(60, Math.max(1, rng.poisson(ctx.scenario.avgRoomSize)));
const reconnects = rng.poisson( const reconnects = rng.poisson(
@ -599,6 +608,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
endReason, endReason,
closedBy, closedBy,
nodeId, nodeId,
...(mediaNode === undefined ? {} : { mediaNode }),
config: { maxParticipants: 0, emptyTimeoutMs: 300_000, departureTimeoutMs: DEPARTURE_TIMEOUT_MS }, config: { maxParticipants: 0, emptyTimeoutMs: 300_000, departureTimeoutMs: DEPARTURE_TIMEOUT_MS },
participantSids: participants.map((p) => p._id), participantSids: participants.map((p) => p._id),
activeParticipantSids: activeStandard.map((s) => s.doc._id), activeParticipantSids: activeStandard.map((s) => s.doc._id),
@ -680,6 +690,7 @@ interface RoomBase {
roomSid: string; roomSid: string;
roomName: string; roomName: string;
nodeId: string; nodeId: string;
mediaNode: string | undefined;
createdAt: number; createdAt: number;
} }
@ -815,6 +826,7 @@ function newSession(
connectionType: aborted ? null : rng.weighted(CONNECTION_TYPES), connectionType: aborted ? null : rng.weighted(CONNECTION_TYPES),
region: '', region: '',
nodeId: base.nodeId, nodeId: base.nodeId,
...(base.mediaNode === undefined ? {} : { mediaNode: base.mediaNode }),
resumes: aborted ? 0 : rng.poisson(0.2), resumes: aborted ? 0 : rng.poisson(0.2),
client, client,
permission: { permission: {

View File

@ -163,3 +163,18 @@ function toIndexDescription(index: IndexDefinition, retention: RetentionSeconds)
function describe(error: unknown): string { function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
/**
* One keep-alive tick at `now`: the heartbeats of the running node processes (not the stopped ones) and the stats time
* of the active rooms and sessions move to `now`, as a running server would keep them.
*/
export async function keepLive(db: Db, now: Date): Promise<void> {
await db
.collection(COLLECTIONS.nodes)
.updateMany(
{ stoppedAt: { $exists: false } },
{ $set: { lastSeenAt: now, batchIntervalMs: BATCH_INTERVAL_MS } }
);
await db.collection(COLLECTIONS.rooms).updateMany({ status: 'active' }, { $set: { lastStatsAt: now } });
await db.collection(COLLECTIONS.participants).updateMany({ status: 'active' }, { $set: { lastStatsAt: now } });
}

View File

@ -53,7 +53,7 @@ prepare_data() {
if [ "${DASHBOARD_DEMO_KEEP_DATA:-false}" != true ]; then if [ "${DASHBOARD_DEMO_KEEP_DATA:-false}" != true ]; then
echo "Generating $DATABASE (scenario S, 30 days, $STATS_MODE stats)" echo "Generating $DATABASE (scenario S, 30 days, $STATS_MODE stats)"
pnpm --silent --dir "$TOOLS_DIR/data-generator" run generate --uri "$MONGO_URI" --db "$DATABASE" \ pnpm --silent --dir "$TOOLS_DIR/data-generator" run generate --uri "$MONGO_URI" --db "$DATABASE" \
--scenario S --days 30 --stats-mode "$STATS_MODE" --drop --five-minute-rollups --node-rollups --room-sums \ --scenario S --days 30 --stats-mode "$STATS_MODE" --drop --five-minute-rollups --room-sums \
--active-window 60 --min-active-rooms 10 ${GENERATOR_ARGS[@]+"${GENERATOR_ARGS[@]}"} | tail -2 --active-window 60 --min-active-rooms 10 ${GENERATOR_ARGS[@]+"${GENERATOR_ARGS[@]}"} | tail -2
fi fi

View File

@ -6,6 +6,8 @@ import {
CLOSED_ROOM, CLOSED_ROOM,
EG, EG,
ENTITIES_RETENTION_MS, ENTITIES_RETENTION_MS,
MEDIA_NODE_ALPHA,
MEDIA_NODE_BETA,
NODE_ALPHA, NODE_ALPHA,
NODE_BETA, NODE_BETA,
P, P,
@ -15,10 +17,22 @@ import {
T T
} from './scenario.js'; } from './scenario.js';
type Base = Pick<ParticipantDocument, 'roomSid' | 'roomName' | 'nodeId' | 'schemaVersion'>; type Base = Pick<ParticipantDocument, 'roomSid' | 'roomName' | 'nodeId' | 'mediaNode' | 'schemaVersion'>;
const closedRoom: Base = { roomSid: CLOSED_ROOM.sid, roomName: CLOSED_ROOM.name, nodeId: NODE_ALPHA, schemaVersion }; const closedRoom: Base = {
const activeRoom: Base = { roomSid: ACTIVE_ROOM.sid, roomName: ACTIVE_ROOM.name, nodeId: NODE_BETA, schemaVersion }; roomSid: CLOSED_ROOM.sid,
roomName: CLOSED_ROOM.name,
nodeId: NODE_ALPHA,
mediaNode: MEDIA_NODE_ALPHA,
schemaVersion
};
const activeRoom: Base = {
roomSid: ACTIVE_ROOM.sid,
roomName: ACTIVE_ROOM.name,
nodeId: NODE_BETA,
mediaNode: MEDIA_NODE_BETA,
schemaVersion
};
const standardPermission = { canSubscribe: true, canPublish: true, canPublishData: true }; const standardPermission = { canSubscribe: true, canPublish: true, canPublishData: true };
const recorderPermission = { canSubscribe: true, canPublish: false, canPublishData: false, hidden: true }; const recorderPermission = { canSubscribe: true, canPublish: false, canPublishData: false, hidden: true };

View File

@ -7,6 +7,8 @@ import {
EG, EG,
ENTITIES_RETENTION_MS, ENTITIES_RETENTION_MS,
INGRESS_SESSION, INGRESS_SESSION,
MEDIA_NODE_ALPHA,
MEDIA_NODE_BETA,
NODE_ALPHA, NODE_ALPHA,
NODE_BETA, NODE_BETA,
P, P,
@ -30,6 +32,7 @@ export const rooms: RoomDocument[] = [
endReason: 'API_DELETE', endReason: 'API_DELETE',
closedBy: 'event', closedBy: 'event',
nodeId: NODE_ALPHA, nodeId: NODE_ALPHA,
mediaNode: MEDIA_NODE_ALPHA,
config: { maxParticipants: 20, emptyTimeoutMs: 300_000, departureTimeoutMs: 20_000 }, config: { maxParticipants: 20, emptyTimeoutMs: 300_000, departureTimeoutMs: 20_000 },
participants: { participants: {
sessions: 4, sessions: 4,
@ -64,6 +67,7 @@ export const rooms: RoomDocument[] = [
status: 'active', status: 'active',
createdAt: at('10:00:00'), createdAt: at('10:00:00'),
nodeId: NODE_BETA, nodeId: NODE_BETA,
mediaNode: MEDIA_NODE_BETA,
config: { maxParticipants: 0, emptyTimeoutMs: 300_000, departureTimeoutMs: 20_000 }, config: { maxParticipants: 0, emptyTimeoutMs: 300_000, departureTimeoutMs: 20_000 },
participants: { participants: {
sessions: 2, sessions: 2,

View File

@ -13,6 +13,9 @@ export const TRACKS_RETENTION_MS = 7 * RETENTION_DAY_MS;
export const NODE_ALPHA = 'ND_alpha8Kq2Lm'; export const NODE_ALPHA = 'ND_alpha8Kq2Lm';
export const NODE_BETA = 'ND_beta3Vx9Pw'; export const NODE_BETA = 'ND_beta3Vx9Pw';
/** Media node labels of the two servers: the scenario is a deployment of two labelled media nodes. */
export const MEDIA_NODE_ALPHA = 'media-node-10-0-0-1';
export const MEDIA_NODE_BETA = 'media-node-10-0-0-2';
export const SERVER_VERSION = '3.10.0'; export const SERVER_VERSION = '3.10.0';
export const REGION = 'eu-west'; export const REGION = 'eu-west';

View File

@ -4,7 +4,7 @@ import type {
HourlyRollupDocument, HourlyRollupDocument,
TrackStatsDocument TrackStatsDocument
} from '../../src/index.js'; } from '../../src/index.js';
import { ACTIVE_ROOM, at, between, DAY, NODE_ALPHA, NODE_BETA, P, T } from './scenario.js'; import { ACTIVE_ROOM, at, between, DAY, MEDIA_NODE_ALPHA, MEDIA_NODE_BETA, NODE_BETA, P, T } from './scenario.js';
const statsAt = at('10:35:00'); const statsAt = at('10:35:00');
const daveUp = { roomSid: ACTIVE_ROOM.sid, participantSid: P.dave, kind: 'up', nodeId: NODE_BETA } as const; const daveUp = { roomSid: ACTIVE_ROOM.sid, participantSid: P.dave, kind: 'up', nodeId: NODE_BETA } as const;
@ -103,8 +103,8 @@ export const analytics5min: FiveMinuteRollupDocument[] = [
internal: { connectedSids: [P.dave, P.ingress2, P.egressStreamer] } internal: { connectedSids: [P.dave, P.ingress2, P.egressStreamer] }
}, },
{ {
_id: rollupId(`node:${NODE_BETA}`, fiveMinBucket), _id: rollupId(`node:${MEDIA_NODE_BETA}`, fiveMinBucket),
scope: `node:${NODE_BETA}`, scope: `node:${MEDIA_NODE_BETA}`,
bucket: fiveMinBucket, bucket: fiveMinBucket,
...fiveMinCounters, ...fiveMinCounters,
internal: { connectedSids: [P.dave, P.ingress2, P.egressStreamer] } internal: { connectedSids: [P.dave, P.ingress2, P.egressStreamer] }
@ -180,8 +180,8 @@ export const analyticsHourly: HourlyRollupDocument[] = [
} }
}, },
{ {
_id: rollupId(`node:${NODE_ALPHA}`, hourBucket), _id: rollupId(`node:${MEDIA_NODE_ALPHA}`, hourBucket),
scope: `node:${NODE_ALPHA}`, scope: `node:${MEDIA_NODE_ALPHA}`,
bucket: hourBucket, bucket: hourBucket,
...closedRoomHour ...closedRoomHour
} }
@ -240,8 +240,8 @@ export const analyticsDaily: DailyRollupDocument[] = [
uniqueIdentities: 5 uniqueIdentities: 5
}, },
{ {
_id: rollupId(`node:${NODE_ALPHA}`, dayBucket), _id: rollupId(`node:${MEDIA_NODE_ALPHA}`, dayBucket),
scope: `node:${NODE_ALPHA}`, scope: `node:${MEDIA_NODE_ALPHA}`,
bucket: dayBucket, bucket: dayBucket,
...closedRoomHour, ...closedRoomHour,
uniqueIdentities: 3 uniqueIdentities: 3

View File

@ -1,5 +1,15 @@
import { DEFAULT_RETENTION_MS, type NodeDocument, type SchemaMetaDocument } from '../../src/index.js'; import { DEFAULT_RETENTION_MS, type NodeDocument, type SchemaMetaDocument } from '../../src/index.js';
import { at, i64, NODE_ALPHA, NODE_BETA, REGION, schemaVersion, SERVER_VERSION } from './scenario.js'; import {
at,
i64,
MEDIA_NODE_ALPHA,
MEDIA_NODE_BETA,
NODE_ALPHA,
NODE_BETA,
REGION,
schemaVersion,
SERVER_VERSION
} from './scenario.js';
export const nodes: NodeDocument[] = [ export const nodes: NodeDocument[] = [
{ {
@ -9,7 +19,8 @@ export const nodes: NodeDocument[] = [
serverVersion: SERVER_VERSION, serverVersion: SERVER_VERSION,
region: REGION, region: REGION,
batchIntervalMs: 10_000, batchIntervalMs: 10_000,
mediaStats: true mediaStats: true,
mediaNode: MEDIA_NODE_ALPHA
}, },
{ {
_id: NODE_BETA, _id: NODE_BETA,
@ -18,7 +29,8 @@ export const nodes: NodeDocument[] = [
serverVersion: SERVER_VERSION, serverVersion: SERVER_VERSION,
region: REGION, region: REGION,
batchIntervalMs: 10_000, batchIntervalMs: 10_000,
mediaStats: true mediaStats: true,
mediaNode: MEDIA_NODE_BETA
} }
]; ];

View File

@ -52,12 +52,17 @@ export const INDEXES: readonly IndexDefinition[] = [
['nameLower', 1], ['nameLower', 1],
['createdAt', -1] ['createdAt', -1]
]), ]),
index(Collection.ROOMS, 'nodeId_status_createdAt_id', [ index(
['nodeId', 1], Collection.ROOMS,
['status', 1], 'mediaNode_status_createdAt_id',
['createdAt', -1], [
['_id', -1] ['mediaNode', 1],
]), ['status', 1],
['createdAt', -1],
['_id', -1]
],
{ options: { partialFilterExpression: { mediaNode: { $exists: true } } } }
),
index( index(
Collection.ROOMS, Collection.ROOMS,
'status_lastEventAt_active', 'status_lastEventAt_active',

View File

@ -49,6 +49,8 @@ import {
} from './schema-builder.js'; } from './schema-builder.js';
const SCOPE_PATTERN = '^(global|node:[^:]+)$'; const SCOPE_PATTERN = '^(global|node:[^:]+)$';
/** A media node label: the characters a `node:<label>` analytics scope accepts. */
const MEDIA_NODE_PATTERN = '^[A-Za-z0-9_.-]{1,128}$';
const ROLLUP_ID_PATTERN = '^(global|node:[^:]+):[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:00Z$'; const ROLLUP_ID_PATTERN = '^(global|node:[^:]+):[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:00Z$';
const API_METHOD_PATTERN = '^[A-Za-z0-9]+_[A-Za-z0-9]+$'; const API_METHOD_PATTERN = '^[A-Za-z0-9]+_[A-Za-z0-9]+$';
@ -61,6 +63,10 @@ const internal = anyObject('Writer bookkeeping; never read by the dashboard');
const mediaStatsMark = bool( const mediaStatsMark = bool(
'Only false: the node that created the entity does not report media statistics; absent otherwise' 'Only false: the node that created the entity does not report media statistics; absent otherwise'
); );
const mediaNodeMark: BsonSchema = {
...str('Media node label of the server that hosts the entity; absent when it has none'),
pattern: MEDIA_NODE_PATTERN
};
const closedBy = oneOfValues(ClosedBy); const closedBy = oneOfValues(ClosedBy);
@ -125,6 +131,7 @@ const rooms = obj({
lastEventAt: req(date()), lastEventAt: req(date()),
lastStatsAt: date(), lastStatsAt: date(),
mediaStats: mediaStatsMark, mediaStats: mediaStatsMark,
mediaNode: mediaNodeMark,
internal, internal,
...entityBase ...entityBase
}); });
@ -196,6 +203,7 @@ const participants = obj({
lastEventAt: req(date()), lastEventAt: req(date()),
lastStatsAt: date(), lastStatsAt: date(),
mediaStats: mediaStatsMark, mediaStats: mediaStatsMark,
mediaNode: mediaNodeMark,
internal, internal,
...entityBase ...entityBase
}); });
@ -487,7 +495,9 @@ const nodes = obj({
batchIntervalMs: { ...int('Configured batch interval of the node, in milliseconds'), minimum: 1000 }, batchIntervalMs: { ...int('Configured batch interval of the node, in milliseconds'), minimum: 1000 },
mediaStats: bool( mediaStats: bool(
'Whether the node reports media statistics; absent on documents written before it existed (read as true)' 'Whether the node reports media statistics; absent on documents written before it existed (read as true)'
) ),
mediaNode: { ...str('Media node label of the server; absent when it has none'), pattern: MEDIA_NODE_PATTERN },
stoppedAt: date('Set by a graceful stop, after the final batch')
}); });
const metaId = (id: string): BsonSchema => ({ bsonType: 'string', enum: [id] }); const metaId = (id: string): BsonSchema => ({ bsonType: 'string', enum: [id] });

View File

@ -9,8 +9,8 @@
"internal": { "connectedSids": ["PA_dav3Pub6Mn2", "PA_1ngressSes02", "PA_egStr3amer02"] } "internal": { "connectedSids": ["PA_dav3Pub6Mn2", "PA_1ngressSes02", "PA_egStr3amer02"] }
}, },
{ {
"_id": "node:ND_beta3Vx9Pw:2026-10-12T10:30:00Z", "_id": "node:media-node-10-0-0-2:2026-10-12T10:30:00Z",
"scope": "node:ND_beta3Vx9Pw", "scope": "node:media-node-10-0-0-2",
"bucket": { "$date": "2026-10-12T10:30:00.000Z" }, "bucket": { "$date": "2026-10-12T10:30:00.000Z" },
"participants": { "connected": { "$numberInt": "3" }, "joined": { "$numberInt": "1" } }, "participants": { "connected": { "$numberInt": "3" }, "joined": { "$numberInt": "1" } },
"bytes": { "up": { "$numberLong": "13986950" }, "down": { "$numberLong": "12811470" } }, "bytes": { "up": { "$numberLong": "13986950" }, "down": { "$numberLong": "12811470" } },

View File

@ -91,8 +91,8 @@
"uniqueIdentities": { "$numberInt": "5" } "uniqueIdentities": { "$numberInt": "5" }
}, },
{ {
"_id": "node:ND_alpha8Kq2Lm:2026-10-12T00:00:00Z", "_id": "node:media-node-10-0-0-1:2026-10-12T00:00:00Z",
"scope": "node:ND_alpha8Kq2Lm", "scope": "node:media-node-10-0-0-1",
"bucket": { "$date": "2026-10-12T00:00:00.000Z" }, "bucket": { "$date": "2026-10-12T00:00:00.000Z" },
"rooms": { "rooms": {
"created": { "$numberInt": "1" }, "created": { "$numberInt": "1" },

View File

@ -73,8 +73,8 @@
} }
}, },
{ {
"_id": "node:ND_alpha8Kq2Lm:2026-10-12T09:00:00Z", "_id": "node:media-node-10-0-0-1:2026-10-12T09:00:00Z",
"scope": "node:ND_alpha8Kq2Lm", "scope": "node:media-node-10-0-0-1",
"bucket": { "$date": "2026-10-12T09:00:00.000Z" }, "bucket": { "$date": "2026-10-12T09:00:00.000Z" },
"rooms": { "rooms": {
"created": { "$numberInt": "1" }, "created": { "$numberInt": "1" },

View File

@ -6,7 +6,8 @@
"serverVersion": "3.10.0", "serverVersion": "3.10.0",
"region": "eu-west", "region": "eu-west",
"batchIntervalMs": { "$numberInt": "10000" }, "batchIntervalMs": { "$numberInt": "10000" },
"mediaStats": true "mediaStats": true,
"mediaNode": "media-node-10-0-0-1"
}, },
{ {
"_id": "ND_beta3Vx9Pw", "_id": "ND_beta3Vx9Pw",
@ -15,6 +16,7 @@
"serverVersion": "3.10.0", "serverVersion": "3.10.0",
"region": "eu-west", "region": "eu-west",
"batchIntervalMs": { "$numberInt": "10000" }, "batchIntervalMs": { "$numberInt": "10000" },
"mediaStats": true "mediaStats": true,
"mediaNode": "media-node-10-0-0-2"
} }
] ]

View File

@ -7,6 +7,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "left", "status": "left",
@ -61,6 +62,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "left", "status": "left",
@ -114,6 +116,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "left", "status": "left",
@ -166,6 +169,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "aborted", "status": "aborted",
@ -210,6 +214,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "EGRESS", "kind": "EGRESS",
"status": "left", "status": "left",
@ -254,6 +259,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "AGENT", "kind": "AGENT",
"status": "left", "status": "left",
@ -298,6 +304,7 @@
"roomSid": "RM_aLkq3ZpD9wXy", "roomSid": "RM_aLkq3ZpD9wXy",
"roomName": "Daily-Standup", "roomName": "Daily-Standup",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "SIP", "kind": "SIP",
"status": "left", "status": "left",
@ -341,6 +348,7 @@
"roomSid": "RM_bQ7tVnW2cErT", "roomSid": "RM_bQ7tVnW2cErT",
"roomName": "Webinar-Q4", "roomName": "Webinar-Q4",
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "active", "status": "active",
@ -393,6 +401,7 @@
"roomSid": "RM_bQ7tVnW2cErT", "roomSid": "RM_bQ7tVnW2cErT",
"roomName": "Webinar-Q4", "roomName": "Webinar-Q4",
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "STANDARD", "kind": "STANDARD",
"status": "joining", "status": "joining",
@ -435,6 +444,7 @@
"roomSid": "RM_bQ7tVnW2cErT", "roomSid": "RM_bQ7tVnW2cErT",
"roomName": "Webinar-Q4", "roomName": "Webinar-Q4",
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "INGRESS", "kind": "INGRESS",
"status": "left", "status": "left",
@ -479,6 +489,7 @@
"roomSid": "RM_bQ7tVnW2cErT", "roomSid": "RM_bQ7tVnW2cErT",
"roomName": "Webinar-Q4", "roomName": "Webinar-Q4",
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "INGRESS", "kind": "INGRESS",
"status": "active", "status": "active",
@ -518,6 +529,7 @@
"roomSid": "RM_bQ7tVnW2cErT", "roomSid": "RM_bQ7tVnW2cErT",
"roomName": "Webinar-Q4", "roomName": "Webinar-Q4",
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"schemaVersion": { "$numberInt": "1" }, "schemaVersion": { "$numberInt": "1" },
"kind": "EGRESS", "kind": "EGRESS",
"status": "active", "status": "active",

View File

@ -10,6 +10,7 @@
"endReason": "API_DELETE", "endReason": "API_DELETE",
"closedBy": "event", "closedBy": "event",
"nodeId": "ND_alpha8Kq2Lm", "nodeId": "ND_alpha8Kq2Lm",
"mediaNode": "media-node-10-0-0-1",
"config": { "config": {
"maxParticipants": { "$numberInt": "20" }, "maxParticipants": { "$numberInt": "20" },
"emptyTimeoutMs": { "$numberLong": "300000" }, "emptyTimeoutMs": { "$numberLong": "300000" },
@ -74,6 +75,7 @@
"status": "active", "status": "active",
"createdAt": { "$date": "2026-10-12T10:00:00.000Z" }, "createdAt": { "$date": "2026-10-12T10:00:00.000Z" },
"nodeId": "ND_beta3Vx9Pw", "nodeId": "ND_beta3Vx9Pw",
"mediaNode": "media-node-10-0-0-2",
"config": { "config": {
"maxParticipants": { "$numberInt": "0" }, "maxParticipants": { "$numberInt": "0" },
"emptyTimeoutMs": { "$numberLong": "300000" }, "emptyTimeoutMs": { "$numberLong": "300000" },

View File

@ -39,14 +39,14 @@
}, },
{ {
"collection": "rooms", "collection": "rooms",
"name": "nodeId_status_createdAt_id", "name": "mediaNode_status_createdAt_id",
"key": [ "key": [
["nodeId", 1], ["mediaNode", 1],
["status", 1], ["status", 1],
["createdAt", -1], ["createdAt", -1],
["_id", -1] ["_id", -1]
], ],
"options": {} "options": { "partialFilterExpression": { "mediaNode": { "$exists": true } } }
}, },
{ {
"collection": "rooms", "collection": "rooms",

View File

@ -15,7 +15,13 @@
"mediaStats": { "mediaStats": {
"bsonType": "bool", "bsonType": "bool",
"description": "Whether the node reports media statistics; absent on documents written before it existed (read as true)" "description": "Whether the node reports media statistics; absent on documents written before it existed (read as true)"
} },
"mediaNode": {
"bsonType": "string",
"description": "Media node label of the server; absent when it has none",
"pattern": "^[A-Za-z0-9_.-]{1,128}$"
},
"stoppedAt": { "bsonType": "date", "description": "Set by a graceful stop, after the final batch" }
}, },
"additionalProperties": false "additionalProperties": false
} }

View File

@ -186,6 +186,11 @@
"bsonType": "bool", "bsonType": "bool",
"description": "Only false: the node that created the entity does not report media statistics; absent otherwise" "description": "Only false: the node that created the entity does not report media statistics; absent otherwise"
}, },
"mediaNode": {
"bsonType": "string",
"description": "Media node label of the server that hosts the entity; absent when it has none",
"pattern": "^[A-Za-z0-9_.-]{1,128}$"
},
"internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" }, "internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" },
"schemaVersion": { "schemaVersion": {
"bsonType": ["int", "long"], "bsonType": ["int", "long"],

View File

@ -158,6 +158,11 @@
"bsonType": "bool", "bsonType": "bool",
"description": "Only false: the node that created the entity does not report media statistics; absent otherwise" "description": "Only false: the node that created the entity does not report media statistics; absent otherwise"
}, },
"mediaNode": {
"bsonType": "string",
"description": "Media node label of the server that hosts the entity; absent when it has none",
"pattern": "^[A-Za-z0-9_.-]{1,128}$"
},
"internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" }, "internal": { "bsonType": "object", "description": "Writer bookkeeping; never read by the dashboard" },
"schemaVersion": { "schemaVersion": {
"bsonType": ["int", "long"], "bsonType": ["int", "long"],

View File

@ -139,7 +139,7 @@ export const ANALYTICS_GRANULARITIES = ['5m', '15m', '1h', '6h', '1d'] as const
export type AnalyticsBucketGranularity = (typeof ANALYTICS_GRANULARITIES)[number]; export type AnalyticsBucketGranularity = (typeof ANALYTICS_GRANULARITIES)[number];
export type AnalyticsGranularity = 'auto' | AnalyticsBucketGranularity; export type AnalyticsGranularity = 'auto' | AnalyticsBucketGranularity;
/** `global` or `node:<nodeId>`. */ /** `global` or `node:<media node label>`. */
export type AnalyticsScope = 'global' | `node:${string}`; export type AnalyticsScope = 'global' | `node:${string}`;
export const ANALYTICS_GLOBAL_SCOPE = 'global'; export const ANALYTICS_GLOBAL_SCOPE = 'global';

View File

@ -30,6 +30,8 @@ export interface ParticipantListItem {
durationMs?: number; durationMs?: number;
connectionType?: Exclude<ConnectionType, 'other'>; connectionType?: Exclude<ConnectionType, 'other'>;
nodeId?: string; nodeId?: string;
/** Media node label of the server that hosts the session; absent when it has none. */
mediaNode?: string;
isPublisher: boolean; isPublisher: boolean;
/** Track sources with at least one publication, in `TrackSource` order. */ /** Track sources with at least one publication, in `TrackSource` order. */
publishedSources: TrackSource[]; publishedSources: TrackSource[];

View File

@ -39,7 +39,10 @@ export interface RoomListItem {
durationMs?: number; durationMs?: number;
/** `reconciler` marks an estimated end. */ /** `reconciler` marks an estimated end. */
closedBy?: ClosedBy; closedBy?: ClosedBy;
/** Id of the server process that hosts the room. */
nodeId?: string; nodeId?: string;
/** Media node label of that server; absent when it has none. */
mediaNode?: string;
participants: { sessions: number; unique: number; active: number; peakActive: number }; participants: { sessions: number; unique: number; active: number; peakActive: number };
egress: { total: number; active: number }; egress: { total: number; active: number };
ingress: { sessions: number; active: number }; ingress: { sessions: number; active: number };
@ -66,5 +69,6 @@ export interface RoomSummary {
export interface RoomsListQuery extends ListQuery<RoomSort> { export interface RoomsListQuery extends ListQuery<RoomSort> {
status?: RoomStatusFilter; status?: RoomStatusFilter;
features?: RoomFeature[]; features?: RoomFeature[];
nodeId?: string; /** Exact media node label. */
mediaNode?: string;
} }

View File

@ -4,9 +4,8 @@ import type { InfoMigration, InfoSchema } from './info.js';
export interface StatusWriter { export interface StatusWriter {
/** Newest `lastSeenAt` of the server nodes, ISO 8601. */ /** Newest `lastSeenAt` of the server nodes, ISO 8601. */
lastSeenAt?: string; lastSeenAt?: string;
nodes: number; /** `stoppedAt` of the newest node document when no node is alive: the server stopped gracefully, ISO 8601. */
/** Nodes whose last batch is within the fresh window of their own batch interval at `generatedAt`. */ stoppedAt?: string;
aliveNodes: number;
/** Largest batch interval among the nodes seen in the last hour; absent when none reports one (read as 10 s). */ /** Largest batch interval among the nodes seen in the last hour; absent when none reports one (read as 10 s). */
batchIntervalMs?: number; batchIntervalMs?: number;
} }

View File

@ -97,6 +97,8 @@ export interface RoomDocument extends EntityBase {
lastStatsAt?: Date; lastStatsAt?: Date;
/** `false` when the node that created it does not report media statistics; absent otherwise. */ /** `false` when the node that created it does not report media statistics; absent otherwise. */
mediaStats?: false; mediaStats?: false;
/** Media node label of the server that hosts it; absent when that server has none. */
mediaNode?: string;
internal?: WriterInternal; internal?: WriterInternal;
} }
@ -171,6 +173,8 @@ export interface ParticipantDocument extends EntityBase {
lastStatsAt?: Date; lastStatsAt?: Date;
/** `false` when the node that created it does not report media statistics; absent otherwise. */ /** `false` when the node that created it does not report media statistics; absent otherwise. */
mediaStats?: false; mediaStats?: false;
/** Media node label of the server that hosts it; absent when that server has none. */
mediaNode?: string;
internal?: WriterInternal; internal?: WriterInternal;
} }

View File

@ -1,6 +1,6 @@
import type { MigrationState, StatsDetail } from './enums.js'; import type { MigrationState, StatsDetail } from './enums.js';
/** `nodes`: one document per server node, refreshed on every writer batch. `_id` is the node id. */ /** `nodes`: one document per server process, refreshed on every writer batch. `_id` is the node id of the process. */
export interface NodeDocument { export interface NodeDocument {
_id: string; _id: string;
firstSeenAt: Date; firstSeenAt: Date;
@ -11,6 +11,10 @@ export interface NodeDocument {
batchIntervalMs?: number; batchIntervalMs?: number;
/** Whether the node reports media statistics; absent on documents written before it existed (read as true). */ /** Whether the node reports media statistics; absent on documents written before it existed (read as true). */
mediaStats?: boolean; mediaStats?: boolean;
/** Media node label of the server: a name of the machine that stays the same across restarts; absent without one. */
mediaNode?: string;
/** Set by a graceful stop, after the final batch. */
stoppedAt?: Date;
} }
/** `schema_meta` `_id: "schema"`: contract version implemented by the writer. */ /** `schema_meta` `_id: "schema"`: contract version implemented by the writer. */