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
extending edition adds its pages over locked CE routes and fills explicit seams (see the library README). Keep seams
open in CE (injectable services, route configs, content projection) and never put logic in the shells.
- `GET /api/v1/info` reports the edition and its features. CE shows PRO features locked, never hidden; PRO API routes
answer `403 PRO_FEATURE` in CE.
- `GET /api/v1/info` reports the edition and its features. CE shows PRO features locked, never hidden, except
`per-node-breakdown`: CE runs on one machine, so nothing about nodes renders without it. PRO API routes answer
`403 PRO_FEATURE` in CE.
## Data contract

View File

@ -36,7 +36,8 @@ queried) and `Deployment` (running the service). One line per observable change,
#### UI
- Overview page with live counters of rooms, participants, publishers, egress, ingress and agents linked to their
lists, today's figures, recent rooms, the data pipeline health (writer and nodes reporting) and stale-data notices.
lists, today's figures, recent rooms, the data pipeline health (last batch, or when the server stopped) and stale-data
notices.
- Participants page with live refresh: active participants, sessions in range and active services; search participant
sessions by identity or SID, filter by kind (standard participants by default), status, room and publishers, sort by
join, leave and duration, and see the sources each session published.
@ -131,6 +132,7 @@ queried) and `Deployment` (running the service). One line per observable change,
their rooms with the last of their sessions once LiveKit's dead node cleanup released them, with an end time accurate
to 30 s (`openvidu.analytics.reconciler` options `enabled`, `interval` and `max_closures_per_run`); a recording lost
with its node is reported as failed.
- Analytics events and statistics queued in a node are written before it stops on a graceful shutdown.
- Analytics events and statistics queued in a node are written before it stops on a graceful shutdown, and the node
records when it stopped.
- Credentials are never stored in the analytics database; metadata is stored only with `store_metadata`, and client
IP addresses are left out unless `anonymize_ip: false`.

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
that leaves a lock without a definition exits with code 1, naming it.
- Extension tokens that `configure` can rebind: `Features`, `AnalyticsCatalogue` (metrics and dimensions the
analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`). A
analytics endpoints compute) and `OverviewExtension` (extra overview fields, merged before the `ETag`, folded from the
node documents and the active rooms and sessions grouped by `mediaNode`). A
`configure` that throws stops the startup with code 1.
- A resource slice is `src/<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

View File

@ -31,12 +31,15 @@ export class AnalyticsRepository {
.sort({ bucket: 1 });
}
/** The `mediaStats` of the nodes of `scope` whose heartbeats span overlaps `[from, to]`, on `lastSeenAt`. */
/**
* The `mediaStats` of the node documents of `scope` (every one for `global`, those with the media node label of a node
* scope) whose heartbeats span overlaps `[from, to]`, on `lastSeenAt`.
*/
nodesInRange(scope: string, from: Date, to: Date): FindCursor<Pick<NodeDocument, 'mediaStats'>> {
const filter: Document = { lastSeenAt: { $gte: from }, firstSeenAt: { $lte: to } };
if (scope.startsWith(NODE_SCOPE_PREFIX)) {
filter._id = scope.slice(NODE_SCOPE_PREFIX.length);
filter.mediaNode = scope.slice(NODE_SCOPE_PREFIX.length);
}
return this.db

View File

@ -25,16 +25,16 @@ export const TODAY_FIELDS = [
'ingress.sessionsStarted'
] as const;
/** Active rooms of a node. */
/** Active rooms of a media node; `_id` is the label, `null` for the rooms without one. */
export interface RoomGroup {
_id: string | null;
rooms: number;
stale: number;
}
/** Active participants of one kind on a node. */
/** Active participants of one kind on a media node; `mediaNode` is `null` for the sessions without a label. */
export interface ParticipantGroup {
_id: { nodeId: string | null; kind: ParticipantKind };
_id: { mediaNode: string | null; kind: ParticipantKind };
count: number;
publishers: number;
stale: number;
@ -52,7 +52,7 @@ function staleCount(threshold: Date): Document {
export function activeRoomsPipeline(threshold: Date): Document[] {
return [
{ $match: { status: RoomStatus.ACTIVE } },
{ $group: { _id: '$nodeId', rooms: { $sum: 1 }, stale: staleCount(threshold) } }
{ $group: { _id: '$mediaNode', rooms: { $sum: 1 }, stale: staleCount(threshold) } }
];
}
@ -62,7 +62,7 @@ export function activeParticipantsPipeline(threshold: Date): Document[] {
{ $match: { kind: { $in: Object.values(ParticipantKind) }, status: ParticipantStatus.ACTIVE } },
{
$group: {
_id: { nodeId: '$nodeId', kind: '$kind' },
_id: { mediaNode: '$mediaNode', kind: '$kind' },
count: { $sum: 1 },
publishers: { $sum: { $cond: ['$isPublisher', 1, 0] } },
stale: staleCount(threshold)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,8 @@ export const roomListFields = {
endedAt: isoDate.optional(),
durationMs: count.optional(),
closedBy: z.enum(enumValues(ClosedBy)).optional(),
nodeId: z.string().optional(),
nodeId: z.string().optional().meta({ description: 'Id of the server process that hosts the room' }),
mediaNode: z.string().optional().meta({ description: 'Media node label of that server, absent without one' }),
participants: z.object({ sessions: count, unique: count, active: count, peakActive: count }),
egress: z.object({ total: count, active: count }),
ingress: z.object({ sessions: count, active: count }),

View File

@ -28,7 +28,12 @@ export function roomsListQuery(now: () => Date) {
features: csvParam(enumValues(RoomFeature))
.optional()
.meta({ description: 'Rooms that used any of these features' }),
nodeId: z.string().min(1).max(128).optional().meta({ description: 'Rooms hosted by this node' }),
mediaNode: z
.string()
.min(1)
.max(128)
.optional()
.meta({ description: 'Rooms hosted by the server with this media node label' }),
sort: z
.enum(enumValues(RoomSort), { error: 'must be createdAt, endedAt, duration, uniqueIdentities or active' })
.default(RoomSort.CREATED_AT)

View File

@ -8,7 +8,7 @@ const INTERVAL_WINDOW_MS = 3_600_000;
/** How long the windows read from `nodes` are reused. */
const WINDOWS_CACHE_MS = 10_000;
export type NodeHeartbeat = Pick<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. */
export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date): number | undefined {
@ -23,9 +23,12 @@ export function writerBatchInterval(nodes: readonly NodeHeartbeat[], now: Date):
return largest;
}
/** A node is alive while its last batch is within the fresh window of its own batch interval. */
/** A node is alive while it has not stopped and its last batch is within the fresh window of its own batch interval. */
export function nodeAlive(node: NodeHeartbeat, now: Date): boolean {
return now.getTime() - node.lastSeenAt.getTime() <= freshnessWindows(node.batchIntervalMs).freshMs;
return (
node.stoppedAt === undefined &&
now.getTime() - node.lastSeenAt.getTime() <= freshnessWindows(node.batchIntervalMs).freshMs
);
}
/** The stale window and the server clock of one response. */

View File

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

View File

@ -12,17 +12,20 @@ export interface StatusServiceOptions {
now: () => Date;
}
/** The newest batch of the nodes, their count, the alive ones and the writer's batch interval at `now`. */
/**
* The newest batch of the nodes, the writer's batch interval and, when no node is alive at `now` and the newest one
* stopped gracefully, its stop.
*/
export function foldWriter(nodes: readonly NodeHeartbeat[], now: Date): StatusWriter {
const lastSeen = nodes.reduce<Date | undefined>(
(newest, node) => (newest === undefined || node.lastSeenAt > newest ? node.lastSeenAt : newest),
const newest = nodes.reduce<NodeHeartbeat | undefined>(
(found, node) => (found === undefined || node.lastSeenAt > found.lastSeenAt ? node : found),
undefined
);
const stopped = newest?.stoppedAt !== undefined && !nodes.some((node) => nodeAlive(node, now));
return compact<StatusWriter>({
lastSeenAt: lastSeen?.toISOString(),
nodes: nodes.length,
aliveNodes: nodes.filter((node) => nodeAlive(node, now)).length,
lastSeenAt: newest?.lastSeenAt.toISOString(),
stoppedAt: stopped ? newest.stoppedAt?.toISOString() : undefined,
batchIntervalMs: writerBatchInterval(nodes, now)
});
}
@ -39,7 +42,7 @@ export class StatusService {
this.clock(),
this.db
.collection<NodeDocument>(Collection.NODES)
.find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1 } })
.find({}, { projection: { lastSeenAt: 1, batchIntervalMs: 1, stoppedAt: 1 } })
.toArray(),
this.options.info.health()
]);

View File

@ -300,16 +300,22 @@ describe.skipIf(mongoUri === '')('analytics API', () => {
expect(await coverage()).toBeUndefined();
});
it('reads the node of a node scope only', async () => {
it('reads the node documents of the media node of a node scope only', async () => {
const nodes = ce.dashboard.db.collection<{ _id: string } & Document>(Collection.NODES);
const repository = new AnalyticsRepository(ce.dashboard.db);
const from = new Date(Date.now() - HOUR);
await nodes.deleteMany({});
await nodes.insertMany([node('ND_a', false), node('ND_b', true)]);
await nodes.insertMany([
{ ...node('ND_a', false), mediaNode: 'mn-a' },
{ ...node('ND_b', true), mediaNode: 'mn-b' },
{ ...node('ND_b2', false), mediaNode: 'mn-b' }
]);
expect(await repository.nodesMediaStats('node:ND_b', from, new Date())).toEqual([true]);
expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, true]);
expect(await repository.nodesMediaStats('node:mn-a', from, new Date())).toEqual([false]);
expect((await repository.nodesMediaStats('node:mn-b', from, new Date())).sort()).toEqual([false, true]);
expect(await repository.nodesMediaStats('node:ND_b', from, new Date())).toEqual([]);
expect((await repository.nodesMediaStats('global', from, new Date())).sort()).toEqual([false, false, true]);
});
});

View File

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

View File

@ -1,11 +1,12 @@
import { CONTRACT_SCHEMAS } from '@openvidu-dashboard/data-generator/contract';
import { generate } from '@openvidu-dashboard/data-generator/generator';
import { COLLECTION_NAMES } from '@openvidu-dashboard/data-generator/schema';
import { COLLECTION_NAMES, keepLive } from '@openvidu-dashboard/data-generator/schema';
import type {
EgressDocument,
EventDocument,
HourlyRollupDocument,
IngressSessionDocument
IngressSessionDocument,
NodeDocument
} from '@openvidu-dashboard/typings';
import { type Document, type Filter, MongoClient } from 'mongodb';
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
@ -41,6 +42,7 @@ describe.skipIf(mongoUri === '')('data generator', () => {
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true,
activeWindowMinutes: 60,
minActiveRooms: 10
});
@ -200,6 +202,88 @@ describe.skipIf(mongoUri === '')('data generator', () => {
}
});
it('labels the nodes and adds an earlier stopped process with media nodes', async () => {
const db = client.db('dashboard_test_generator_media_nodes');
await db.dropDatabase();
try {
await generate(db, {
scenario: 'S',
days: 2,
statsMode: 'participant',
seed: 3,
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true
});
const nodes = await db.collection<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 () => {
const db = client.db('dashboard_test_generator_media_stats');
await db.dropDatabase();
@ -213,6 +297,7 @@ describe.skipIf(mongoUri === '')('data generator', () => {
retentionDays: { events: 7, stats: 7, tracks: 7, entities: 90 },
timeseries: true,
rollups: { fiveMinute: true, nodes: true, roomSums: true },
mediaNodes: true,
nodesWithoutMediaStats: 1
});

View File

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

View File

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest';
import { type ListResponse, type ParticipantListItem } from '@openvidu-dashboard/typings';
import { Collection, type ListResponse, type ParticipantListItem } from '@openvidu-dashboard/typings';
import { openApiDocument } from '../../src/api/openapi.js';
import type { RouteRegistry } from '../../src/api/route-registry.js';
@ -372,5 +372,29 @@ describe.skipIf(mongoUri === '')('participants API', () => {
'PA_ag3'
]);
});
it('carries the media node label of the sessions that have one', async () => {
await reseed(ce.dashboard.db, {
participants: [
participant({
_id: 'PA_labelled',
identity: 'alice',
roomSid: 'RM_a',
joinedAt: minutesAgo(3),
mediaNode: 'mn-a'
}),
participant({ _id: 'PA_plain', identity: 'bob', roomSid: 'RM_a', joinedAt: minutesAgo(2) })
]
});
await ce.dashboard.db
.collection(Collection.PARTICIPANTS)
.updateOne({ _id: 'PA_plain' } as never, { $unset: { mediaNode: '' } });
const items = (await json<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']);
});
it('filters by features and node', async () => {
it('filters by features and media node', async () => {
const plain = { egress: false, ingress: false, agent: false, sip: false };
await reseed(ce.dashboard.db, {
@ -178,14 +178,30 @@ describe.skipIf(mongoUri === '')('rooms API', () => {
closedRoom({ _id: 'RM_eg', createdAt: minutesAgo(30), features: { ...plain, egress: true } }),
closedRoom({ _id: 'RM_in', createdAt: minutesAgo(20), features: { ...plain, ingress: true } }),
closedRoom({ _id: 'RM_plain', createdAt: minutesAgo(10), features: plain }),
activeRoom({ _id: 'RM_nodeA', createdAt: minutesAgo(5), nodeId: 'node-a', features: plain }),
activeRoom({ _id: 'RM_nodeB', createdAt: minutesAgo(4), nodeId: 'node-b', features: plain })
activeRoom({
_id: 'RM_nodeA',
createdAt: minutesAgo(5),
nodeId: 'ND_a',
mediaNode: 'mn-a',
features: plain
}),
activeRoom({
_id: 'RM_nodeB',
createdAt: minutesAgo(4),
nodeId: 'ND_b',
mediaNode: 'mn-b',
features: plain
})
]
});
expect(sids(await json(ce, '/rooms?features=egress,ingress'))).toEqual(['RM_in', 'RM_eg']);
expect(sids(await json(ce, '/rooms?nodeId=node-a&status=active'))).toEqual(['RM_nodeA']);
expect(sids(await json(ce, '/rooms?q=rm_node&nodeId=node-a'))).toEqual(['RM_nodeA']);
const one = await json<ListResponse<RoomListItem>>(ce, '/rooms?mediaNode=mn-a&status=active');
expect(sids(one)).toEqual(['RM_nodeA']);
expect(one.items[0]).toMatchObject({ nodeId: 'ND_a', mediaNode: 'mn-a' });
expect(sids(await json(ce, '/rooms?mediaNode=mn-z'))).toEqual([]);
expect(sids(await json(ce, '/rooms?q=rm_node&mediaNode=mn-a'))).toEqual(['RM_nodeA']);
});
it('carries the media statistics mark of the rooms whose node reports none, sorted or ranked', async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,21 +10,38 @@ const seen = (secondsAgo: number, batchIntervalMs?: number) => ({
});
describe('writer status', () => {
it('takes the newest batch and counts the nodes seen within 30 s', () => {
expect(foldWriter([seen(50), seen(4), seen(30)], NOW)).toEqual({
lastSeenAt: '2026-10-13T11:59:56.000Z',
nodes: 3,
aliveNodes: 2
});
const stopped = (secondsAgo: number) => {
const at = new Date(NOW.getTime() - secondsAgo * 1000);
return { lastSeenAt: at, stoppedAt: at };
};
it('takes the newest batch', () => {
expect(foldWriter([seen(50), seen(4), seen(30)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:56.000Z' });
});
it('reports no batch without nodes', () => {
expect(foldWriter([], NOW)).toEqual({ nodes: 0, aliveNodes: 0 });
expect(foldWriter([], NOW)).toEqual({});
});
it('reports the stop of the newest node when no node is alive', () => {
expect(foldWriter([stopped(6), seen(3600)], NOW)).toEqual({
lastSeenAt: '2026-10-13T11:59:54.000Z',
stoppedAt: '2026-10-13T11:59:54.000Z'
});
});
it('reports no stop after a restart', () => {
expect(foldWriter([stopped(60), seen(3)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:57.000Z' });
});
it('reports no stop while another node is alive', () => {
expect(foldWriter([stopped(2), seen(6)], NOW)).toEqual({ lastSeenAt: '2026-10-13T11:59:58.000Z' });
});
it('judges each node by its own interval and reports the largest one', () => {
expect(foldWriter([seen(80, 60_000), seen(40, 10_000)], NOW)).toMatchObject({
aliveNodes: 1,
expect(foldWriter([seen(80, 60_000), stopped(120)], NOW)).toEqual({
lastSeenAt: '2026-10-13T11:58:40.000Z',
batchIntervalMs: 60_000
});
});

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

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
test('overview against the real backend: live tiles, stale hint, writer health, recent rooms and the lock', async ({
test('overview against the real backend: live tiles, stale hint, writer health, recent rooms and nothing per node', async ({
page
}) => {
await page.goto('/login?redirect=%2Foverview%3Ftz%3DUTC');
@ -18,9 +18,9 @@ test('overview against the real backend: live tiles, stale hint, writer health,
await expect(page.getByRole('alert').filter({ hasText: 'may be stale' })).toContainText('1 active room');
await expect(page.locator('.page-header ovd-live-chip')).toHaveText('LIVE');
await expect(card('Data pipeline')).toContainText(/Last batch at \d\d:\d\d:\d\d/);
await expect(card('Data pipeline')).toContainText(/\d+ of \d+ nodes? reporting/);
await expect(card('Data pipeline')).not.toContainText('node');
await expect(card('Today (UTC, since 00:00)')).toContainText('3');
await expect(card('Recent rooms').getByRole('row', { name: /Webinar-Q4/ })).toBeVisible();
await expect(card('Recent rooms').locator('ovd-status-badge mat-icon.stale')).toHaveCount(1);
await expect(card('By node')).toContainText('Per-node breakdown is available in OpenVidu PRO');
await expect(card('By node')).toHaveCount(0);
});

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

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 }) => {
await page.goto('/overview?tz=UTC');
// The pointer rests at the top-left corner, where the overlay renders the tooltip before positioning it: the
// mouseleave that follows would close a tooltip still opening, as on a slow runner.
await page.mouse.move(640, 700);
// mouseleave that follows would close a tooltip still opening, as on a slow runner. The page title has no tooltip.
const title = await page.getByRole('heading', { level: 1, name: 'Overview' }).boundingBox();
await page.mouse.move((title?.x ?? 0) + 4, (title?.y ?? 0) + 4);
const tiles = page.getByRole('region', { name: 'Live' });

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'
);
await expect(card(page, 'Data pipeline')).toContainText(/Last batch at \d\d:\d\d:\d\d/);
await expect(card(page, 'Data pipeline')).toContainText(/\d+ of \d+ nodes? reporting/);
await expect(card(page, 'Data pipeline')).not.toContainText('node');
await expect(card(page, 'Data pipeline')).not.toContainText('schema');
await expect(card(page, 'Data pipeline')).not.toContainText('retention:');
await expect(card(page, 'Data pipeline').getByRole('link', { name: 'Storage and retention' })).toBeVisible();
await expect(card(page, 'Today (UTC, since 00:00)')).toContainText('143');
await expect(card(page, 'By node')).toContainText('Per-node breakdown is available in OpenVidu PRO');
// CE has a single machine: no per-node card, and Recent rooms takes the full width.
await expect(page.locator('ovd-section-card').filter({ hasText: 'By node' })).toHaveCount(0);
await expect(page.getByText('Per-node breakdown')).toHaveCount(0);
await expect(card(page, 'Recent rooms')).toHaveClass(/\bfull\b/);
const recent = card(page, 'Recent rooms');
@ -105,7 +108,7 @@ test.describe('a silent writer', () => {
await page.getByRole('navigation', { name: 'Main' }).getByRole('link', { name: 'Overview' }).click();
await expect(page.locator('.page-header ovd-live-chip')).toHaveText('stale');
await expect(card(page, 'Data pipeline')).toContainText('0 of 2 nodes reporting');
await expect(card(page, 'Data pipeline').locator('mat-icon.warn')).toBeVisible();
});
});

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,
widen and retry) and render an `ovd-analytics-card` with `CardContent`.
- `ANALYTICS_SCOPE_SELECT` (Analytics header) and `ROOMS_NODE_FILTER` (Rooms filter bar): loaders of the node
selects, which send their own scopes requests.
- `OVERVIEW_NODES_CARD`: the "By node" card of the Overview, given `overview`, the last overview response.
selects, which send their own scopes requests; the Rooms one binds a media node label to the `node` URL parameter,
sent to the list as `mediaNode`.
- `OVERVIEW_NODES_CARD`: the content of the "By node" card of the Overview, given `overview`, the last overview
response.
- These three, and the Node columns of the Rooms, Participants and Agents tables (the `mediaNode` of each item),
render only with `per-node-breakdown`; without it nothing about nodes renders, not even locked.
- **Status styles.** `provideStatusStyles()` registers status domains beyond the CE ones of `ovd-status-badge`, with
the styles of `STATUS_FAMILY_STYLES`.
- **Chart colours.** `ChartColorsService.colors()` resolves the theme's chart tokens; `resolved(tokens)` resolves an

View File

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

View File

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

View File

@ -14,23 +14,6 @@
@if (scopeSelect(); as 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>

View File

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

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

View File

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

View File

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

View File

@ -28,7 +28,10 @@ function elapsed(snapshot: OverviewSnapshot, now: number): number {
return Math.max(0, now - snapshot.receivedAt);
}
/** Component of the PRO "By node" card, given `overview`, the last overview response; CE provides none and renders the lock. */
/**
* Content of the "By node" card, given `overview`, the last overview response; rendered only with `per-node-breakdown`.
* CE provides none and shows no card.
*/
export const OVERVIEW_NODES_CARD = new InjectionToken<Type<unknown> | null>('OVERVIEW_NODES_CARD', {
factory: () => null
});

View File

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

View File

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

View File

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

View File

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

View File

@ -67,7 +67,7 @@
}
</ng-template>
<ng-template ovdCell="node" let-item>
<span class="ovd-mono">{{ item.nodeId ?? '—' }}</span>
<span class="ovd-mono">{{ item.mediaNode ?? '—' }}</span>
</ng-template>
@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 { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js';
import { exactListId, listSearchQuery } from '../../components/search/list-search.js';
import { FeatureService } from '../../core/info.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js';
@ -34,6 +35,7 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
const listState = inject(ListStateService);
const previews = inject(EntityPreviewService);
const rangePicker = inject(RangePickerService);
const perNode = inject(FeatureService).enabledSignal('per-node-breakdown');
const q = computed(() => listSearchQuery(query.q(), 'participant'));
const room = computed(() => {
@ -86,7 +88,8 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
...ranked.column('duration')
},
{ key: 'published', header: 'Published', card: 'metric' },
{ key: 'node', header: 'Node', defaultHidden: true }
// The media node label, only where there are several media nodes.
...(perNode() ? [{ key: 'node', header: 'Node', defaultHidden: true }] : [])
]),
/** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */
time: (value: string): string =>

View File

@ -11,6 +11,7 @@ import { infoResponse, settle } from '../../../testing/fixtures.js';
import { routeElement } from '../../../testing/harness.js';
import { LIST_SEARCH_DEBOUNCE_MS } from '../../components/search/list-search.js';
import { InfoService } from '../../core/info.js';
import { PreferencesService } from '../../core/preferences/preferences.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ParticipantLockedPage } from './participant-locked-page.js';
import { ParticipantsPage } from './participants-page.js';
@ -119,6 +120,37 @@ function text(element: Element | null | undefined): string {
const debounce = () => new Promise((resolve) => setTimeout(resolve, LIST_SEARCH_DEBOUNCE_MS + 50));
describe('ParticipantsPage', () => {
it('offers no Node column in CE', async () => {
const { harness } = await open('/participants');
const columns = (
harness.routeDebugElement?.componentInstance as { sessions: { columns(): { key: string }[] } }
).sessions.columns();
expect(columns.map((column) => column.key)).not.toContain('node');
});
it('shows the media node label in the Node column with per-node breakdown', async () => {
const http = setup();
const info = TestBed.inject(InfoService).load();
http.expectOne('api/v1/info').flush(infoResponse({ features: ['per-node-breakdown'] as never[] }));
await info;
TestBed.inject(PreferencesService).setHiddenColumns('participants', []);
const harness = await RouterTestingHarness.create('/participants');
await load(http, harness, page([item({ mediaNode: 'mn-a' }), item({ sid: 'PA_plain' })]), summary());
await harness.fixture.whenStable();
const element = routeElement(harness);
const nodeIndex = [...element.querySelectorAll('thead th')].findIndex((th) => text(th) === 'Node');
expect(nodeIndex).toBeGreaterThan(-1);
expect(
[...element.querySelectorAll('tbody tr')].map((row) => text(row.querySelectorAll('td')[nodeIndex]))
).toEqual(['mn-a', '—']);
});
it('reproduces the URL state in the controls and the request', async () => {
const { element, request } = await open(
'/participants?from=now-7d&to=now&tz=UTC&q=ali&kind=STANDARD,EGRESS&status=left&limit=50'

View File

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

View File

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

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 { 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', {
factory: () => null
});
@ -23,7 +26,7 @@ export interface RoomsListRequest {
status: RoomStatusFilter;
q: string | null;
features: readonly RoomFeature[];
nodeId: string | null;
mediaNode: string | null;
sort: RoomSort;
order: SortOrder;
cursor: string | null;
@ -50,7 +53,7 @@ export class RoomsDataService {
status: request.status,
q: request.q,
features: request.features,
nodeId: request.nodeId,
mediaNode: request.mediaNode,
sort: request.sort,
order: request.order,
cursor: request.cursor,

View File

@ -42,23 +42,6 @@
@if (nodeSelect(); as 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">
@ -168,7 +151,7 @@
</span>
</ng-template>
<ng-template ovdCell="node" let-room>
<span class="ovd-mono">{{ room.nodeId ?? '—' }}</span>
<span class="ovd-mono">{{ room.mediaNode ?? '—' }}</span>
</ng-template>
@if (exactId(); as sid) {

View File

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

View File

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

View File

@ -3,9 +3,6 @@ import { Component, computed, effect, inject, signal, type Type, untracked } fro
import { MatButton } from '@angular/material/button';
import { MatButtonToggle, MatButtonToggleGroup } from '@angular/material/button-toggle';
import { MatChipListbox, MatChipOption } from '@angular/material/chips';
import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatOption, MatSelect } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import {
@ -72,12 +69,6 @@ interface RoomsData {
MatButtonToggleGroup,
MatChipListbox,
MatChipOption,
MatFormField,
MatIcon,
MatLabel,
MatOption,
MatSelect,
MatSuffix,
MatTooltip,
NgComponentOutlet,
RouterLink,
@ -102,17 +93,17 @@ export class RoomsPage {
protected readonly now = injectTicker();
protected readonly tz = this.query.tz;
protected readonly limit = this.query.limit;
/** The `ROOMS_NODE_FILTER` select with `per-node-breakdown`, the locked select without it. */
/** The `ROOMS_NODE_FILTER` select and the Node column with `per-node-breakdown`; nothing about nodes without it. */
protected readonly nodeFilter = inject(FeatureService).enabledSignal('per-node-breakdown');
private readonly loadNodeFilter = inject(ROOMS_NODE_FILTER);
protected readonly nodeSelect = signal<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 q = computed(() => listSearchQuery(this.query.q(), 'room'));
protected readonly exactId = computed(() => exactListId(this.q(), 'room'));
protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS));
protected readonly nodeId = computed(() => (this.nodeFilter() ? this.query.value('nodeId') : null));
/** The media node label of the `node` parameter, ignored without `per-node-breakdown`. */
protected readonly node = computed(() => (this.nodeFilter() ? this.query.value('node') : null));
private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), this.now()));
/** Range span when the range last changed, so the ticker never re-renders the headers. */
private readonly requestRangeMs = computed(() => {
@ -134,7 +125,7 @@ export class RoomsPage {
status: this.status(),
q: this.q(),
features: this.selectedFeatures(),
nodeId: this.nodeId(),
mediaNode: this.node(),
sort: this.sort(),
order: this.order(),
cursor: this.query.cursor(),
@ -206,7 +197,7 @@ export class RoomsPage {
},
{ key: 'bytes', header: 'Data up / down', align: 'end' },
{ key: 'features', header: 'Features', info: INFO_TEXTS.roomFeatures },
{ key: 'node', header: 'Node', defaultHidden: true }
...(this.nodeFilter() ? [{ key: 'node', header: 'Node', defaultHidden: true }] : [])
]);
protected readonly trackRoom = (room: RoomListItem): string => room.sid;
@ -264,7 +255,7 @@ export class RoomsPage {
}
protected clearFilters(): void {
void this.query.navigate({ status: null, q: null, features: null, nodeId: null });
void this.query.navigate({ status: null, q: null, features: null, node: null });
}
protected clearSearch(): void {

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

View File

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

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 |
| `--plain-stats` | off | Force the plain-collection fallback for `track_stats` |
| `--five-minute-rollups` | off | Write `analytics_5min` inside the 5-minute rollup window |
| `--node-rollups` | off | Write the `node:<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 |
| `--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 |
| `--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 |
| -------- | ------------- | ---------------- | ----------------- |
@ -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.
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.
- `analytics_hourly` and `analytics_daily` (global scope, plus one scope per node with `--node-rollups`)
aggregated from the generated entities; bytes and quality are spread over the hours each session was connected.
`--room-sums` adds the room size and duration sums; `--five-minute-rollups` adds `analytics_5min` (lifecycle
counters, the `participants.connected` gauge, bytes and quality per five minutes) 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 }`.
- `nodes` (3 nodes) and `schema_meta` (`schema`, `retention`, `capabilities`, `config`).
- `analytics_hourly` and `analytics_daily` (global scope, plus one scope per media node label with
`--media-nodes --node-rollups`) aggregated from the generated entities; bytes and quality are spread over the
hours each session was connected. `--room-sums` adds the room size and duration sums; `--five-minute-rollups`
adds `analytics_5min` (lifecycle counters, the `participants.connected` gauge, bytes and quality per five minutes)
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 }` and `mediaNodes`.
- `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
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 {
/** `analytics_5min` documents inside the 5-minute rollup window. */
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;
/** `rooms.participantsSum` and `rooms.durationMsSum` at room end. */
roomSums: boolean;
@ -745,7 +745,8 @@ export class Rollups {
add(bundle: RoomBundle, windows: DeriveWindows): void {
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.at(
room.createdAt,

View File

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

View File

@ -160,6 +160,8 @@ export interface GenerateOptions {
minActiveRooms?: number;
/** Generated nodes, the last ones, that report no media statistics; none by default. */
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;
}
@ -187,6 +189,10 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
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) {
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 rng = new Rng(options.seed);
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(
rng,
scenario,
@ -224,7 +252,9 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
nodeIds,
retentionEntitiesMs: retentionDays.entities * DAY,
retentionTracksMs: retentionDays.tracks * DAY,
withoutMediaStats: new Set(nodeIds.slice(NODE_COUNT - nodesWithoutMediaStats))
withoutMediaStats,
mediaNodes,
...(restart === undefined ? {} : { restart })
};
const windows: DeriveWindows = {
now,
@ -304,15 +334,34 @@ export async function generate(db: Db, options: GenerateOptions): Promise<Genera
writer.add(COLLECTIONS.analytics5min, doc);
}
const regions = ['eu-west-1', 'eu-west-1', 'us-east-1'];
for (const [i, nodeId] of nodeIds.entries()) {
const mediaNode = mediaNodes.get(nodeId);
writer.add(COLLECTIONS.nodes, {
_id: nodeId,
firstSeenAt: new Date(windowStart - DAY),
firstSeenAt: new Date(restart?.nodeId === nodeId ? restart.at : windowStart - DAY),
lastSeenAt: new Date(now),
serverVersion: '3.10.0',
region: ['eu-west-1', 'eu-west-1', 'us-east-1'][i],
region: regions[i],
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 { 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
@ -25,15 +25,7 @@ async function main(): Promise<void> {
try {
while (!stop.signal.aborted) {
const now = 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 keepLive(db, new Date());
await sleep(intervalMs, undefined, { signal: stop.signal }).catch(() => undefined);
}
} finally {

View File

@ -33,6 +33,7 @@ export interface RoomDoc {
endReason: string | null;
closedBy: 'event' | 'reconciler' | null;
nodeId: string;
mediaNode?: string;
config: { maxParticipants: number; emptyTimeoutMs: number; departureTimeoutMs: number };
participantSids: string[];
activeParticipantSids: string[];
@ -93,6 +94,7 @@ export interface ParticipantDoc {
connectionType: string | null;
region: string;
nodeId: string;
mediaNode?: string;
resumes: number;
client: ClientInfo;
permission: { canPublish: boolean; canSubscribe: boolean; canPublishData: boolean; hidden: boolean };
@ -290,6 +292,10 @@ export interface GeneratorContext {
retentionTracksMs: number;
/** Nodes that report no media statistics: their sessions carry no bytes, quality or `track_stats`. */
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 {
@ -408,7 +414,10 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
const { rng, now } = ctx;
const roomSid = rng.id('RM_');
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 hasIngress = rng.chance(0.02);
const hasAgent = rng.chance(0.05);
@ -420,7 +429,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
? 'reconciler'
: '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 identityCount = Math.min(60, Math.max(1, rng.poisson(ctx.scenario.avgRoomSize)));
const reconnects = rng.poisson(
@ -599,6 +608,7 @@ export function generateRoom(ctx: GeneratorContext, plan: RoomPlan): RoomBundle
endReason,
closedBy,
nodeId,
...(mediaNode === undefined ? {} : { mediaNode }),
config: { maxParticipants: 0, emptyTimeoutMs: 300_000, departureTimeoutMs: DEPARTURE_TIMEOUT_MS },
participantSids: participants.map((p) => p._id),
activeParticipantSids: activeStandard.map((s) => s.doc._id),
@ -680,6 +690,7 @@ interface RoomBase {
roomSid: string;
roomName: string;
nodeId: string;
mediaNode: string | undefined;
createdAt: number;
}
@ -815,6 +826,7 @@ function newSession(
connectionType: aborted ? null : rng.weighted(CONNECTION_TYPES),
region: '',
nodeId: base.nodeId,
...(base.mediaNode === undefined ? {} : { mediaNode: base.mediaNode }),
resumes: aborted ? 0 : rng.poisson(0.2),
client,
permission: {

View File

@ -163,3 +163,18 @@ function toIndexDescription(index: IndexDefinition, retention: RetentionSeconds)
function describe(error: unknown): string {
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
echo "Generating $DATABASE (scenario S, 30 days, $STATS_MODE stats)"
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
fi

View File

@ -6,6 +6,8 @@ import {
CLOSED_ROOM,
EG,
ENTITIES_RETENTION_MS,
MEDIA_NODE_ALPHA,
MEDIA_NODE_BETA,
NODE_ALPHA,
NODE_BETA,
P,
@ -15,10 +17,22 @@ import {
T
} 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 activeRoom: Base = { roomSid: ACTIVE_ROOM.sid, roomName: ACTIVE_ROOM.name, nodeId: NODE_BETA, schemaVersion };
const closedRoom: Base = {
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 recorderPermission = { canSubscribe: true, canPublish: false, canPublishData: false, hidden: true };

View File

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

View File

@ -4,7 +4,7 @@ import type {
HourlyRollupDocument,
TrackStatsDocument
} 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 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] }
},
{
_id: rollupId(`node:${NODE_BETA}`, fiveMinBucket),
scope: `node:${NODE_BETA}`,
_id: rollupId(`node:${MEDIA_NODE_BETA}`, fiveMinBucket),
scope: `node:${MEDIA_NODE_BETA}`,
bucket: fiveMinBucket,
...fiveMinCounters,
internal: { connectedSids: [P.dave, P.ingress2, P.egressStreamer] }
@ -180,8 +180,8 @@ export const analyticsHourly: HourlyRollupDocument[] = [
}
},
{
_id: rollupId(`node:${NODE_ALPHA}`, hourBucket),
scope: `node:${NODE_ALPHA}`,
_id: rollupId(`node:${MEDIA_NODE_ALPHA}`, hourBucket),
scope: `node:${MEDIA_NODE_ALPHA}`,
bucket: hourBucket,
...closedRoomHour
}
@ -240,8 +240,8 @@ export const analyticsDaily: DailyRollupDocument[] = [
uniqueIdentities: 5
},
{
_id: rollupId(`node:${NODE_ALPHA}`, dayBucket),
scope: `node:${NODE_ALPHA}`,
_id: rollupId(`node:${MEDIA_NODE_ALPHA}`, dayBucket),
scope: `node:${MEDIA_NODE_ALPHA}`,
bucket: dayBucket,
...closedRoomHour,
uniqueIdentities: 3

View File

@ -1,5 +1,15 @@
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[] = [
{
@ -9,7 +19,8 @@ export const nodes: NodeDocument[] = [
serverVersion: SERVER_VERSION,
region: REGION,
batchIntervalMs: 10_000,
mediaStats: true
mediaStats: true,
mediaNode: MEDIA_NODE_ALPHA
},
{
_id: NODE_BETA,
@ -18,7 +29,8 @@ export const nodes: NodeDocument[] = [
serverVersion: SERVER_VERSION,
region: REGION,
batchIntervalMs: 10_000,
mediaStats: true
mediaStats: true,
mediaNode: MEDIA_NODE_BETA
}
];

View File

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

View File

@ -49,6 +49,8 @@ import {
} from './schema-builder.js';
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 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(
'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);
@ -125,6 +131,7 @@ const rooms = obj({
lastEventAt: req(date()),
lastStatsAt: date(),
mediaStats: mediaStatsMark,
mediaNode: mediaNodeMark,
internal,
...entityBase
});
@ -196,6 +203,7 @@ const participants = obj({
lastEventAt: req(date()),
lastStatsAt: date(),
mediaStats: mediaStatsMark,
mediaNode: mediaNodeMark,
internal,
...entityBase
});
@ -487,7 +495,9 @@ const nodes = obj({
batchIntervalMs: { ...int('Configured batch interval of the node, in milliseconds'), minimum: 1000 },
mediaStats: bool(
'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] });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,13 @@
"mediaStats": {
"bsonType": "bool",
"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
}

View File

@ -186,6 +186,11 @@
"bsonType": "bool",
"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" },
"schemaVersion": {
"bsonType": ["int", "long"],

View File

@ -158,6 +158,11 @@
"bsonType": "bool",
"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" },
"schemaVersion": {
"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 AnalyticsGranularity = 'auto' | AnalyticsBucketGranularity;
/** `global` or `node:<nodeId>`. */
/** `global` or `node:<media node label>`. */
export type AnalyticsScope = 'global' | `node:${string}`;
export const ANALYTICS_GLOBAL_SCOPE = 'global';

View File

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

View File

@ -39,7 +39,10 @@ export interface RoomListItem {
durationMs?: number;
/** `reconciler` marks an estimated end. */
closedBy?: ClosedBy;
/** Id of the server process that hosts the room. */
nodeId?: string;
/** Media node label of that server; absent when it has none. */
mediaNode?: string;
participants: { sessions: number; unique: number; active: number; peakActive: number };
egress: { total: number; active: number };
ingress: { sessions: number; active: number };
@ -66,5 +69,6 @@ export interface RoomSummary {
export interface RoomsListQuery extends ListQuery<RoomSort> {
status?: RoomStatusFilter;
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 {
/** Newest `lastSeenAt` of the server nodes, ISO 8601. */
lastSeenAt?: string;
nodes: number;
/** Nodes whose last batch is within the fresh window of their own batch interval at `generatedAt`. */
aliveNodes: number;
/** `stoppedAt` of the newest node document when no node is alive: the server stopped gracefully, ISO 8601. */
stoppedAt?: string;
/** Largest batch interval among the nodes seen in the last hour; absent when none reports one (read as 10 s). */
batchIntervalMs?: number;
}

View File

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

View File

@ -1,6 +1,6 @@
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 {
_id: string;
firstSeenAt: Date;
@ -11,6 +11,10 @@ export interface NodeDocument {
batchIntervalMs?: number;
/** Whether the node reports media statistics; absent on documents written before it existed (read as true). */
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. */