openvidu-dashboard: the specs state the current behaviour

Wording that narrated the rebuild or compared with earlier builds is gone, and the statements that drifted from the code follow it: API parameters and fields, image stages, writer collections, reconciler rules, migration batches, UI routes, labels and metadata. The inactive ingress badge stays neutral as specified (a code fix follows).
pull/924/head
juancarmore 2026-10-26 14:26:18 +01:00
parent 26e3900759
commit 54006ce300
28 changed files with 381 additions and 378 deletions

View File

@ -44,14 +44,13 @@ shows only that session ignoring the range and every filter, and Enter opens it
### Requirement: Agents table
Edition: CE. The table SHALL show, in order, SID (participant detail link with copy button), Identity (copy button),
Name, Room (link to the room detail), Status (stale cue), Joined, Left, Duration (live), Published (source icons) and
Node (hidden by default), with no Kind or SDK column and times formatted as on the other lists with the time-zone
header hint; Joined (the default), Left and Duration are sortable; the cursor paginator and the shared skeleton, error
and empty states apply.
Node (hidden by default), with times formatted as on the other lists and the time-zone header hint; Joined (the
default), Left and Duration are sortable; the cursor paginator and the shared skeleton, error and empty states apply.
#### Scenario: Agent row
- **GIVEN** an active agent `agent-1` (session `PA_z9`, name "Support bot") in `demo-room` on node `node-a` publishing a microphone
- **WHEN** the row renders
- **THEN** the cells read, in order, `PA_z9` once, linking to `/participants/PA_z9` with its copy button, `agent-1` with its copy button, "Support bot", `demo-room` linking to `/rooms/RM_abc`, "active", the join time, "—", a duration counting up live and the microphone source icon, no SDK or Kind cell is shown and the Node column is hidden until chosen in the Columns menu
- **THEN** the cells read, in order, `PA_z9` once, linking to `/participants/PA_z9` with its copy button, `agent-1` with its copy button, "Support bot", `demo-room` linking to `/rooms/RM_abc`, "active", the join time, "—", a duration counting up live and the microphone source icon, and the Node column is hidden until chosen in the Columns menu
#### Scenario: Detail links in CE
- **GIVEN** the CE dashboard with `features: []`
@ -75,9 +74,9 @@ and empty states apply.
### Requirement: Live agents counter tile
Edition: CE. The page header SHALL show a KPI tile "Agents now" with the number of active agent sessions
across all rooms, read from the overview live counters and refreshed at the auto-refresh interval, keeping
the last value while refreshing.
Edition: CE. Above the filters, the page SHALL show a KPI tile "Agents now" with the number of active agent sessions
across all rooms, read from the overview live counters and refreshed at the auto-refresh interval, keeping the last
value while refreshing.
#### Scenario: Counter refresh
- **GIVEN** the tile shows 3
@ -100,7 +99,7 @@ collected, with a link to the Participants page filtered by `kind=AGENT`.
### Requirement: Agent sessions tile
Edition: CE. Next to "Agents now", the page header SHALL show a KPI tile "Agent sessions in range" with the agent
Edition: CE. Next to "Agents now", the page SHALL show a KPI tile "Agent sessions in range" with the agent
sessions joined in the global range, read from the participants summary (`sessions.byKind.AGENT`, "≈" and the rollup
tooltip when approximate) and refreshed with the page.

View File

@ -80,8 +80,8 @@ Edition: CE. The CE metrics SHALL be the eighteen of the "CE metrics" scenario;
Edition: CE. Analytics series SHALL accept `granularity` `auto`, `5m`, `15m`, `1h`, `6h` and `1d`, reject `1m`,
read `analytics_5min` for `5m` and `15m`, `analytics_hourly` for `1h` and `6h` and `analytics_daily` for `1d`, and
SHALL never read entity or stats collections. Sub-hour values SHALL require a range of at most 24 h whose `from` is
inside the 5-minute rollup retention. `auto` SHALL resolve to `5m` (≤ 24 h), `1h` (≤ 7 d), `6h` (≤ 90 d), otherwise
`1d`.
inside the 5-minute rollup retention. `auto` SHALL resolve to `5m` (≤ 24 h inside that retention), `1h` (≤ 7 d), `6h`
(≤ 90 d), otherwise `1d`.
#### Scenario: Automatic resolution by range
@ -233,7 +233,7 @@ without ingress activity.
#### Scenario: Summary values
- **GIVEN** hourly documents in range summing to `rooms.created: 120`, `rooms.ended: 110`, `participants.joined:
900`, `participants.active: 880`, `apiCalls.total: 5000`, `apiErrors: 50`
900`, `participants.active: 880`, `apiCalls.total: 5000`, `apiErrors.total: 50`
- **WHEN** the summary is requested
- **THEN** the body has `roomsCreated: 120`, `roomsEnded: 110`, `participantSessions: 900`,
`connectionSuccessRate: 97.8`, `apiCalls: 5000` and `apiErrorRate: 1.0`

View File

@ -43,7 +43,7 @@ while the effective granularity is below `1h` and read "Hourly" in its subtitle.
- **GIVEN** `from=now-7d&to=now&granularity=1d`
- **WHEN** the user opens the selector
- **THEN** "1 d" is selected and the auto option still reads "auto (1 h)"
- **THEN** "1 d" is selected and the auto option reads "auto (1 h)"
#### Scenario: Hourly-only chart on the default range
@ -76,8 +76,7 @@ while the effective granularity is below `1h` and read "Hourly" in its subtitle.
Edition: CE. The tile row SHALL show, in this order, Rooms created, Participant sessions, Unique identities (labelled
"sum of N UTC days"), Data up / down (up and down on two lines, each with its arrow), Connection success, Egress
(count and total duration) and Ingress (sessions and total duration) from the summary endpoint with the shared
formatters, show "n/a" with a hint for a value the summary omits and keep the last value while refreshing; the API
error rate is not shown.
formatters, show "n/a" with a hint for a value the summary omits and keep the last value while refreshing.
#### Scenario: Tiles from the summary
@ -91,7 +90,7 @@ error rate is not shown.
- **WHEN** the tile row renders
- **THEN** the labels read Rooms created, Participant sessions, Unique identities, Data up / down, Connection
success, Egress and Ingress, and no tile reads "API error rate" or "Bandwidth"
success, Egress and Ingress
#### Scenario: Missing average
@ -185,8 +184,8 @@ as tiles.
Edition: CE. The Egress section SHALL show the tiles Total egress and Total duration (the summary's `egressStarted` and
`egressDurationMs`), `egressStarted` as the bar chart "Egress started" beside the "Egress type" donut, and the
`egressEnded.<status>` series as the stacked bar chart "Egress ended by status"; it has no Egress duration chart, and
each chart requested at `1h` while the effective granularity is finer reads the "Hourly" note under its title.
`egressEnded.<status>` series as the stacked bar chart "Egress ended by status"; each chart requested at `1h` while
the effective granularity is finer reads the "Hourly" note under its title.
#### Scenario: Ended by status
@ -199,7 +198,7 @@ each chart requested at `1h` while the effective granularity is finer reads the
- **GIVEN** a summary with `egressStarted: 120` and `egressDurationMs: 36000000`
- **WHEN** the section renders
- **THEN** the tiles read Total egress "120" and Total duration "10 h 00 m", and no chart is titled "Egress duration"
- **THEN** the tiles read Total egress "120" and Total duration "10 h 00 m"
#### Scenario: Status colours
@ -208,18 +207,16 @@ each chart requested at `1h` while the effective granularity is finer reads the
### Requirement: PRO strip and locked cards
Edition: CE. Right after the CE sections, with no divider or label of their own, the page SHALL render the cards
Quality, Diagnostics and Top rooms; when the matching feature is disabled each card SHALL render the compact in-card
lock, sized to its message rather than to the real card, without any request. There is no per-node card: per-node
analytics is the header's scope selector.
Edition: CE. Right after the Ingress section, in the same column of cards as the CE sections, the page SHALL render
the cards Quality, Diagnostics and Top rooms; when the matching feature is disabled each card SHALL render the compact
in-card lock, sized to its message rather than to the real card, without any request.
#### Scenario: Strip in CE
- **GIVEN** the UI loaded `features: []`
- **WHEN** the Analytics page renders
- **THEN** the three PRO cards show the compact lock with their feature title, each only as tall as its message, no
"PRO" divider separates them from the CE sections, and no request to `/analytics/top`, `/analytics/scopes` or a PRO
metric is sent
- **THEN** the three PRO cards follow the Ingress section and show the compact lock with their feature title, each
only as tall as its message, and no request to `/analytics/top`, `/analytics/scopes` or a PRO metric is sent
### Requirement: Diagnostics section
@ -230,8 +227,7 @@ available in OpenVidu PRO".
- **GIVEN** the UI loaded `features: []`
- **WHEN** the card renders
- **THEN** its heading reads "Diagnostics", the lock reads "Diagnostics is available in OpenVidu PRO", and the page
shows no card or lock named "Failures"
- **THEN** its heading reads "Diagnostics" and the lock reads "Diagnostics is available in OpenVidu PRO"
### Requirement: Ingress section
@ -272,9 +268,9 @@ with a lock icon whose tooltip says per-node analytics is available in OpenVidu
### Requirement: CSV export per section
Edition: CE. Every section card SHALL have an "Export CSV" action that downloads the CSV of the card's series or
breakdowns with the current URL parameters through the shared HTTP service, saves it under the server file name and
is disabled on locked cards.
Edition: CE. Every section card except Top rooms SHALL have an "Export CSV" action in its header that downloads the CSV
of the card's series or breakdowns with the current URL parameters through the shared HTTP service, saves it under the
server file name and is disabled on locked cards.
#### Scenario: Bandwidth export

View File

@ -10,7 +10,8 @@ health probes, so that each resource endpoint only specifies what is particular
### Requirement: Base path and resource naming
Edition: CE. Every dashboard API route SHALL live under `/api/v1`, use lower-case plural nouns for resources
(`rooms`, `participants`, `tracks`, `egresses`, `ingresses`, `analytics`, `overview`, `auth`, `info`), nest
(`rooms`, `participants`, `agents`, `tracks`, `egresses`, `ingresses`, `analytics`, `overview`, `status`, `auth`,
`info`), nest
sub-resources at most one level under the parent identifier, and answer every unknown `/api` path with a JSON
`404` instead of the SPA fallback.
@ -153,8 +154,8 @@ for a ranked sort) with `_id` as tie-breaker, and reject with `400` a malformed
#### Scenario: Cursor content
- **WHEN** a list endpoint issues a cursor
- **THEN** it is a base64url string encoding the sort key and `_id` of the boundary item plus the direction, and for a
ranked sort the `asOf` time of its first page
- **THEN** it is a base64url string encoding the sort value and `_id` of the boundary item, the `sort` and `order` it
was issued for and the direction, plus the `asOf` time of its first page for a ranked sort
### Requirement: Optional total count
@ -196,11 +197,11 @@ absent, and SHALL reject any other value with `400`.
### Requirement: Search and enum filters
Edition: CE. List endpoints SHALL accept `q` (trimmed, 1 to 100 characters) as a case-insensitive prefix match
Edition: CE. List endpoints SHALL accept `q` (trimmed, 2 to 100 characters) as a case-insensitive prefix match
over the searchable fields the resource spec declares, except a `q` that matches the list's entity ID pattern, which
follows the exact-ID search; `status` as one of the resource's status values and `kind` as a comma-separated list of
the resource's kind values; empty `q` SHALL be ignored and an unknown `status` or `kind` value SHALL be rejected with
`400`.
follows the exact-ID search whatever its length; `status` and `kind` take the resource's values, as a single value or
a comma-separated list as the resource spec declares; empty `q` SHALL be ignored and an unknown `status` or `kind`
value SHALL be rejected with `400`.
#### Scenario: Prefix search is case-insensitive
@ -235,7 +236,8 @@ Edition: CE. Every list endpoint SHALL respond `200` with `{ items, nextCursor,
Edition: CE. Every time-series endpoint SHALL respond `200` with
`{ granularity, from, to, series: [{ name, points: [[ts, value]] }] }` where `from`/`to` are the resolved
range in UTC, `granularity` is the applied bucket size, `ts` is the bucket start as an ISO 8601 UTC string and
every series is dense over the range (a bucket without data holds `0` for counters and `null` for averages).
every series is dense over the range (a bucket without data holds `0` for counters and `null` for averages; a UTC day
whose `uniqueIdentities` the reconciler has not computed yet holds `null`).
#### Scenario: Dense series
@ -305,8 +307,8 @@ request SHALL include that id.
Edition: CE. The API SHALL expose an OpenAPI 3.1 document generated from the same schemas that validate
requests and responses, covering every `/api/v1` route the edition implements, at `GET /api/v1/docs/openapi.json` with a
browsable page at `GET /api/v1/docs`; both SHALL be served only in development mode and answer `404` in
production.
browsable page at `GET /api/v1/docs`; both SHALL be served only when `NODE_ENV` is not `production` and answer `404`
otherwise.
#### Scenario: Development mode
@ -347,7 +349,7 @@ keeps working for at least one minor release.
### Requirement: Legacy health check
Edition: CE. `GET /api/healthcheck` SHALL answer `200` `{ status: 'ok' }` without authentication and without
touching MongoDB, so existing container health checks and installers keep working.
touching MongoDB, as the image health check and the health checks of the OpenVidu 3.9 deployments expect.
#### Scenario: MongoDB down
@ -513,6 +515,7 @@ answer `400` with code `SORT_RANGE_TOO_WIDE`; an exact-ID search ignores the sor
- **THEN** the response is `400` with code `SORT_RANGE_TOO_WIDE`
#### Scenario: Exact ID with a ranked sort
- **WHEN** the client sends `GET /api/v1/rooms?q=RM_abc&sort=duration&from=now-30d&to=now`
- **THEN** the response is `200` with the room `RM_abc` as the only item

View File

@ -105,7 +105,7 @@ algorithm and expiry on every protected request and SHALL NOT keep a revocation
### Requirement: Protected routes
Edition: CE. Every `/api/v1` route SHALL require `Authorization: Bearer <accessToken>` except the auth endpoints
`login`, `refresh` and `logout`, `health`, `ready`, the public subset of `info` and the development-only docs;
`login`, `refresh` and `logout`, `health`, `ready`, the public subset of `info` and the OpenAPI docs routes;
`GET /api/healthcheck` and the SPA assets SHALL also be public. A missing, malformed, invalid or expired token SHALL
answer `401` `UNAUTHORIZED` with `details.reason` `missing`, `invalid` or `expired` and `WWW-Authenticate: Bearer`.
@ -207,7 +207,7 @@ expired or belongs to a revoked family.
- **GIVEN** a live refresh token
- **WHEN** the client posts to refresh with it
- **THEN** the response is `200` with a new access token and a different refresh cookie, and the presented token is
no longer live
marked as rotated
#### Scenario: No cookie
@ -231,8 +231,8 @@ family id.
- **GIVEN** a refresh token rotated 5 minutes ago
- **WHEN** it is presented again
- **THEN** the response is `401`, the cookie is cleared, every token of the family is revoked and the successor token
issued 5 minutes ago no longer works
- **THEN** the response is `401`, the cookie is cleared, every token of the family is revoked and a refresh with the
successor token issued 5 minutes ago answers `401`
#### Scenario: Two tabs refreshing at once

View File

@ -60,7 +60,8 @@ test.
### Requirement: Integration test job with a real MongoDB
Edition: CE. A job SHALL run `./dashboard.sh test-integration`, which starts a MongoDB 8 replica set with
Testcontainers. The job SHALL fail, not skip, when Docker is unavailable on the runner.
Testcontainers. The job SHALL fail, not skip, when Docker is unavailable on the runner. Latency budget overruns are
reported as warnings (`DASHBOARD_TEST_LATENCY_BUDGETS=report`), while query plan assertions fail the job.
#### Scenario: Docker available
- **GIVEN** a runner with a working Docker daemon
@ -74,8 +75,9 @@ Testcontainers. The job SHALL fail, not skip, when Docker is unavailable on the
### Requirement: End-to-end job
Edition: CE. A job SHALL run `./dashboard.sh e2e`: build the application, seed a MongoDB with fixtures, run the
Playwright suites against it, and upload the Playwright reports and test results as one artifact on failure.
Edition: CE. A job SHALL run `./dashboard.sh e2e`: build the application, run the Playwright suite against a mocked API
and the real-backend suite against the backend on a MongoDB seeded with fixtures, and upload the Playwright reports and
test results as one artifact on failure.
#### Scenario: Report on failure
- **GIVEN** a failing Playwright test
@ -97,8 +99,8 @@ rejects a fixture or the schema versions differ.
### Requirement: Dependency update automation
Edition: CE. Dependabot SHALL watch the npm ecosystem of `/openvidu-dashboard` weekly, grouping minor and patch
updates and ignoring `@types/node` major updates while the dashboard runs on Node.js 24, in addition to the existing
`github-actions` entry.
updates and ignoring `@types/node` major updates while the dashboard runs on Node.js 24, next to the weekly
`github-actions` entry of the repository.
#### Scenario: Weekly update
- **WHEN** a dependency in the workspace catalog has a new patch version

View File

@ -28,11 +28,6 @@ version with the one it supports and refuse to serve data when the writer is old
- **WHEN** the dashboard starts
- **THEN** it serves data and `GET /api/v1/info` reports the mismatch as a warning
#### Scenario: Contract change before the version ships
- **GIVEN** schema version 1, not yet shipped in a release
- **WHEN** the contract gains `nodes.batchIntervalMs`, `egresses.roomNameLower` and the indexes `roomNameLower_startedAt` and `lastSessionAt_id`
- **THEN** `SCHEMA_VERSION` stays 1 on both sides and the writer records `version: 1` in `schema_meta`
### Requirement: Writer metadata documents
Edition: CE and PRO. Besides `schema`, `schema_meta` SHALL hold `retention`, `capabilities` and `config`, which the
writer replaces at every initialization, `migration` (`server-legacy-migration`) and `reconciler`
@ -87,7 +82,7 @@ fields, with the field list of the shared contract (`dashboard-ce/typings/src/da
#### Scenario: Fields of a room document
- **WHEN** a room document is read
- **THEN** it has `name`, `nameLower`, `status` (`active` | `closed`), `createdAt`, `endedAt`, `durationMs`, `endReason`, `closedBy`, `nodeId`, `config` (max participants, empty and departure timeouts, metadata when allowed), `participants { sessions, unique, active, peakActive, aborted, byKind }`, `tracks`, `egress` (by type), `ingress`, `bytes { up, down }`, `quality { scoreSum, scoreCount, minScore }`, `features { egress, ingress, agent, sip }`, `lastEventAt`, `lastStatsAt`, `schemaVersion`, `expiresAt`
- **THEN** it has `name`, `nameLower`, `status` (`active` | `closed`), `createdAt`, `endedAt`, `durationMs`, `endReason`, `closedBy`, `nodeId`, `config` (max participants, empty and departure timeouts, metadata when allowed), `participants { sessions, unique, active, peakActive, aborted, byKind }`, `tracks`, `egress` (by type), `ingress`, `bytes { up, down }`, `quality { scoreSum, scoreCount, minScore, poor, lost }`, `features { egress, ingress, agent, sip }`, `lastEventAt`, `lastStatsAt`, `schemaVersion`, `expiresAt`
#### Scenario: Room lifecycle
- **GIVEN** `ROOM_CREATED`, three `PARTICIPANT_JOINED`/`ACTIVE`, one `PARTICIPANT_LEFT`, `ROOM_ENDED (API_DELETE)`
@ -111,7 +106,7 @@ traffic, quality and retention fields, with the field list of the shared contrac
#### Scenario: Fields of a participant document
- **WHEN** a participant document is read
- **THEN** it has `identity`, `identityLower`, `name`, `roomSid`, `roomName`, `kind`, `kindDetails`, `status` (`joining` | `active` | `left` | `aborted`), `joinedAt`, `activeAt`, `leftAt`, `durationMs`, `disconnectReason`, `closedBy`, `connectTimeMs`, `connectionType`, `region`, `nodeId`, `resumes`, `client { sdk, version, os, osVersion, browser, browserVersion, deviceModel, network, address }`, `permission`, `isPublisher`, `tracks { published, bySource, subscribed, subscribeFailures }`, `bytes`, `quality`, `schemaVersion`, `expiresAt`
- **THEN** it has `identity`, `identityLower`, `name`, `roomSid`, `roomName`, `kind`, `kindDetails`, `status` (`joining` | `active` | `left` | `aborted`), `joinedAt`, `activeAt`, `leftAt`, `durationMs`, `disconnectReason`, `closedBy`, `connectTimeMs`, `connectionType`, `region`, `nodeId`, `resumes`, `client { sdk, version, os, osVersion, browser, browserVersion, deviceModel, network, address }`, `permission`, `isPublisher`, `metadata` and `attributes` (when allowed), `tracks { published, bySource, subscribed, subscribeFailures }`, `bytes`, `quality`, `lastEventAt`, `lastStatsAt`, `schemaVersion`, `expiresAt`
#### Scenario: Connection aborted
- **GIVEN** `PARTICIPANT_JOINED` followed by `PARTICIPANT_CONNECTION_ABORTED`
@ -135,7 +130,7 @@ describe one (track, subscriber) pair (references, lifecycle, error, final subsc
#### Scenario: Fields of track and subscription documents
- **WHEN** a track and a subscription document are read
- **THEN** the track has `roomSid`, `participantSid`, `identity`, `type`, `source`, `mimeType`, `name`, `status` (`published` | `unpublished`), `publishedAt`, `unpublishedAt`, `durationMs`, `muted`, `width`, `height`, `simulcast`, `codecs[]`, `subscribers { current, total, failed }`, `publishStats`; and the subscription has `trackSid`, `roomSid`, `subscriberSid`, `subscriberIdentity`, `publisherSid`, `publisherIdentity`, `source`, `status` (`requested` | `subscribed` | `unsubscribed` | `failed`), `error`, `subscribedAt`, `unsubscribedAt`, `durationMs`, `subscribeStats`
- **THEN** the track has `roomSid`, `participantSid`, `identity`, `type`, `source`, `mimeType`, `name`, `status` (`published` | `unpublished`), `publishedAt`, `unpublishedAt`, `durationMs`, `closedBy`, `muted`, `width`, `height`, `simulcast`, `codecs[]`, `encryption`, `subscribers { current, total, failed }`, `publishStats`; and the subscription has `trackSid`, `roomSid`, `subscriberSid`, `subscriberIdentity`, `publisherSid`, `publisherIdentity`, `source`, `status` (`requested` | `subscribed` | `unsubscribed` | `failed`), `error`, `subscribedAt`, `unsubscribedAt`, `durationMs`, `closedBy`, `subscribeStats`
#### Scenario: Track closed by participant leave
- **GIVEN** a published track whose `TRACK_UNPUBLISHED` was never emitted
@ -228,11 +223,11 @@ types.
### Requirement: Track statistics time series
Edition: CE and PRO. `track_stats` SHALL hold one document per 30-second flush per participant and
direction (`participant` mode) or per track and direction (`track` mode), with normalized units, and
signal/data pseudo-tracks SHALL be folded into participant byte totals instead of being stored as tracks.
signal/data pseudo-tracks SHALL only add to the participant byte totals, without `track_stats` documents.
#### Scenario: Fields of a stats document
- **WHEN** a stats document is read
- **THEN** it has `ts`, `meta { roomSid, participantSid, trackSid (track mode only), kind (`up` | `down`), nodeId }`, `score`, `minScore`, `medianScore`, `bytes`, `packets`, `packetsLost`, `rttMs`, `jitterMs`, `nacks`, `plis`, `firs`, `frames`, `layers[] { layer, bytes, packets, frames }`, `mimeType`
- **THEN** it has `ts`, `meta { roomSid, participantSid, trackSid (track mode only), kind (`up` | `down`), nodeId }`, `score`, `minScore`, `medianScore`, `bytes`, `packets`, `packetsLost`, `rttMs`, `jitterMs`, `nacks`, `plis`, `firs`, `frames`, `layers[] { layer, bytes, packets, frames }`, `mimeType` (track mode only)
#### Scenario: Participant mode merges tracks
- **GIVEN** `participant` mode and one flush with camera and microphone upstream stats of the same participant
@ -246,13 +241,14 @@ signal/data pseudo-tracks SHALL be folded into participant byte totals instead o
### Requirement: Rollups
Edition: CE and PRO. `analytics_5min`, `analytics_hourly` and `analytics_daily` SHALL hold one document per
scope (`global` and `node:<nodeId>`) and bucket with pre-aggregated counters, so that analytics over long
ranges never read entity or stats documents. Map keys SHALL be normalized enum names, never user input. Every
participant counter SHALL count STANDARD sessions only, except `participants.byKind`, which counts every kind.
scope (`global` and `node:<nodeId>`) and bucket with pre-aggregated counters, so that long ranges never read
entity or stats documents. Map keys SHALL be enum names or a fixed vocabulary, never user input. Every
participant counter SHALL count STANDARD sessions only, except `participants.byKind` and the 5-minute
`participants.connected` (participants with stats), which count every kind.
#### Scenario: Fields of the rollup documents
- **WHEN** hourly or daily rollup documents are read
- **THEN** they have counters for rooms (created, ended, by end reason, `durationMsSum`, `participantsSum`), participants (joined, active, aborted, left, by kind, SDK, OS, browser, connection type, disconnect reason, connect time, minutes), tracks, egress (started, by type, by end status, duration), ingress sessions (started, ended, started by input type, duration), bytes, quality (score sum/count, poor, lost), API calls by method and errors by method, and daily documents also `uniqueIdentities` (set by the server's reconciler once the UTC day has closed); 5-minute documents have bytes, quality, the active-participants gauge, the `PARTICIPANT_ACTIVE` count and the lifecycle counters (rooms created/ended, participants joined/left/aborted, egress started/ended)
- **THEN** they have counters for rooms (created, ended, by end reason, `durationMsSum`, `participantsSum`), participants (joined, active, aborted, left, by kind, SDK, OS, browser, connection type, disconnect reason, connect time, minutes), tracks, egress (started, by type, by end status, duration), ingress sessions (started, ended, started by input type, duration), bytes, quality (score sum/count, poor, lost), API calls and API errors (total and by method), and daily documents also `uniqueIdentities` (set by the server's reconciler once the UTC day has closed); 5-minute documents have bytes, quality, `participants.connected` (distinct participants with stats), the `PARTICIPANT_ACTIVE` count and the lifecycle counters (rooms created/ended, participants joined/left/aborted, egress started/ended)
#### Scenario: Five-minute bandwidth
- **GIVEN** stats flushed between 10:00 and 10:05 UTC
@ -291,7 +287,7 @@ heartbeat so decommissioned nodes disappear. A document without `batchIntervalMs
- **THEN** its document has `lastSeenAt` within the last batch interval and `batchIntervalMs: 10000`
#### Scenario: Node document without the interval
- **GIVEN** a `nodes` document without `batchIntervalMs`, written to a development database before the field existed
- **GIVEN** a `nodes` document without `batchIntervalMs`
- **WHEN** the dashboard derives that node's freshness windows
- **THEN** it uses a 10 000 ms interval
@ -334,7 +330,7 @@ and the rollups expire by TTL indexes.
#### Scenario: Active entity never expires
- **GIVEN** a room active for longer than the entities retention
- **WHEN** the TTL monitor runs
- **THEN** the room document still exists
- **THEN** the room document exists
#### Scenario: Retention change applies to existing data
- **GIVEN** a deployment that changes `retention.events` from 7 d to 30 d
@ -344,7 +340,8 @@ and the rollups expire by TTL indexes.
### Requirement: Secrets and personal data
Edition: CE and PRO. No document SHALL contain `turn_password`, storage credentials, stream keys, signing
keys, tokens or SIP credentials. Client IP addresses SHALL be stored only when `anonymize_ip` is `false`, and
participant metadata, attributes and room metadata SHALL be stored only when `store_metadata` is enabled.
participant metadata, attributes, room metadata and ingress participant metadata SHALL be stored only when
`store_metadata` is enabled.
#### Scenario: Anonymized IP
- **GIVEN** the default configuration
@ -403,7 +400,7 @@ range on `startedAt`. An egress document without `roomNameLower` is not matched
- **THEN** `roomNameLower_startedAt` exists with the key `{ roomNameLower: 1, startedAt: -1 }` and the partial filter `{ roomNameLower: { $exists: true } }`
#### Scenario: Document without the search field
- **GIVEN** an egress document of room `demo-room` without `roomNameLower`, written to a development database before the field existed
- **GIVEN** an egress document of room `demo-room` without `roomNameLower`
- **WHEN** the egress list is searched by the room name `demo`
- **THEN** that egress is not returned, and egresses of the same room that carry `roomNameLower` are

View File

@ -90,8 +90,7 @@ that MongoDB is disabled and exit with code 0 before connecting to anything; uns
### Requirement: TLS certificate authority for external MongoDB
Both editions. When `DATABASE_TLS_CA_FILE` is set, the dashboard SHALL connect with TLS and trust the PEM
bundle at that path (for services such as Amazon DocumentDB). The variable keeps the `DATABASE_` prefix for
consistency with `DATABASE_URL` and `DATABASE_NAME`; it is the one new variable not prefixed `DASHBOARD_`.
bundle at that path (for services such as Amazon DocumentDB).
#### Scenario: Private CA accepted
- **GIVEN** a MongoDB server presenting a certificate signed by a private CA and `DATABASE_TLS_CA_FILE`
@ -151,13 +150,13 @@ and exit with code 1.
- **WHEN** an analytics request is repeated
- **THEN** each request aggregates from MongoDB and returns the same body as with the cache enabled
### Requirement: Naming rule for new variables
Both editions. Every configuration variable introduced by the rebuild SHALL be prefixed `DASHBOARD_`, except
### Requirement: Naming rule for dashboard variables
Both editions. Every configuration variable that OpenVidu 3.9 does not read SHALL be prefixed `DASHBOARD_`, except
database connection variables, which SHALL be prefixed `DATABASE_`.
#### Scenario: Configuration schema audit
- **WHEN** the set of variable names read by the configuration is listed
- **THEN** every name is in the legacy list, starts with `DASHBOARD_` or starts with `DATABASE_`
- **THEN** every name is read by OpenVidu 3.9, starts with `DASHBOARD_` or starts with `DATABASE_`
### Requirement: HTTP contract owned by the API conventions
Both editions. The health endpoint (`GET /api/healthcheck`), `HEAD /`, the SPA fallback (including the unstripped
@ -173,8 +172,8 @@ capability; this capability adds no second definition.
### Requirement: Startup order
Edition: CE. Startup SHALL proceed in this order: load configuration, ping MongoDB, check the writer schema version,
listen. A failure at any step SHALL exit without listening and log its cause; the exit code is 1 for configuration and
MongoDB failures.
provision the signing key and the session indexes, listen. A failure at any step SHALL exit without listening and log
its cause; the exit code is 3 for a writer schema older than the dashboard's minimum and 1 for any other failure.
#### Scenario: MongoDB unreachable

View File

@ -118,9 +118,8 @@ subset.
Edition: CE. `features` SHALL be an array of `FeatureKey` identifiers from a shared enumeration (`room-detail`,
`participant-detail`, `egress-detail`, `ingress-detail`, `analytics-quality`, `analytics-diagnostics`,
`analytics-top-rooms`, `track-detail`, `per-node-breakdown`, `license-info`), empty on CE; new PRO features are added to
it by the change that introduces them, and it contains neither `analytics-failures`, `analytics-ingress` nor
`agents-page`.
`analytics-top-rooms`, `track-detail`, `per-node-breakdown`, `license-info`), empty on CE; every PRO feature has its
identifier in it.
#### Scenario: CE feature list
@ -135,8 +134,7 @@ it by the change that introduces them, and it contains neither `analytics-failur
### Requirement: CE performs no license check
Edition: CE. The CE dashboard SHALL ignore every `OPENVIDU_PRO_*` variable, SHALL make no licensing request
and SHALL NOT depend on the private licensing package.
Edition: CE. The CE dashboard SHALL ignore every `OPENVIDU_PRO_*` variable and SHALL make no licensing request.
#### Scenario: CE with an invalid license variable
@ -185,9 +183,9 @@ identifier, after authentication, so that a direct API call never falls through
### Requirement: UI locks from features
Edition: CE. The UI SHALL decide the locked state of PRO routes and sections from the `features` of the
authenticated `GET /api/v1/info`, reloaded after every login, against the `requiredFeature` declared in route and
section metadata; the locked presentation SHALL be rendered without calling the PRO endpoint and SHALL never
depend on receiving a `403`. Lock visuals and the handling of a stray `403` belong to the UI shell.
authenticated `GET /api/v1/info`, reloaded after every login, against the `feature` declared in the page metadata of
a route and on each gated section; the locked presentation SHALL be rendered without calling the PRO endpoint and
SHALL never depend on receiving a `403`. Lock visuals and the handling of a stray `403` belong to the UI shell.
#### Scenario: Locked route on CE

View File

@ -147,8 +147,8 @@ returns at most one item.
### Requirement: Time range on start time
Edition: CE. The egresses list SHALL apply `from`/`to` to `startedAt` for every sort key and order, so that an egress
that started before `from` and is still active is not listed unless found by `room` within range or by exact id.
Edition: CE. The egresses list SHALL apply `from`/`to` to `startedAt` for every sort key, order and filter, so that an
egress that started before `from` is not listed, even while it is active, unless it is looked up by exact id.
#### Scenario: Long-running egress outside the range
@ -188,7 +188,8 @@ and hour-aligned sums of the hourly rollups with `approximate: true` beyond it.
### Requirement: Index-backed egress queries
Edition: CE and PRO. Every query of this capability SHALL run on an index of the shared contract
(`dashboard-ce/typings/contract/indexes.ts`): no winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
(`dashboard-ce/typings/contract/indexes.ts`), or on `_id` for the hourly rollups the long-range summary reads: no
winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
by the page size plus the documented residual filters for the index-ordered pages and by the egresses started in the
range for the ranked sorts.

View File

@ -42,7 +42,7 @@ the ranked-sort limit, which falls back to `startedAt`.
#### Scenario: Defaults
- **WHEN** `/egress` opens without query parameters
- **THEN** it uses `status=all`, `sort=startedAt`, `order=desc`, the global range and the stored page size
- **THEN** it uses every status and type, `sort=startedAt`, `order=desc`, the global range and the stored page size
#### Scenario: Any state change drops the cursor
@ -63,7 +63,7 @@ last value while refreshing.
- **GIVEN** the summary answers `{ active: 3, startedInRange: 120, byType: { track: 20, room_composite: 100 },
byEndStatus: { complete: 110, failed: 7, aborted: 2 }, totalDurationMs: 36000000, approximate: false }`
- **WHEN** the page renders
- **THEN** the tiles read 3, 120 with hint "Room composite 100 · Track 20", 7 with hint "aborted 2", and "10 h 00 m"
- **THEN** the tiles read 3, 120 with hint "Room composite 100 · Track 20", 7 with hint "Aborted 2", and "10 h 00 m"
#### Scenario: Stable hint order
@ -74,10 +74,10 @@ last value while refreshing.
### Requirement: Status, type, room and search filters
Edition: CE. The Egress page SHALL offer, in this order, the shared list search box labelled "Room name or egress ID"
(a room-name prefix or an exact egress ID, with the shared hints, clear button and debounce), a type select (All plus
the five types with their labels), a status select (All, Active, Complete, Failed, Aborted, Limit reached; Active
writes `starting,active,ending`) and, when `room` is set, the shared room chip; filters are written to the URL on
change.
(a room-name prefix or an exact egress ID), a multi-select type filter (the five types with their labels), a
multi-select status filter (Active, Complete, Failed, Aborted, Limit reached; Active writes `starting,active,ending`),
both reading "All" with nothing selected, and, when `room` is set, the shared room chip; filters are written to the URL
on change.
#### Scenario: Filter order
@ -228,10 +228,10 @@ with retry and the request id.
### Requirement: Egress detail route and CE lock
Edition: CE. `/egress/:egressId` SHALL be registered in CE with `requiredFeature: "egress-detail"`, read `tz`, `order`,
`cursor` and `limit` from the URL, and in CE render the full-page lock over a dimmed silhouette of three tiles and one
block, under the breadcrumbs and a header holding only the title (the egress ID) with its copy button and the status
badge with its estimated cue; the header shows no refresh control and no PRO endpoint is called.
Edition: CE. `/egress/:egressId` SHALL be registered in CE as a timed route that is not range-scoped and declares the
feature `egress-detail`, and in CE render the full-page lock over a dimmed silhouette of three tiles and one block,
under the breadcrumbs and a header holding only the title (the egress ID) with its copy button and the status badge
with its estimated cue; the header shows no refresh control and no PRO endpoint is called.
#### Scenario: Locked from the list
@ -306,7 +306,7 @@ Started SHALL stay sortable for any range.
- **GIVEN** `from=now-30d&to=now` and the default limit
- **WHEN** the user hovers or focuses the Duration header
- **THEN** the header is disabled and its tooltip reads "Narrow the range to <n> to sort by Duration", with <n>
naming the 7-day limit, while the Started header still toggles the order
naming the 7-day limit, while the Started header toggles the order
#### Scenario: Range narrowed back
@ -317,15 +317,14 @@ Started SHALL stay sortable for any range.
### Requirement: Egress room-name search
Edition: CE. A search text of at least two characters that is not an egress ID SHALL list, through `q`, the egresses
of the range whose room name starts with that text, case-insensitively, with no limit on the number of matching rooms
and no hint about one.
of the range whose room name starts with that text, case-insensitively, with no limit on the number of matching
rooms.
#### Scenario: Prefix matching many rooms
- **GIVEN** 150 rooms whose name starts with `load-`, each with an egress started in the range
- **WHEN** the user types `LOAD-`
- **THEN** after the debounce the URL has `q=LOAD-`, the paginator walks through the egresses of all 150 rooms, and no
hint about the newest matching rooms is shown
- **THEN** after the debounce the URL has `q=LOAD-` and the paginator walks through the egresses of all 150 rooms
#### Scenario: Prefix only

View File

@ -43,7 +43,7 @@ definition facts, lifecycle and the aggregate session counters. `schemaVersion`,
#### Scenario: Fields of an item
- **WHEN** an item is read
- **THEN** it has `ingressId`, `name`, `inputType` (`rtmp` | `whip` | `url`), `roomName`, `participantIdentity`,
- **THEN** it has `ingressId`, `name?`, `inputType` (`rtmp` | `whip` | `url`), `roomName`, `participantIdentity`,
`status` (`inactive` | `buffering` | `publishing` | `error` | `complete` | `deleted`), `enabled`, `reusable`,
`createdAt`, `deletedAt?`, `sessions`, `totalDurationMs`, `lastSessionAt?` and `hasError`, and no `url`,
`streamKey`, `participantName`, `schemaVersion` or `expiresAt`
@ -218,7 +218,8 @@ boundaries.
### Requirement: Index-backed ingress queries
Edition: CE and PRO. Every query of this capability SHALL run on an index of the shared contract
(`dashboard-ce/typings/contract/indexes.ts`): no winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
(`dashboard-ce/typings/contract/indexes.ts`), or on `_id` for the hourly rollups the long-range summary reads: no
winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
by the page size plus the documented residual filters, and for the list by the ingresses of the three branches of the
activity range.

View File

@ -42,7 +42,7 @@ wider than the ranked-sort limit, which falls back to `createdAt`.
#### Scenario: Defaults
- **WHEN** `/ingress` opens without query parameters
- **THEN** it uses `status=all`, every input type, `sort=createdAt`, `order=desc`, the global range and the stored page
- **THEN** it uses every status and input type, `sort=createdAt`, `order=desc`, the global range and the stored page
size
#### Scenario: Any state change drops the cursor
@ -63,7 +63,7 @@ carry a tooltip saying the numbers are hour-aligned; tiles keep their value whil
sessionsInRange: 12, totalDurationMs: 21600000, approximate: true }`
- **WHEN** the tiles render
- **THEN** they read "Publishing now 2", "Ingress in range 5" with hint "RTMP 3 · WHIP 2 · URL 0", "Sessions in
range 12" and "Total duration 6 h 00 m", the last two with the approximate tooltip, and no tile reads "Ingresses"
range 12" and "Total duration 6 h 00 m", the last two with the approximate tooltip
#### Scenario: Failed list keeps the tiles
@ -96,8 +96,8 @@ bound to `status`; both write the URL on change and show their current value fro
### Requirement: Ingress search
Edition: CE. The Ingress page SHALL offer the shared list search box labelled "Ingress name or ID" for an ingress name
prefix or an `IN_` ID, which writes `q` after a 300 ms debounce, shows the hint "Type at least 2 characters" instead
of searching on one character, shows the hint "Exact ID: range and filters ignored · Enter opens it" on an exact
prefix or an `IN_` ID, which writes `q` after a 300 ms debounce, shows the hint "Type at least 2 characters" and
writes nothing on one character, shows the hint "Exact ID: range and filters ignored · Enter opens it" on an exact
`IN_` ID, and opens that ingress with the current range when Enter is pressed on it.
#### Scenario: Prefix search
@ -249,10 +249,10 @@ with retry and request id while keeping the previous rows when a load fails.
### Requirement: Ingress detail route and CE lock
Edition: CE. `/ingress/:ingressId` SHALL be registered in CE with `requiredFeature: "ingress-detail"`, read `tz`,
`order`, `cursor`, `limit` and `types` from the URL, and in CE render the full-page lock over a dimmed silhouette of
three tiles and one block, under the breadcrumbs and a header holding only the title (the name, or the ID without one)
with its copy button and the status badge; the header has no refresh control and no PRO endpoint is called.
Edition: CE. `/ingress/:ingressId` SHALL be registered in CE as a timed route that is not range-scoped and declares the
feature `ingress-detail`, and in CE render the full-page lock over a dimmed silhouette of three tiles and one block,
under the breadcrumbs and a header holding only the title (the name, or the ID without one) with its copy button and
the status badge; the header has no refresh control and no PRO endpoint is called.
#### Scenario: Locked from the list
@ -340,4 +340,4 @@ sort by <column>"; Created SHALL stay sortable for any range.
- **GIVEN** `from=now-30d&to=now` and the default limit
- **WHEN** the user hovers or focuses the Sessions header
- **THEN** the header is disabled and its tooltip reads "Narrow the range to <n> to sort by Sessions", with <n>
naming the 7-day limit, while the Created header still toggles the order
naming the 7-day limit, while the Created header toggles the order

View File

@ -1,8 +1,8 @@
# overview-api Specification
## Purpose
The overview endpoint: one cheap, index-backed response with the live counters, today's totals, recent rooms,
stale hints and writer health that the Overview page and the live counter tiles poll.
The overview endpoint: one cheap, index-backed response with the live counters, today's totals, recent rooms and
stale hints that the Overview page and the live counter tiles poll.
## Requirements
@ -14,7 +14,7 @@ Edition: CE. `GET /api/v1/overview` SHALL require a bearer token, accept only `t
#### Scenario: Default response
- **GIVEN** a deployment with a running writer
- **WHEN** the client requests `GET /api/v1/overview` with a valid token
- **THEN** the response is `200` with `generatedAt` within one second of the server clock and the keys `live`, `today` and `recentRooms`, no `health` key, and no `nodes` key on CE
- **THEN** the response is `200` with `generatedAt` within one second of the server clock and the keys `live`, `today` and `recentRooms`, and no `nodes` key on CE
#### Scenario: Unknown timezone
- **WHEN** the client requests `GET /api/v1/overview?tz=Mars/Olympus`
@ -132,7 +132,7 @@ Edition: CE. The response SHALL carry a weak `ETag` derived from the body withou
### Requirement: Index-backed overview
Edition: CE. Every overview query SHALL use an existing index (`rooms { status, createdAt }`,
Edition: CE. Every overview query SHALL use an index of the shared contract (`rooms { status, createdAt }`,
`participants { status, kind }`, `egresses { status, startedAt }`, `ingress_sessions { status }`,
`analytics_hourly { scope, bucket }`), examining at most each collection's active documents plus 20 rooms; `nodes`,
bounded by the node count, is read whole for the stale window's batch interval; `schema_meta` is not read.

View File

@ -31,7 +31,7 @@ is not part of its link.
#### Scenario: Tile values and links
- **GIVEN** CE, and `live` with `rooms: 12`, `participants: 85`, `publishers: 54`, `participantsByKind` `{ EGRESS: 3, AGENT: 2 }`, `egresses: 3` and `ingressSessions: 1`
- **WHEN** the tiles render
- **THEN** they read 12, 85, 54, 3, 1 and 2, no Services tile is shown, and they link to `/rooms?status=active`, `/participants?status=active`, `/participants?status=active&publisher=true`, `/egress?status=active`, `/ingress?status=publishing` and `/agents?status=active`
- **THEN** they read 12, 85, 54, 3, 1 and 2, and they link to `/rooms?status=active`, `/participants?status=active`, `/participants?status=active&publisher=true`, `/egress?status=active`, `/ingress?status=publishing` and `/agents?status=active`
#### Scenario: Refresh keeps values
- **GIVEN** the tiles showing the last response
@ -57,7 +57,7 @@ responses the state does not change.
#### Scenario: Long interval
- **GIVEN** the auto-refresh at 5 min and status responses every 30 s whose writer batch is 5 s old
- **WHEN** four minutes pass after the last overview response
- **THEN** the chip still reads "LIVE" and no banner says that no data was received
- **THEN** the chip reads "LIVE" and no banner says that no data was received
#### Scenario: Auto-refresh off
- **GIVEN** the auto-refresh set to Off and a fresh writer
@ -132,8 +132,8 @@ and a "View all rooms" link while there are recent rooms.
### Requirement: Stale entities hint
Edition: CE. When two consecutive overview responses, a `304` included, have
`live.stale.rooms + live.stale.participants > 0`, the page SHALL show the shared warning notice under the tiles naming
both counts and stating that they have had no stats for over `live.stale.olderThanMs` and may be closed by the server
reconciler, with a link to `/rooms?status=active`; the notice SHALL disappear with the first response whose counts add
both counts and stating that they have had no stats for over `live.stale.olderThanMs` (in seconds) and may be stale,
with a link to `/rooms?status=active`; the notice SHALL disappear with the first response whose counts add
up to `0`.
#### Scenario: Hint shown
@ -159,14 +159,14 @@ up to `0`.
### Requirement: Server status card
Edition: CE. A "Data pipeline" card SHALL show, from the last server status, the writer line ("Last batch at
hh:mm:ss" with a check icon when the batch is fresh, as for the live chip, a warning icon otherwise, "Last batch:
never" when no node was seen), "N of M nodes reporting" (warning icon when N < M), the migration progress bar only
while it runs, and a "Storage and retention" link to `/settings?tab=about`; it shows a skeleton until the first status
and updates with the status poll.
never" when no batch was ever seen), "N of M nodes reporting" (warning icon when N < M), the migration progress bar
only while it runs, and a "Storage and retention" link to `/settings?tab=about`; it shows a skeleton until the first
status and updates with the status poll.
#### Scenario: Healthy card
- **GIVEN** a status with the writer batch 4 s before `generatedAt` at 12:31:05, `nodes: 2`, `aliveNodes: 2` and migration `completed`
- **WHEN** the card renders
- **THEN** it reads "Data pipeline", "Last batch at 12:31:05" with the check icon and "2 of 2 nodes reporting", and shows neither the migration, a version, the schema nor the retention
- **THEN** it reads "Data pipeline", "Last batch at 12:31:05" with the check icon and "2 of 2 nodes reporting", and shows no migration progress
#### Scenario: Migration running
- **GIVEN** a status with `migration` `{ state: "running", progress: { processed: 400000, total: 1200000 } }`
@ -197,7 +197,7 @@ visible.
#### Scenario: Empty database
- **GIVEN** a fresh deployment with no room ever created
- **WHEN** the page renders
- **THEN** the empty state reads "Waiting for the first events", every tile reads 0, the status card shows "Last batch: never" and no error banner is shown
- **THEN** the empty state reads "Waiting for the first events", every tile reads 0, the status card shows "Last batch: never" and "no node has reported yet" with warning icons, and no error banner is shown
### Requirement: Overview refresh policy
Edition: CE. The page SHALL follow the shared auto-refresh policy with the live-view default of 10 s, send

View File

@ -39,15 +39,15 @@ and SHALL use a Node 24 base image pinned to an exact version tag.
### Requirement: CE runtime is a plain Node image without private dependencies
CE edition. The CE image SHALL run the built backend (which serves the SPA) on an official Node 24 image with only
production dependencies and no private package; npm, npx and corepack SHALL be absent from the runtime image.
Edition: CE. The CE image SHALL run the built backend (which serves the SPA) on an official Node 24 image with only
production dependencies and no private package; npm, npx, corepack and yarn SHALL be absent from the runtime image.
#### Scenario: CE build needs no private access
- **GIVEN** no build secret and no credentials for private repositories
- **WHEN** the CE image is built
- **THEN** the build succeeds
#### Scenario: CE image carries no licensing package
#### Scenario: CE image carries no private package
- **WHEN** the CE image filesystem is searched for packages of private repositories
- **THEN** nothing is found
@ -75,17 +75,17 @@ Edition: CE. The image SHALL declare `EXPOSE 5000` and a `HEALTHCHECK` that requ
### Requirement: Multi-architecture images
Edition: CE. The Dockerfile SHALL build for `linux/amd64` and `linux/arm64` the way OpenVidu Meet does: the
platform-independent build stage runs once on `$BUILDPLATFORM` and only the final stage runs per target
platform (buildx with QEMU on the self-hosted runner, no native arm64 runner).
Edition: CE. The Dockerfile SHALL build for `linux/amd64` and `linux/arm64`: the platform-independent SPA build stage
runs once on `$BUILDPLATFORM` and the other stages run per target platform (buildx with QEMU, no native arm64
runner).
#### Scenario: Multi-platform build
- **WHEN** the image is built with `--platform linux/amd64,linux/arm64`
- **THEN** the resulting manifest list contains both platforms and each variant starts on its architecture
### Requirement: No timezone database in the images
Edition: CE. The image SHALL NOT install `tzdata`; timestamps are stored and logged in UTC and time zone
conversion for bucketing uses the data bundled with Node.
Edition: CE. The runtime image SHALL NOT contain `tzdata` (it is purged from the Node base image); timestamps are
stored and logged in UTC and time zone conversion for bucketing uses the data bundled with Node.
#### Scenario: Time zone conversion without tzdata
- **GIVEN** a container with `TZ` unset and no `/usr/share/zoneinfo`
@ -103,9 +103,8 @@ directly.
### Requirement: Image names and build wrapper
Edition: CE. The image SHALL keep the name `openvidu/openvidu-dashboard`. `./dashboard.sh build-image ce` SHALL build
it honouring
`IMAGE_TAG` (default `dev`) and `BASE_HREF` (default `/`).
Edition: CE. The image SHALL be named `openvidu/openvidu-dashboard`. `./dashboard.sh build-image ce` SHALL build it
honouring `IMAGE_TAG` (default `dev`) and `BASE_HREF` (default `/`).
#### Scenario: Wrapper builds CE
- **WHEN** `./dashboard.sh build-image ce` runs
@ -122,8 +121,8 @@ Edition: CE. The uncompressed `linux/amd64` CE image SHALL NOT exceed 300 MB; th
### Requirement: Supply-chain verification inside the image build
Edition: CE. The Dockerfile SHALL install Aikido safe-chain in the build stage and run `npm safe-chain-verify`
(with `SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=0`, as the previous images did) before installing dependencies, so the
verification runs wherever the image is built.
(with `SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=0`, so only packages flagged as malicious fail the build) before installing
dependencies, so the verification runs wherever the image is built.
#### Scenario: Build with a flagged package
- **GIVEN** a lockfile that resolves a package flagged as malicious by safe-chain

View File

@ -43,7 +43,7 @@ tie-breaker) or by the ranked `leftAt` and `duration`, with `order` `desc` (defa
### Requirement: Identity prefix search
Edition: CE. The `q` parameter of the participants and agents lists SHALL match the case-insensitive prefix of the
participant identity when it holds at least two characters after trimming and does not start with `PA_`;
participant identity when it holds at least two characters after trimming and does not match `^PA_[A-Za-z0-9]+$`;
a one-character `q` SHALL be rejected with `400`; `q` combines with every other filter of the list.
#### Scenario: Prefix match
@ -66,8 +66,9 @@ a one-character `q` SHALL be rejected with `400`; `q` combines with every other
- **THEN** only the `assistant-1` agent session is returned
### Requirement: Exact SID lookup through the list
Edition: CE. When `q` starts with `PA_` the participants and agents lists SHALL return the session with that exact SID
as its only item, ignoring the time range and the other filters, and an empty list when no session has that SID.
Edition: CE. When `q` matches `^PA_[A-Za-z0-9]+$` the participants and agents lists SHALL return the session with that
exact SID as its only item, ignoring the time range, the other filters and the sort, and an empty list when no session
has that SID.
#### Scenario: SID outside the range
- **GIVEN** session `PA_x1y2` joined 10 days ago
@ -172,7 +173,7 @@ and sessions per kind joined in the range. Range counts follow the exact-or-appr
#### Scenario: Long range
- **GIVEN** the default `DASHBOARD_LIST_TOTAL_MAX_RANGE` of 7 days
- **WHEN** the client requests the summary with `from=now-30d&to=now`
- **THEN** `sessions.participants` is the sum of `participants.joined` and `sessions.byKind` the sums of `participants.byKind` of the global hourly rollups whose bucket starts inside the range, and `approximate` is `true`
- **THEN** `sessions.byKind` holds the sums of `participants.byKind` of the global hourly rollups whose bucket starts inside the range, `sessions.participants` its `STANDARD` value, and `approximate` is `true`
#### Scenario: Index-backed
- **GIVEN** a database generated at scenario M

View File

@ -79,20 +79,14 @@ and the removable chip "Publishers" while `publisher=true`. SIP, CONNECTOR and B
### Requirement: Participants table
Edition: CE. The table SHALL show, in order, the columns SID (link to the detail with a copy button), Identity (copy
button), Name, Room (link to the room detail), Kind (kind badge), Status (status badge with the stale cue), Joined,
Left, Duration (live), Published (source icons) and Node (hidden by default), with no Client or Connection column; the
sortable headers are Joined (`joinedAt`, the default), Left (`leftAt`) and Duration (`duration`); links never trigger
on row click.
Left, Duration (live), Published (source icons) and Node (hidden by default); the sortable headers are Joined
(`joinedAt`, the default), Left (`leftAt`) and Duration (`duration`); links never trigger on row click.
#### Scenario: Row rendering
- **GIVEN** an active STANDARD session `PA_x1y2` of `alice` in `demo-room` publishing a camera and a microphone
- **WHEN** the row renders
- **THEN** the cells read, in order, `PA_x1y2` once, linking to `/participants/PA_x1y2` with its copy button, `alice` with its copy button, the name, `demo-room` linking to `/rooms/RM_abc`, the Standard kind badge, "active", the join time, "—", a duration counting up live and the camera and microphone source icons
#### Scenario: Client unknown
- **GIVEN** a session without `client` and without `connectionType`
- **WHEN** the row renders
- **THEN** the row renders like any other, since the table has no Client or Connection column
#### Scenario: Sort toggles order
- **GIVEN** the default `joinedAt` descending
- **WHEN** the user activates the Joined header

View File

@ -218,7 +218,8 @@ and are hour-aligned sums of the hourly rollups with `approximate: true` beyond
### Requirement: Index-backed room queries
Edition: CE. Every query of this capability SHALL run on an index of the shared contract
(`dashboard-ce/typings/contract/indexes.ts`): no winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
(`dashboard-ce/typings/contract/indexes.ts`), or on `_id` for the hourly rollups the long-range summary reads: no
winning plan contains a `COLLSCAN`, and `totalDocsExamined` is bounded
by the page size plus the documented residual filters for the index-ordered pages and by the rooms created in the range
for the ranked sorts.

View File

@ -31,8 +31,8 @@ the start time; invalid values fall back silently; any change of filter, search,
### Requirement: Summary tiles
Edition: CE. The Rooms page SHALL show four KPI tiles from the summary endpoint above the filters: Active rooms, Rooms
in range, Participant sessions in range and Active participants; approximate values carry a tooltip saying the range
exceeds the exact-count limit, and the tiles keep their last value while refreshing.
in range, Participant sessions and Active participants; approximate values carry a tooltip saying the range exceeds the
exact-count limit, and the tiles keep their last value while refreshing.
#### Scenario: Tiles after load
@ -51,8 +51,8 @@ exceeds the exact-count limit, and the tiles keep their last value while refresh
Edition: CE. The Rooms page SHALL offer a status segment (Active, Closed, All), the shared list search box labelled
"Room name or SID" for a room name prefix or an `RM_` SID, and a multi-select of feature chips (Egress, Ingress, Agent);
an exact SID shows only that room ignoring the range and every filter, Enter on it opens the room keeping the range,
and no "Showing room …" line is shown. SIP is not offered as a feature chip nor shown as a room feature.
an exact SID shows only that room ignoring the range and every filter, and Enter on it opens the room keeping the
range. SIP is not offered as a feature chip nor shown as a room feature.
#### Scenario: Short search text
@ -64,8 +64,8 @@ and no "Showing room …" line is shown. SIP is not offered as a feature chip no
- **GIVEN** the user typed `RM_ab12cd`, the room exists and the status filter is Active
- **WHEN** the result arrives
- **THEN** the table shows that one room regardless of range and status, the box shows the hint "Exact ID: range and
filters ignored · Enter opens it", and no "Showing room RM_ab12cd" line is shown
- **THEN** the table shows that one room regardless of range and status, and the box shows the hint "Exact ID: range
and filters ignored · Enter opens it"
#### Scenario: Enter on an exact SID
@ -105,7 +105,7 @@ Active, Data up / down, Features (badges E, I, A) and Node (hidden by default);
- **GIVEN** `status=all` and the default 24 h range
- **WHEN** the user activates the Ended header
- **THEN** the URL gets `sort=endedAt&order=desc` without `cursor`, the list still holds the rooms started in the range,
- **THEN** the URL gets `sort=endedAt&order=desc` without `cursor`, the list holds the rooms started in the range,
and the active rooms, which have no end yet, come first
#### Scenario: Sort by unique identities
@ -119,7 +119,8 @@ Active, Data up / down, Features (badges E, I, A) and Node (hidden by default);
- **GIVEN** a room with `closedBy: "reconciler"`
- **WHEN** the row renders
- **THEN** the status badge has the dotted outline and the Ended cell carries the "estimated" tooltip
- **THEN** the status badge has the dotted outline and the Ended cell carries the tooltip "Closed by the reconciler:
the end time is estimated"
### Requirement: Rooms pagination
@ -197,11 +198,10 @@ previous rows when a load fails.
### Requirement: Room detail route and CE lock
Edition: CE. `/rooms/:sid` SHALL be registered in CE with `requiredFeature: "room-detail"`, read `tz`, `types`,
`participant`, `order`, `cursor` and `limit` from the URL, and in CE render the breadcrumbs, a title (room name, or SID)
with its copy button and the status badge, filled from the list cache or else from `GET /api/v1/rooms?q=<sid>`, and the
full-page lock over a silhouette of three tiles and a block, with no other header row, no refresh control and no PRO
request.
Edition: CE. `/rooms/:roomSid` SHALL be registered in CE as a timed route that is not range-scoped and declares the
feature `room-detail`, and in CE render the breadcrumbs, a title (room name, or SID) with its copy button and the
status badge, filled from the list cache or else from `GET /api/v1/rooms?q=<sid>`, and the full-page lock over a
silhouette of three tiles and a block, with no other header row, no refresh control and no PRO request.
#### Scenario: Locked from the list

View File

@ -2,7 +2,7 @@
## Purpose
The `openvidu.analytics` configuration surface of the OpenVidu server: keys, defaults per edition,
validation, the mapping of the legacy `expiration` key and the environment variable forms.
validation, the mapping of the deprecated `expiration` and `fixer_interval` keys and the environment variable forms.
## Requirements
@ -47,8 +47,8 @@ SHALL default to `participant` and the PRO server to `track`; an explicit value
- **WHEN** stats are written
- **THEN** `track_stats` documents are per participant and direction
### Requirement: Legacy expiration key
Edition: CE and PRO. The deprecated `expiration` key SHALL still be accepted: when it is set and neither
### Requirement: Deprecated expiration key
Edition: CE and PRO. The deprecated `expiration` key SHALL be accepted: when it is set and neither
`retention.events` nor `retention.stats` is set, both take its value and a deprecation warning names them; when
either of them is set, `expiration` is ignored for both and the warning says so.
@ -63,7 +63,7 @@ either of them is set, `expiration` is ignored for both and the warning says so.
- **THEN** `retention.events` is `1d`, `retention.stats` keeps its default `7d` and a warning states that `expiration` is ignored
### Requirement: Deprecated fixer_interval key
Edition: CE and PRO. The deprecated `fixer_interval` key SHALL still set the reconciler period when
Edition: CE and PRO. The deprecated `fixer_interval` key SHALL set the reconciler period when
`reconciler.interval` is absent, with a deprecation warning naming `reconciler.interval`; when both are set,
`reconciler.interval` SHALL win and the warning SHALL say that `fixer_interval` is ignored.
@ -149,7 +149,7 @@ bare integer of nanoseconds, or a whole number of days followed by `d` and optio
- **WHEN** the server starts
- **THEN** closed tracks and subscriptions are kept for 36 hours
#### Scenario: Hours still accepted
#### Scenario: Go duration in hours
- **GIVEN** `retention.events: 168h`
- **WHEN** the server starts
- **THEN** raw events are kept for 7 days

View File

@ -1,7 +1,7 @@
# server-analytics-writer Specification
## Purpose
How the OpenVidu server turns LiveKit analytics events and stats into the v2 analytics model of the
How the OpenVidu server turns LiveKit analytics events and stats into the analytics model of the
`data-model` capability: codec, projections, stats modes, batching and idempotency, database
initialization, shutdown and observability of the write path.
@ -23,10 +23,10 @@ single member that is set and no entry for unset fields.
- **THEN** `payload.track` contains `sid` and no other key
### Requirement: Pure projection into write models
Edition: CE and PRO. Each event SHALL be projected by a pure function of the event, the configuration and
the batch clock into write models (collection, filter, update) with no database read, and the write
models SHALL be idempotent and commutative so that replayed or reordered events converge on the same
contract fields.
Edition: CE and PRO. Each event SHALL be projected into write models (collection, filter, update) by a pure function
of the event, the configuration and the references the batch resolves beforehand from its cache or with one query per
kind (identities, published tracks, entities the reconciler closed), with no database read of its own, and the write
models SHALL be idempotent and commutative so that replayed or reordered events converge on the same contract fields.
#### Scenario: Every emitted type is projected
- **GIVEN** the recorded fixture set containing every event type the server emits
@ -92,8 +92,9 @@ nanoseconds and microseconds; `rttMs` stays in milliseconds, `jitterMs` is deriv
### Requirement: Secrets allow-list
Edition: CE and PRO. Projectors SHALL copy only fields named in an explicit allow-list into any document,
including raw payloads; every other field, present or future, SHALL be dropped. Egress stream, web and
webhook URLs SHALL be stored with userinfo, query string and the last path segment redacted.
including raw payloads; every other field, present or future, SHALL be dropped. Egress stream, web and websocket
URLs SHALL be stored without userinfo, query string and fragment and with their last path segment replaced by `***`;
the ingress URL SHALL be stored without userinfo, query string and fragment.
#### Scenario: Fully populated messages
- **GIVEN** an `AnalyticsEvent` of every type with every string field set to a unique sentinel, including `room.turn_password`, egress storage credentials, webhook signing keys, egress tokens, `ingress.stream_key`, `ingress.url`, SIP credentials and `api_call` request bodies
@ -106,9 +107,10 @@ webhook URLs SHALL be stored with userinfo, query string and the last path segme
- **THEN** the stored output URL is `rtmps://live.example.com/app/***`
### Requirement: Personal data flags
Edition: CE and PRO. `anonymize_ip` SHALL remove `client_info.address` from every document, and
`store_metadata` SHALL gate room metadata, participant metadata and attributes and ingress participant
metadata; IP addresses and metadata are dropped by default, also from the documents of the legacy migration.
Edition: CE and PRO. `anonymize_ip` SHALL remove the client IP addresses (`client_info.address` and
`client_meta.client_addr`) from every document, and `store_metadata` SHALL gate room metadata, participant metadata
and attributes and ingress participant metadata; IP addresses and metadata are dropped by default, also from the
documents of the legacy migration.
#### Scenario: Anonymized IP
- **GIVEN** the default configuration
@ -132,7 +134,7 @@ metadata; IP addresses and metadata are dropped by default, also from the docume
### Requirement: Stored raw event types
Edition: CE and PRO. A raw `events` document SHALL be inserted only for types in the configured stored
set, while every type is still projected; the default set excludes `API_CALL`, `TRACK_PUBLISHED_UPDATE`,
set, while every type is projected; the default set excludes `API_CALL`, `TRACK_PUBLISHED_UPDATE`,
`TRACK_MAX_SUBSCRIBED_VIDEO_QUALITY` and `TRACK_SUBSCRIBE_REQUESTED`; `API_CALL` is never stored raw;
`INGRESS_UPDATED` is stored only when `ingress.state.status` changed.
@ -149,7 +151,7 @@ set, while every type is still projected; the default set excludes `API_CALL`, `
#### Scenario: Configured stored set
- **GIVEN** `events.store_types` set to `[ROOM_CREATED, ROOM_ENDED, API_CALL]`
- **WHEN** a `ROOM_CREATED`, a `PARTICIPANT_JOINED` and an `API_CALL` are processed
- **THEN** only the `ROOM_CREATED` raw event exists and the participant document is still written
- **THEN** only the `ROOM_CREATED` raw event exists and the participant document is written
### Requirement: API call events
Edition: CE and PRO. The server SHALL emit an `API_CALL` analytics event with its node id for every Twirp API
@ -189,13 +191,14 @@ SHALL be updated.
### Requirement: Stats deltas into entities and rollups
Edition: CE and PRO. Each stats batch SHALL add its byte totals and quality samples (score sum, sample
count, minimum score, poor and lost counts) to the participant, the room and the `global` and
`node:<nodeId>` documents of `analytics_5min` and `analytics_hourly` of the flush bucket, and SHALL set
`lastStatsAt` on the participant and the room to the latest flush time.
`node:<nodeId>` documents of `analytics_5min`, `analytics_hourly` and `analytics_daily` of the flush bucket, SHALL
add every participant with stats to the 5-minute `participants.connected` count, and SHALL set `lastStatsAt` on the
participant and the room to the latest flush time.
#### Scenario: Bucket attribution
- **GIVEN** a flush stamped 10:04:59 UTC with 1 000 upstream bytes
- **WHEN** it is stored
- **THEN** `analytics_5min` `global:…T10:00:00Z` and `analytics_hourly` `global:…T10:00:00Z` grow `bytes.up` by 1 000, and so do the `node:<nodeId>` documents
- **THEN** `analytics_5min` `global:…T10:00:00Z`, `analytics_hourly` `global:…T10:00:00Z` and `analytics_daily` `global:…T00:00:00Z` grow `bytes.up` by 1 000, and so do the `node:<nodeId>` documents
#### Scenario: Quality bands
- **GIVEN** samples with scores 4.3, 1.5 and 1.0
@ -209,9 +212,10 @@ count, minimum score, poor and lost counts) to the participant, the room and the
### Requirement: Rollup counters from events
Edition: CE and PRO. Each projected event SHALL increment the hourly and daily counters of its UTC bucket
in both scopes exactly once: increments are applied only for events whose raw insert succeeded, or whose
rollup step has not yet succeeded in an in-process retry, so retried and replayed batches never double
count. Map keys SHALL be enum names or a fixed vocabulary, never user input.
in both scopes, and its 5-minute lifecycle counters, once: an event stored raw counts only when its raw insert
succeeds, so an event already stored by a previous process never counts again; an event not stored raw counts when
it is projected; an in-process retry never reapplies a rollup step that succeeded. Map keys SHALL be enum names or a
fixed vocabulary, never user input.
#### Scenario: Batch retried after a transient error
- **GIVEN** a batch whose raw inserts succeeded and whose rollup step failed with a network error
@ -236,7 +240,7 @@ collection and insert raw events and stats with unordered inserts, without any c
#### Scenario: Two nodes write concurrently
- **GIVEN** two nodes with batches due at the same time
- **WHEN** both flush
- **THEN** both batches are written without waiting for each other and no lock key exists in Redis
- **THEN** both batches are written without waiting for each other and neither of them takes a lock in Redis
#### Scenario: Same document twice in a batch
- **GIVEN** a `PARTICIPANT_JOINED`, `PARTICIPANT_ACTIVE` and `PARTICIPANT_LEFT` of one session in one batch
@ -306,7 +310,7 @@ the same time SHALL all complete it.
#### Scenario: Empty database
- **GIVEN** an empty database
- **WHEN** the server starts
- **THEN** every collection of the contract exists with its validator and indexes, `track_stats` is a time-series collection and `schema_meta` has `schema`, `retention` and `capabilities` documents
- **THEN** every collection of the contract exists with its validator and indexes, `track_stats` is a time-series collection and `schema_meta` has `schema`, `retention`, `capabilities` and `config` documents
#### Scenario: Time-series unsupported
- **GIVEN** a server that rejects time-series collection creation
@ -349,13 +353,13 @@ the same time SHALL all complete it.
- **THEN** every node completes initialization and the legacy documents end up once in `legacy_events`
### Requirement: Legacy events collection at first start
Edition: CE and PRO. Before initializing, the writer SHALL rename an `events` collection written by the
previous writer (one holding a document without `ts` while `schema_meta` has no `schema` document) to
Edition: CE and PRO. Before initializing, the writer SHALL rename a legacy `events` collection, written by
OpenVidu 3.9 or earlier (one holding a document without `ts` while `schema_meta` has no `schema` document), to
`legacy_events`, keeping its documents for the legacy migration, and SHALL never overwrite an existing
`legacy_events` collection.
#### Scenario: Legacy events collection
- **GIVEN** an `events` collection whose documents have the previous writer's shape and no `schema_meta.schema`
- **GIVEN** an `events` collection whose documents have the legacy shape and no `schema_meta.schema`
- **WHEN** the server starts
- **THEN** that collection is renamed to `legacy_events` with its documents, a new `events` collection with the contract's validator is created, and a later restart renames nothing
@ -402,7 +406,7 @@ one line per cause per interval; log lines SHALL never contain event payloads or
#### Scenario: Outage logging
- **GIVEN** a database unavailable for one minute with the default interval
- **WHEN** the logs are read
- **THEN** there are at most six warn lines for the outage, each with the retry count and queue depths, and none contains a payload or the connection string credentials
- **THEN** there are at most six warn lines for the outage, each with the error and the queue depths, and none contains a payload or the connection string credentials
### Requirement: Contract conformance
Edition: CE and PRO. The writer SHALL vendor the `data-model` contract (JSON Schemas, index definitions,
@ -420,22 +424,23 @@ version.
- **WHEN** the test suite runs
- **THEN** the contract test fails naming both versions
### Requirement: Legacy write path removed
Edition: CE and PRO. The writer SHALL NOT write the legacy `stats`, `active_entities` and `last_alive`
collections, SHALL NOT run the active-entities fixer or acquire the legacy Redis write lock, and SHALL
NOT include a Redis sink.
### Requirement: Writer collections and Redis keys
Edition: CE and PRO. The writer SHALL write only the collections of the `data-model` contract, never the legacy
`stats`, `active_entities` or `last_alive` collections, and its only analytics keys in Redis SHALL be the locks of
the reconciler (`analytics-reconciler-lock`) and of the legacy migration (`analytics-migration-lock`); events and
stats SHALL NOT be published to Redis.
#### Scenario: Collections after a run
- **GIVEN** a server started against an empty database
- **WHEN** it has processed traffic for several minutes
- **THEN** no `stats`, `active_entities` or `last_alive` collection exists and Redis holds no `analytics-db-operations-lock` or `active-entities-lock` key (the reconciler's own `analytics-reconciler-lock` is the only analytics lock)
- **THEN** no `stats`, `active_entities` or `last_alive` collection exists and `analytics-reconciler-lock` is the only analytics key in Redis
### Requirement: Standard participant counters
Edition: CE and PRO servers. The writer SHALL count only STANDARD sessions in the room participant counters
(`sessions`, `unique`, `active`, `peakActive`, `aborted`), in every rollup participant counter (joined, active, left,
aborted, minutes, connect time, SDK, OS, browser, connection type, disconnect reasons), in `rooms.participantsSum` and
in the daily `uniqueIdentities`; `participants.byKind` SHALL count the sessions of every kind. Which kinds keep a room
open SHALL not change.
in the daily `uniqueIdentities`; `participants.byKind` SHALL count the sessions of every kind. Room liveness SHALL
NOT depend on these counters.
#### Scenario: Egress participant joins
- **GIVEN** an active room with one active STANDARD participant

View File

@ -1,31 +1,31 @@
# server-legacy-migration Specification
## Purpose
Conversion of the legacy analytics collections (`events`, `stats`, `active_entities`, `last_alive`) into
the v2 read model by the OpenVidu server, so that an upgraded deployment keeps its history, closes the
entities the legacy writer left open and exposes the progress to the dashboard.
Conversion of the legacy analytics collections (`events`, `stats`, `active_entities`, `last_alive`), written by
OpenVidu 3.9 and earlier, into the analytics model by the OpenVidu server, so that an upgraded deployment keeps its
history, closes the entities the legacy writer left open and exposes the progress to the dashboard.
## Requirements
### Requirement: Migration trigger at writer startup
Edition: CE and PRO. After renaming a legacy `events` collection and initializing the v2 collections, a writer
Edition: CE and PRO. After renaming a legacy `events` collection and initializing the analytics collections, a writer
that finds no `schema_meta` `_id: "migration"` document and a legacy collection (`legacy_events`, `stats`,
`active_entities` or `last_alive`) SHALL write `_id: "migration"` with `state: "pending"` and start the migration in
the background; every later start resumes a migration that is not `completed`. Without legacy collections no
migration document SHALL be written.
#### Scenario: Legacy deployment upgraded
- **GIVEN** a database written by the legacy writer and no `schema_meta` `_id: "schema"` document
- **WHEN** the new writer starts
- **THEN** `legacy_events` holds the legacy documents with their indexes, the v2 collections exist, the live write path accepts events immediately and `schema_meta` `_id: "migration"` has `state: "pending"`, `total`, `writerVersion` and `fromSchemaVersion: 0`
- **GIVEN** a database written by OpenVidu 3.9 or earlier and no `schema_meta` `_id: "schema"` document
- **WHEN** the writer starts
- **THEN** `legacy_events` holds the legacy documents with their indexes, the analytics collections exist, the live write path accepts events immediately and `schema_meta` `_id: "migration"` has `state: "pending"`, `total`, `writerVersion` and `fromSchemaVersion: 0`
#### Scenario: Fresh deployment
- **GIVEN** an empty database
- **WHEN** the writer starts
- **THEN** no `migration` document exists and nothing is renamed
#### Scenario: Legacy collection next to a v2 schema
- **GIVEN** a `schema_meta` `_id: "schema"` document written by a v2 writer, no `migration` document and a legacy `last_alive` collection
#### Scenario: Legacy collection next to an analytics schema
- **GIVEN** a `schema_meta` `_id: "schema"` document written by the analytics writer, no `migration` document and a legacy `last_alive` collection
- **WHEN** the writer starts
- **THEN** `schema_meta` `_id: "migration"` is written with `state: "pending"`, whatever the recorded schema version
@ -58,8 +58,9 @@ access to the writer.
### Requirement: Single runner across nodes
Edition: CE and PRO. The migration SHALL run on at most one node at a time, elected with the Redis lock
`analytics-migration-lock` (TTL 60 s, refreshed after every batch; a process-local lock in a deployment without Redis); a node that cannot
obtain the lock SHALL serve the live path and retry the lock every minute, so it takes over when the runner dies.
`analytics-migration-lock` (TTL 60 s, refreshed after every batch; a process-local lock in a deployment without
Redis); a node that cannot obtain the lock SHALL serve the live path and retry the lock every minute, so it takes over
when the runner dies.
#### Scenario: Two nodes start together
- **GIVEN** a pending migration and two writer nodes starting at the same time
@ -72,10 +73,10 @@ obtain the lock SHALL serve the live path and retry the lock every minute, so it
- **THEN** node B obtains the lock at its next attempt, without a restart, and resumes from the stored checkpoint
### Requirement: Replay order and batching
Edition: CE and PRO. Legacy events SHALL be replayed in `timestamp.seconds` then `timestamp.nanos`
order and stats in `time_stamp.seconds` order, in batches of at most `batch_size` documents extended
so that a batch never splits a second, read through a streaming cursor that holds one batch in memory,
with a pause after each batch so the throughput does not exceed `rate` documents per second.
Edition: CE and PRO. Legacy events SHALL be replayed in `timestamp.seconds` then `timestamp.nanos` order and stats in
`time_stamp.seconds` order, in batches of `batch_size` documents (fewer at the end) extended until the second of their
last document is complete, so that a batch never splits a second, read through a streaming cursor that holds one batch
in memory, with a pause after each batch so the throughput does not exceed `rate` documents per second.
#### Scenario: Throttle respected
- **GIVEN** `rate: 1000` and 10 000 legacy events
@ -179,15 +180,15 @@ wrote a heartbeat (legacy nodes), which the replay closes later.
- **THEN** one `rooms` document exists with `status: "closed"`, `closedBy: "event"`, `participants.sessions: 1` and `participants.active: 0`
#### Scenario: Live reconciler during the replay
- **GIVEN** a running migration, a replayed legacy participant still open and a participant of a crashed v2 node
- **GIVEN** a running migration, an open replayed legacy participant and a participant of a crashed node that wrote heartbeats
- **WHEN** the live reconciler runs
- **THEN** it closes the participant of the crashed v2 node and leaves the legacy participant to the replay
- **THEN** it closes the participant of the crashed node and leaves the legacy participant to the replay
### Requirement: Checkpoint and resume
Edition: CE and PRO. The checkpoint (`phase` and the last fully applied second of each collection) SHALL
be persisted after every batch; a restart, a lost lock or a failure SHALL leave a checkpoint from which
the next run resumes without reapplying any document. A non-transient error, or a transient one still failing
at the fifth attempt, SHALL set `state: "failed"` with a clear `error`, and the next writer start SHALL retry from
the next run resumes without reapplying any document. A non-transient error, or a transient one that persists
through five attempts, SHALL set `state: "failed"` with a clear `error`, and the next writer start SHALL retry from
the checkpoint.
#### Scenario: Killed mid-run
@ -197,7 +198,7 @@ the checkpoint.
#### Scenario: Database error
- **GIVEN** a MongoDB outage during a batch
- **WHEN** the batch still fails at its fifth attempt, the attempts spaced 1, 2, 4 and 8 s
- **WHEN** the batch fails five attempts in a row, spaced 1, 2, 4 and 8 s
- **THEN** `state` is `failed`, `error` names the failure and the checkpoint, and the next writer start retries from the checkpoint
#### Scenario: Transient error with progress
@ -232,11 +233,12 @@ the migration `completed`; `legacy_events` and `stats` SHALL never be deleted by
### Requirement: Legacy data kept for rollback
Edition: CE and PRO. The migration SHALL only read `legacy_events` and `stats`, never changing their documents or
indexes, so that the previous server version can be restored on the legacy data that has not expired by its TTL.
indexes, so that a legacy server (OpenVidu 3.9 or earlier) can be restored on the legacy data that has not expired
by its TTL.
#### Scenario: Rollback after the migration
- **GIVEN** a completed migration
- **WHEN** the operator stops the server, drops the v2 collections, renames `legacy_events` back to `events` and starts the previous version
- **WHEN** the operator stops the server, drops the analytics collections, renames `legacy_events` back to `events` and starts the legacy server
- **THEN** `events` and `stats` hold every legacy document whose `openvidu_expire_at` has not passed, with their original indexes
### Requirement: Observability
@ -252,7 +254,7 @@ one line per state transition.
- **THEN** `openvidu_analytics_migration_state` is 2 and `openvidu_analytics_migration_processed_total` grows with the replay
### Requirement: Migration invariants
Edition: CE and PRO. After completion the v2 collections SHALL satisfy: one `rooms` document per distinct
Edition: CE and PRO. After completion the analytics collections SHALL satisfy: one `rooms` document per distinct
legacy `room.sid`, one `participants` document per distinct legacy `participant_id`, one `egresses`
document per `egress_id`, one `ingress_sessions` document per `ingress.state.resource_id`, no secret
field copied, and durations of event-closed entities within 1 s of the legacy start/end difference.

View File

@ -8,8 +8,8 @@ LiveKit's live state, so the dashboard never shows entities that no longer exist
## Requirements
### Requirement: Participant candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat a participant session as a candidate when its
status is `active` and `lastStatsAt` is older than 3 × stats interval (90 s), or when it has no stats at
Edition: CE and PRO servers. The reconciler SHALL treat a participant session as a candidate when it is
open (`joining` or `active`) and `lastStatsAt` is older than 3 × stats interval (90 s), or when it has no stats at
all and `activeAt` (or `joinedAt` when the session never became active) is older than the same period.
#### Scenario: Stats stopped
@ -28,13 +28,14 @@ all and `activeAt` (or `joinedAt` when the session never became active) is older
- **THEN** the participant is not a candidate
### Requirement: Room candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat as a candidate an `active` room that received no stats for
3 × stats interval and either has no open participant session of a kind that keeps a room open (every kind but EGRESS
and AGENT, which LiveKit treats as dependent) and a `lastEventAt` older than its empty timeout plus 2 × reconciler
interval, or is hosted by a dead node and received no event for 3 × stats interval, whatever its open sessions.
Edition: CE and PRO servers. The reconciler SHALL treat as a candidate an `active` room that received no stats for 3 ×
stats interval and either has no open participant session of a kind that keeps a room open (every kind but EGRESS and
AGENT, which LiveKit treats as dependent) and a `lastEventAt` older than its empty timeout (default 300 s) plus 2 ×
reconciler interval, or is hosted by a dead node and received no event for 3 × stats interval, whatever its open
sessions.
#### Scenario: Empty room past its timeout
- **GIVEN** an `active` room with empty timeout 300 s, no active STANDARD participants and `lastEventAt` 7 minutes old
- **GIVEN** an `active` room with empty timeout 300 s, no active STANDARD participants and `lastEventAt` more than 7 minutes old
- **WHEN** a reconciler run starts
- **THEN** the room is a candidate
@ -49,7 +50,7 @@ interval, or is hosted by a dead node and received no event for 3 × stats inter
- **THEN** the room is a candidate
#### Scenario: Room of a dead node
- **GIVEN** an `active` room on a dead node, created 2 minutes ago with empty timeout 300 s, whose last stats flush and last event are 100 s old and whose only STANDARD participant is still `active`
- **GIVEN** an `active` room on a dead node, created 2 minutes ago with empty timeout 300 s, whose last stats flush and last event are 100 s old and whose only STANDARD participant is `active`
- **WHEN** a reconciler run starts
- **THEN** the room is a candidate
@ -59,10 +60,10 @@ interval, or is hosted by a dead node and received no event for 3 × stats inter
- **THEN** the room is not a candidate
### Requirement: Egress and ingress session candidate detection
Edition: CE and PRO servers. The reconciler SHALL treat an egress or an ingress session as a candidate
only when it is still active, its room is `closed` in the store for longer than 2 × LiveKit cleanup
interval, and the room's node is dead; egress and ingress of active rooms or alive nodes are never
candidates.
Edition: CE and PRO servers. The reconciler SHALL treat an egress or an ingress session as a candidate only when it
has not ended (egress `starting`, `active` or `ending`; ingress session `buffering` or `publishing`), its room is
`closed` in the store for longer than 2 × LiveKit cleanup interval, and the room's node is dead; egress and ingress of
active rooms or alive nodes are never candidates.
#### Scenario: Egress of a closed room on a dead node
- **GIVEN** an `active` egress whose room is `closed` since 7 minutes with `nodeId` of a dead node
@ -88,7 +89,7 @@ not list it with its SID, or when its last signal is older than 2 × LiveKit cle
#### Scenario: Node stopped writing batches
- **GIVEN** a candidate participant whose node has `lastSeenAt` 45 s old and is absent from LiveKit's node registry
- **WHEN** the candidate is checked
- **THEN** it is confirmed with reason `node-dead` even if the live listing still contains it
- **THEN** it is confirmed with reason `node-dead` even if the live listing contains it
#### Scenario: Node alive by registry
- **GIVEN** a candidate participant whose node has `lastSeenAt` 45 s old but LiveKit's node registry reports it available
@ -118,7 +119,7 @@ not list it with its SID, or when its last signal is older than 2 × LiveKit cle
#### Scenario: Untrusted listing
- **GIVEN** a candidate room of a dead node whose last signal is 2 minutes old and a live listing that returns an error
- **WHEN** the run executes
- **THEN** the room stays `active` and the participant sessions of the dead node are still closed
- **THEN** the room stays `active` and the participant sessions of the dead node are closed
### Requirement: Confirmation by live state when the node is alive
Edition: CE and PRO servers. When a candidate's node is alive, the reconciler SHALL confirm it only if the
@ -133,7 +134,7 @@ two consecutive runs; a candidate present in the listing SHALL be kept and logge
#### Scenario: Absent once
- **GIVEN** a candidate participant whose node is alive and which was absent from the live listing for the first time in this run
- **WHEN** the run ends
- **THEN** the participant is still `active`
- **THEN** the participant stays `active`
#### Scenario: Present in live state
- **GIVEN** a candidate participant whose node is alive and which is listed in the live state
@ -142,8 +143,8 @@ two consecutive runs; a candidate present in the listing SHALL be kept and logge
### Requirement: Untrusted live listing
Edition: CE and PRO servers. A live listing SHALL be untrusted and confirm nothing when reading it fails,
or when it lists no room while the store has active rooms on alive nodes (the state right after a Redis
restart). Observations from an untrusted listing SHALL not count towards consecutive absences.
or when it lists no room while active rooms of alive nodes received stats within the last 3 × stats interval (the
state right after a Redis restart). Observations from an untrusted listing SHALL not count towards consecutive absences.
#### Scenario: Redis error
- **GIVEN** candidates whose nodes are alive and a live listing that returns an error
@ -151,13 +152,14 @@ restart). Observations from an untrusted listing SHALL not count towards consecu
- **THEN** no entity is closed, `openvidu_analytics_reconciler_runs_total{result="live-state-error"}` increases and the next run starts the absence count from zero
#### Scenario: Empty listing after a Redis restart
- **GIVEN** a Redis restart that wiped the room state while the store has active rooms on alive nodes
- **GIVEN** a Redis restart that wiped the room state while active rooms of alive nodes keep receiving stats
- **WHEN** the next run executes
- **THEN** the listing is treated as untrusted and no entity is closed
- **THEN** the listing is treated as untrusted, no entity is closed and `openvidu_analytics_reconciler_runs_total{result="live-state-untrusted"}` increases
### Requirement: Fresh stats veto
Edition: CE and PRO servers. The reconciler SHALL never close an entity whose `lastStatsAt` is within the
last stats interval at closure time, re-reading the document immediately before closing it.
Edition: CE and PRO servers. The reconciler SHALL never close a room or participant session whose `lastStatsAt` is
within the last stats interval at closure time: each closure is a conditional update that applies only while the
entity keeps the status the run read and has no such stats flush (and, for a room, no event since the run read it).
#### Scenario: Stats arrive between detection and closure
- **GIVEN** a confirmed candidate participant
@ -170,7 +172,7 @@ last stats interval at closure time, re-reading the document immediately before
- **THEN** none of them is closed
### Requirement: Participant closure cascade
Edition: CE and PRO servers. Closing a participant session SHALL set its tracks still `published` to
Edition: CE and PRO servers. Closing a participant session SHALL set its `published` tracks to
`unpublished` and the open subscriptions to those tracks, and the participant's own open subscriptions, to
`unsubscribed`, all with the participant's end time and `closedBy: "reconciler"`.
@ -185,7 +187,7 @@ Edition: CE and PRO servers. Closing a participant session SHALL set its tracks
- **THEN** its status is `aborted` with `disconnectReason: "RECONCILED"`
### Requirement: Room closure cascade
Edition: CE and PRO servers. Closing a room SHALL close its participant sessions still open (`active` or
Edition: CE and PRO servers. Closing a room SHALL close its open participant sessions (`active` or
`joining`) with the room's end time, applying the participant cascade to each of them.
#### Scenario: Room with a stale dependent participant
@ -216,9 +218,10 @@ a later run.
### Requirement: Closure fields
Edition: CE and PRO servers. A closure SHALL set the terminal status, `closedBy: "reconciler"`, `endReason`
or `disconnectReason` `RECONCILED`, the end time `max(lastStatsAt, lastEventAt)` of the closed entity
(cascaded entities inherit it), `durationMs`, and `expiresAt` from the retention of its class (`retention.tracks`
for tracks and subscriptions, `retention.entities` otherwise).
or `disconnectReason` `RECONCILED`, the end time (`max(lastStatsAt, lastEventAt)` for rooms and participant sessions,
inherited by cascaded entities; for egress and ingress sessions, as in their store fallback), `durationMs`, and
`expiresAt` from the retention of its class (`retention.tracks` for tracks and subscriptions, `retention.entities`
otherwise).
#### Scenario: End time from the last signal
- **GIVEN** a confirmed participant with `lastStatsAt` 10:00:30 and `lastEventAt` 10:00:05
@ -236,9 +239,10 @@ for tracks and subscriptions, `retention.entities` otherwise).
- **THEN** the track has `expiresAt = unpublishedAt + 168h` and the participant `expiresAt = leftAt + retention.entities`
### Requirement: Synthetic raw event per closure
Edition: CE and PRO servers. Every closure, including cascaded ones, SHALL insert exactly one raw event of
the matching close type with `synthetic: true`, `_id` prefixed `AE_SYN_` and derived from the entity id,
the entity's lifted references and the closure reason in its payload.
Edition: CE and PRO servers. Every closure of a room, participant session, egress or ingress session, including the
participant sessions a room closure cascades to, SHALL insert exactly one raw event of the matching close type with
`synthetic: true`, `_id` prefixed `AE_SYN_` and derived from the entity id, the entity's lifted references and the
closure reason in its payload.
#### Scenario: Participant closure event
- **GIVEN** a participant closed by the reconciler
@ -285,7 +289,7 @@ and `error`; the store reconciler SHALL close only candidate egress and ingress
#### Scenario: Store fallback for an ingress session
- **GIVEN** a candidate ingress session
- **WHEN** the reconciler closes it
- **THEN** it has `status: "error"`, `closedBy: "reconciler"` and its ingress document reflects the ended session
- **THEN** it has `status: "error"`, `error: "ingress lost (reconciled)"`, `closedBy: "reconciler"`, `endedAt = max(room.endedAt, session.startedAt)` and its ingress document reflects the ended session
### Requirement: Leader-only periodic runs
Edition: CE and PRO servers. Runs SHALL execute on one node at a time, chosen by a Redis lock
@ -305,12 +309,12 @@ in a Redis restart SHALL be re-acquired without manual intervention.
### Requirement: Reconciler switch
Edition: CE and PRO servers. With `openvidu.analytics.reconciler.enabled: false` (default `true`) a node SHALL skip
the periodic and startup runs and SHALL log at startup that entities whose end event is lost stay open; the legacy
migration still runs its own post-replay reconciliation.
migration runs its own post-replay reconciliation whatever this setting.
#### Scenario: Reconciler disabled
- **GIVEN** `reconciler.enabled: false` and a participant of a crashed node still `active` in the store
- **GIVEN** `reconciler.enabled: false` and a participant of a crashed node `active` in the store
- **WHEN** 5 minutes pass
- **THEN** the participant is still `active`, `schema_meta` has no new `reconciler` run, no daily `uniqueIdentities` is set and the startup log says that lost entities stay open
- **THEN** the participant stays `active`, `schema_meta` has no new `reconciler` run, no daily `uniqueIdentities` is set and the startup log says that lost entities stay open
### Requirement: Daily unique identities
Edition: CE and PRO servers. After its closures, within its time budget, each run SHALL set `uniqueIdentities` on
@ -341,8 +345,8 @@ closes the sessions of its previous incarnation without waiting a full interval.
### Requirement: Idempotent and bounded runs
Edition: CE and PRO servers. A run SHALL be idempotent, SHALL close at most
`openvidu.analytics.reconciler.max_closures_per_run` entities (default 500) within the lock's time budget, and when
the limit or the budget is reached SHALL record the run as partial and continue after 5 s instead of waiting a full
interval.
the limit or the budget is reached SHALL record the run as partial and run a follow-up 5 s later, without waiting
for the next interval.
#### Scenario: Run repeated
- **GIVEN** a run that closed 10 entities
@ -357,8 +361,9 @@ interval.
### Requirement: Metrics
Edition: CE and PRO servers. The node SHALL expose `openvidu_analytics_reconciler_runs_total{result}`,
`openvidu_analytics_reconciler_closed_total{entity}`, `openvidu_analytics_reconciler_candidates{entity}` and
`openvidu_analytics_reconciler_run_duration_seconds` on its Prometheus endpoint, with `entity` in room,
participant, track, subscription, egress, ingress_session.
`openvidu_analytics_reconciler_run_duration_seconds` on its Prometheus endpoint, with `result` in ok, partial,
live-state-error, live-state-untrusted, error, and `entity` in room, participant, track, subscription, egress,
ingress_session (no track or subscription candidates).
#### Scenario: After a closure
- **GIVEN** a run that closed one participant with one track and two subscriptions
@ -376,9 +381,10 @@ Edition: CE and PRO servers. Every run SHALL upsert `schema_meta` (`_id: "reconc
- **THEN** the `reconciler` document has the counts of that run and `lastRunAt` within the last `reconciler.interval`
### Requirement: Structured closure logs
Edition: CE and PRO servers. Every closure SHALL emit one structured log line with the entity type and
id, room, node, reason (`node-dead`, `absent-live`, `cascade`, `room-closed-node-dead`), `lastStatsAt`,
`lastEventAt` and the end time written; kept candidates SHALL be logged at debug level.
Edition: CE and PRO servers. Every closure of a room, participant session, egress or ingress session SHALL emit one
info log line with the entity type and id, room, node, reason (`node-dead`, `absent-live`, `cascade`,
`room-closed-node-dead`, `legacy-fixer`), `lastStatsAt` and `lastEventAt` when known and the end time written; kept
rooms SHALL be logged at debug level and kept participant sessions that LiveKit lists in one warning per run.
#### Scenario: Closure log
- **GIVEN** a participant closed with reason `node-dead`

View File

@ -7,7 +7,7 @@ schema compatibility, migration, retention and storage as reported by the server
## Requirements
### Requirement: Settings tabs
CE. `/settings` SHALL show two tabs, Preferences and About, selected by the `tab` query parameter
Edition: CE. `/settings` SHALL show two tabs, Preferences and About, selected by the `tab` query parameter
(`preferences` by default); switching tabs writes `tab` to the URL.
#### Scenario: Deep link to About
@ -19,9 +19,9 @@ CE. `/settings` SHALL show two tabs, Preferences and About, selected by the `tab
Edition: CE. Preferences SHALL offer theme (system/light/dark), default time zone (browser zone shown by name, or any
IANA zone), default range preset, rows per page (10/25/50/100) and one auto-refresh interval for live views
(off/10 s/30 s/1 min/5 min), none for Analytics, ignoring a stored history-view interval. Changes save at once with a
"Saved" toast; a new default zone also becomes the session zone. "Reset to defaults" restores system theme, browser
zone, last 24 h, 25 rows and 10 s.
(off/10 s/30 s/1 min/5 min), none for Analytics; other stored intervals are ignored. Changes save at once with a
"Saved" toast; a new default zone also becomes the session zone. "Reset to defaults" restores system theme,
browser zone, last 24 h, 25 rows and 10 s.
#### Scenario: Change rows per page
- **GIVEN** rows per page set to 50
@ -35,12 +35,12 @@ zone, last 24 h, 25 rows and 10 s.
#### Scenario: One auto-refresh setting
- **WHEN** the Preferences tab renders
- **THEN** it shows one auto-refresh control, "Auto-refresh of live views", and none for Analytics or history views
- **THEN** it shows one auto-refresh control, "Auto-refresh of live views", and none for Analytics
#### Scenario: Stored history interval
- **GIVEN** stored preferences with a 50-row page size and a history-view interval of 30 s
#### Scenario: Stored interval of another kind
- **GIVEN** stored preferences with a 50-row page size and `refreshMs.history` of 30 s
- **WHEN** the dashboard loads them
- **THEN** lists show 50 rows, the history value is ignored and Analytics does not refresh by itself
- **THEN** lists show 50 rows, the `history` interval is ignored and Analytics does not refresh by itself
#### Scenario: Any default zone
- **GIVEN** the Preferences tab of a session in Europe/Madrid
@ -48,32 +48,31 @@ zone, last 24 h, 25 rows and 10 s.
- **THEN** "Saved" shows, the next timed page opens with `tz=America/New_York`, and a new browser session starts in
America/New_York
#### Scenario: Stored UTC from an earlier version
#### Scenario: Stored lowercase utc
- **GIVEN** stored preferences whose time zone reads `utc`
- **WHEN** the dashboard loads them
- **THEN** the default zone is UTC and the Preferences tab shows UTC selected
- **THEN** the default zone is UTC and the Preferences tab shows "Another zone" selected with UTC
### Requirement: Versions and schema compatibility
### Requirement: OpenVidu version
CE. About SHALL show the OpenVidu version (the dashboard version) with the edition badge, as reported by
`GET /api/v1/info`; it shows no server, schema or MongoDB version and no licence information, and a schema
incompatibility is reported only by the shell's warning banner.
Edition: CE. About SHALL show one version line, the OpenVidu version (the dashboard version) with the edition badge, as
reported by `GET /api/v1/info`; a schema incompatibility is reported by the shell's warning banner, not by About.
#### Scenario: Incompatible schema
- **GIVEN** the writer reports schema 3
- **WHEN** About renders
- **THEN** About still shows only the version and badge, and the shell banner names both schema versions
- **THEN** About shows only the version and badge, and the shell banner names both schema versions
#### Scenario: Version and edition
- **GIVEN** the info endpoint reports dashboard 3.10.0, edition CE and schema 1 for dashboard and writer
- **WHEN** About renders
- **THEN** it reads "OpenVidu 3.10.0" with the CE badge and no schema line
- **THEN** it reads "OpenVidu 3.10.0" with the COMMUNITY badge and no schema line
### Requirement: Migration status
CE. About SHALL show the legacy migration only while it runs (progress bar with processed/total records, following
the server status poll every 10 s) and, when it ends while the page is open, its outcome (completed with its time, or
failed with its message); once the page is reloaded after the migration ended, About shows nothing about it.
Edition: CE. About SHALL show the legacy migration only while it runs (progress bar with processed/total records,
following the server status poll every 10 s) and, when it ends while the page is open, its outcome (completed with its
time, or failed with its message); once the page is reloaded after the migration ended, About shows nothing about it.
#### Scenario: Migration running
- **GIVEN** the status reports 300000 of 1200000 records processed
@ -91,7 +90,7 @@ failed with its message); once the page is reloaded after the migration ended, A
- **THEN** the section reads "Migration completed at hh:mm:ss" until the page is left
### Requirement: Retention and storage
CE. About SHALL show the retention per data class (events, stats, tracks and subscriptions, entities, 5-minute
Edition: CE. About SHALL show the retention per data class (events, stats, tracks and subscriptions, entities, 5-minute
rollups, hourly rollups, daily rollups) as a table with one row per class, each value in the shared retention length
wording, and a storage table with collection, document count, size and oldest document, as reported by the info
endpoint; sizes use the shared byte formatter.
@ -118,7 +117,7 @@ action and the Preferences tab stays usable.
### Requirement: About loading state
Edition: CE. While the info document has not arrived and its request has not failed, About SHALL render a loading
state instead of an empty tab; the version, migration, retention and storage replace it when the document arrives.
skeleton; the version, migration, retention and storage replace it when the document arrives.
#### Scenario: Info in flight
- **GIVEN** the info request has not answered yet

View File

@ -165,11 +165,11 @@ raw value when the dashboard has no label for it.
### Requirement: KPI tile
CE. The KPI tile SHALL show label, value with tabular figures, optional unit, delta and hint, keep the last value
while refreshing, show a skeleton only on first load and highlight a changed value for 600 ms unless reduced
motion is requested or the previous change was less than 5 s earlier. A bandwidth tile shows upload and download
on two lines, each with its arrow. An approximate value is prefixed with a focusable "≈" that carries the
approximation tooltip; the tile itself carries no tooltip.
Edition: CE. The KPI tile SHALL show label, value with tabular figures, optional unit, delta and hint, keep the last
value while refreshing, show a skeleton only on first load and highlight a changed value for 600 ms unless reduced
motion is requested or the value changed less than 5 s before. A bandwidth tile shows upload and download on two lines
with their arrows. An approximate value is prefixed with a focusable "≈" that carries the approximation tooltip; the
tile itself carries no tooltip.
#### Scenario: Refresh keeps the value
- **GIVEN** a tile showing 87
@ -187,7 +187,7 @@ approximation tooltip; the tile itself carries no tooltip.
- **THEN** the approximation tooltip shows, and hovering the rest of the tile shows nothing
### Requirement: Time-series chart fixed to the range
CE. The time-series chart SHALL render the given series between `from` and `to` in the given timezone with
Edition: CE. The time-series chart SHALL render the given series between `from` and `to` in the given timezone with
granularity buckets; the x-axis never extends beyond the range, wheel and drag scroll the page, and no zoom
component is enabled. It uses a shared tooltip keyed by x across the charts of a page, 2 px lines, one y-axis
per chart, and formats bytes, bitrates, durations and percentages with the shared formatters.
@ -200,7 +200,7 @@ per chart, and formats bytes, bitrates, durations and percentages with the share
#### Scenario: Sparse data
- **GIVEN** buckets exist only for half of the range
- **WHEN** the chart renders
- **THEN** the x-axis still spans `from` to `to` and missing buckets show gaps, not a shortened axis
- **THEN** the x-axis spans `from` to `to` and missing buckets show gaps, not a shortened axis
### Requirement: Chart markers
Edition: CE. The chart SHALL draw event markers (join/leave, egress start/end, aborted) as dashed vertical lines,
@ -220,18 +220,19 @@ text on the chart.
### Requirement: Chart palette and quality bands
CE. Series colours SHALL follow the entity, never the rank, from eight palette slots with light and dark values that
are pairwise distinguishable; known values (participant kinds, egress statuses and types, ingress input types,
Edition: CE. Series colours SHALL follow the entity, never the rank, from eight palette slots with light and dark values
that are pairwise distinguishable; known values (participant kinds, egress statuses and types, ingress input types,
directions) have fixed distinct slots, and two other entities of one chart never share a slot.
#### Scenario: Palette values
- **WHEN** series and quality bands render in light and dark themes
- **THEN** the slots and bands use the theme tokens, the bands are tinted 12 % with Excellent `#0ca30c`, Good `#0088aa`/`#0f93b8`, Poor `#eda100`/`#c98500` and Lost `#d03b3b`, and the palette check finds every pair of slots distinguishable in both themes
- **THEN** the series take the colours of the slot tokens `--ovd-chart-1` to `--ovd-chart-8` for the applied theme,
each slot with a light and a dark value, and each quality band is drawn in its own colour tinted at 12 % opacity
#### Scenario: Same entity, same colour
- **GIVEN** the SDK breakdown and the SDK time series on one page
- **GIVEN** the Participant kind breakdown of two ranges, with `EGRESS` first in one and second in the other
- **WHEN** both render
- **THEN** `js` is teal in both and `swift` is orange in both
- **THEN** `EGRESS` has the same colour in both
#### Scenario: Egress statuses
- **GIVEN** egresses ended as complete, aborted, limit reached and failed in one chart
@ -239,7 +240,7 @@ directions) have fixed distinct slots, and two other entities of one chart never
- **THEN** the four segments have four distinguishable colours and failed is red
### Requirement: Chart accessibility
CE. Each chart SHALL expose `role="img"` with a generated summary (range, series, min/max/last), be focusable,
Edition: CE. Each chart SHALL expose `role="img"` with a generated summary (range, series, min/max/last), be focusable,
offer a data table toggle, and show direct labels on breakdowns whose light slots fall below 3:1.
#### Scenario: Screen reader summary
@ -248,7 +249,7 @@ offer a data table toggle, and show direct labels on breakdowns whose light slot
- **THEN** the summary names the range, the two series and their min, max and last values, and the table toggle is the next focus stop
### Requirement: Breakdown chart
CE. The breakdown chart SHALL render a donut or horizontal bar with a legend, direct labels, a fixed colour per
Edition: CE. The breakdown chart SHALL render a donut or horizontal bar with a legend, direct labels, a fixed colour per
entity and a toggle to a table of the same data; more than eight entries fold into "Other" (outline grey).
#### Scenario: Nine SDKs
@ -257,11 +258,11 @@ entity and a toggle to a table of the same data; more than eight entries fold in
- **THEN** eight slices are shown plus "Other", and the table toggle lists all nine
### Requirement: Date-time range picker
CE. The picker SHALL offer presets 1 h, 6 h, 24 h, 7 d, 30 d, 90 d, this month and custom, explicit `from`/`to`
Edition: CE. The picker SHALL offer presets 1 h, 6 h, 24 h, 7 d, 30 d, 90 d, this month and custom, explicit `from`/`to`
to the minute, a timezone select with a UTC toggle, and show the active zone. It validates `from < to`, warns while
the start, that of the applied range until the custom start is edited, predates the retention of the page's data
at the same instant, and writes the parameters as described by the shell. Its zone controls change the session zone
only, never the stored default zone.
at the same instant, and writes the parameters by the shell's URL rules. Its zone controls change only the session
zone, never the stored default.
#### Scenario: Custom range
- **GIVEN** the user picks custom with `2026-10-01 00:00` to `2026-10-02 00:00`
@ -276,7 +277,7 @@ only, never the stored default zone.
#### Scenario: Zone picked on a list
- **GIVEN** the stored default zone is the browser zone Europe/Madrid
- **WHEN** the user selects Asia/Tokyo in the picker of the rooms list
- **THEN** the URL has `tz=Asia/Tokyo`, every page opened afterwards in the session uses Asia/Tokyo, and Settings still
- **THEN** the URL has `tz=Asia/Tokyo`, every page opened afterwards in the session uses Asia/Tokyo, and Settings
shows "Browser (Europe/Madrid)" as the default
#### Scenario: Applied range older than the retention
@ -343,8 +344,8 @@ region and reserves its width, so the controls never move when it appears or cha
- **WHEN** the refresh control renders
- **THEN** it shows just the refresh button and the update time
#### Scenario: Page that cannot auto-refresh now
- **GIVEN** a page that cannot auto-refresh now
#### Scenario: Page that cannot auto-refresh
- **GIVEN** a page whose refresh scheduler is paused with a reason
- **WHEN** the refresh control renders
- **THEN** the interval select or menu button stays visible but disabled, with the page's reason as its tooltip
@ -391,7 +392,7 @@ shows only on the Overview.
- **THEN** no live chip is shown, since the status badge already says active
### Requirement: Breadcrumbs component
CE. The breadcrumbs component SHALL render items (label, optional link, optional copy value) as a `nav` with an
Edition: CE. The breadcrumbs component SHALL render items (label, optional link, optional copy value) as a `nav` with an
ordered list, append the current range parameters to links, and render the last item as text followed by a copy
button.
@ -451,7 +452,7 @@ filters and the Columns button of its table.
- **THEN** its Export CSV button is in the section header, after the title
### Requirement: Toast
CE. Toasts SHALL be Material snackbars shown for 3 s in a polite live region, one at a time.
Edition: CE. Toasts SHALL be Material snackbars shown for 3 s in a polite live region, one at a time.
#### Scenario: Copy toast
- **GIVEN** a copy action
@ -462,8 +463,8 @@ CE. Toasts SHALL be Material snackbars shown for 3 s in a polite live region, on
Edition: CE. Shared formatters SHALL render numbers with `Intl.NumberFormat('en')`, percentages with one decimal and a
space before the sign ("96.4 %", donut labels included), bytes in decimal units with three significant digits and
durations as `850 ms`, `42 s`, `12 m 05 s`, `1 h 02 m`, `3 d 4 h`; timestamps SHALL read as the timestamp format scenario
states.
durations as `850 ms`, `42 s`, `12 m 05 s`, `1 h 02 m`, `3 d 4 h`; timestamps SHALL read as the timestamp format
scenario states.
#### Scenario: Bytes and duration
- **GIVEN** 12345678 bytes and 3725000 ms
@ -482,7 +483,7 @@ states.
when `tz=UTC`
### Requirement: External links
CE. Links to documentation and PRO pages SHALL open in a new tab with `rel="noopener"` and a trailing
Edition: CE. Links to documentation and PRO pages SHALL open in a new tab with `rel="noopener"` and a trailing
`open_in_new` icon.
#### Scenario: Documentation link
@ -503,9 +504,9 @@ time-series chart names its bucket (such as "Hourly") and the time zone of its a
- **THEN** a note naming "Hourly" and Europe/Madrid reads under the title, not above it
### Requirement: Track source icon
CE. Track sources SHALL render as icons with a tooltip and an accessible label (Camera `videocam`, Microphone `mic`,
Screen share `screen_share`, Screen share audio `volume_up`, Unknown `help`) wherever a source is shown in a table or
a list of sources.
Edition: CE. Track sources SHALL render as icons with a tooltip and an accessible label (Camera `videocam`, Microphone
`mic`, Screen share `screen_share`, Screen share audio `volume_up`, Unknown `help`) wherever a source is shown in a
table or a list of sources.
#### Scenario: Published sources
- **GIVEN** a participant publishing camera and microphone
@ -514,7 +515,7 @@ a list of sources.
### Requirement: Paused live chip
CE. While the page's auto-refresh is off, the live chip SHALL read "paused" with a `pause` icon and the tooltip
Edition: CE. While the page's auto-refresh is off, the live chip SHALL read "paused" with a `pause` icon and the tooltip
"Auto-refresh is off · data from hh:mm:ss" (the page's last update); "stale" takes precedence, and choosing an interval
again turns it back to "LIVE".
@ -557,9 +558,9 @@ the tiles wrapping at the minimum width until measured, so no tile renders narro
- **THEN** no tile is narrower than 150 px and the row does not jump to another layout after the first frame
### Requirement: Retention length wording
CE. Every UI text that names a retention SHALL state it in the largest of days, hours, minutes and seconds that divides
it exactly, as "7 days", "1 day", "36 hours" or "90 minutes", or as "7-day", "36-hour" before a noun, and never round
it; a retention without limit SHALL read "Kept forever" where a value is shown.
Edition: CE. Every UI text that names a retention SHALL state it in the largest of days, hours, minutes and seconds that
divides it exactly, as "7 days", "1 day", "36 hours" or "90 minutes", or as "7-day", "36-hour" before a noun, and never
round it; a retention without limit SHALL read "Kept forever" where a value is shown.
#### Scenario: Whole days
- **GIVEN** an hourly rollup retention of 400 days and a range starting 500 days ago
@ -582,8 +583,8 @@ it; a retention without limit SHALL read "Kept forever" where a value is shown.
- **THEN** it reads "90 minutes", or "90-minute" before a noun
### Requirement: No native title tooltips
CE. Cards, sections and charts SHALL NOT expose their title as a native `title` attribute, so hovering any part of
them, links included, shows only the tooltips the page defines.
Edition: CE. Cards, sections and charts SHALL NOT expose their title as a native `title` attribute, so hovering any part
of them, links included, shows only the tooltips the page defines.
#### Scenario: Link inside a card
- **GIVEN** the Recent rooms card of the Overview
@ -805,7 +806,7 @@ entity facts take one from their definition; the label then carries no hover tex
#### Scenario: Sortable column header
- **GIVEN** a sortable column header with an info tip
- **WHEN** the user activates the info icon
- **THEN** the tooltip shows and the sort does not change; activating the header label still sorts
- **THEN** the tooltip shows and the sort does not change; activating the header label sorts
#### Scenario: Tile that links to a list
- **GIVEN** an Overview live tile that opens a filtered list and has an info tip

View File

@ -16,7 +16,7 @@ reads two ways: Participant sessions, Unique identities, Services, Data up / dow
- **WHEN** the user opens the info tip of a "Participant sessions" or "Unique identities" label on any page
- **THEN** Participant sessions reads "Each time someone joins a room starts a participant session; leaving and joining again starts another. Only regular participants count: egress, ingress and agents are services." and Unique identities reads "Distinct participant identities among those sessions: someone who joined three times counts once."
#### Scenario: Services and quality
#### Scenario: Services
- **WHEN** the user opens the info tip of a "Services" label
- **THEN** it reads "Egress, ingress, agent and other non-user sessions. They join rooms as participants but are not

View File

@ -8,32 +8,31 @@ keyboard, responsive and accessibility rules that every page inherits.
## Requirements
### Requirement: Route configs with navigation metadata
CE. The UI library SHALL export its routes as route configs whose navigation metadata (label, icon, path,
order, feature key, live-capable, range-scoped) drives the sidenav, the header controls and the breadcrumbs.
A shell composes routes from one or more config arrays; a later array replaces an earlier route with the same
path. The shells SHALL contain no page logic.
Edition: CE. The UI library SHALL export its routes as route configs whose navigation metadata (label, icon, path,
order, feature key, live-capable, range-scoped, timed, retention) drives the sidenav, the header controls and the
breadcrumbs. A shell composes routes from one or more config arrays; a later array replaces an earlier route with the
same path and keeps the metadata keys it does not set. The shells SHALL contain no page logic.
#### Scenario: CE shell composes the library routes
- **GIVEN** the CE shell provides the library route configs
- **WHEN** the application starts
- **THEN** the sidenav lists Overview, Analytics, Rooms, Participants, Agents, Egress, Ingress and Settings in this order, each with its icon and route
#### Scenario: PRO shell overrides a route by path
#### Scenario: Later config overrides a route by path
- **GIVEN** a shell composes the CE configs followed by a config that declares `/rooms/:roomSid`
- **WHEN** the user opens `/rooms/RM_ab12cd`
- **THEN** the component of the later config renders and the navigation metadata of the entry is preserved
### Requirement: Sidenav modes by breakpoint
CE. The sidenav SHALL follow the Material CDK breakpoints: Large and XLarge use side mode expanded to 240 px with
a stored option to pin it as a 72 px rail; Medium uses a 72 px rail with tooltips that the burger expands as an
overlay; Small and XSmall use an over-mode drawer closed by default. The entry of the current section is marked
visually and with `aria-current="page"`, on its list and on every detail page under it, whatever the query
parameters.
Edition: CE. The sidenav SHALL follow the Material CDK breakpoints: Large and XLarge use side mode expanded to 240 px
with a stored option to pin it as a 72 px rail; Medium uses a 72 px rail with tooltips that the burger expands as an
overlay; Small and XSmall use an over-mode drawer closed by default. The entry of the current section is marked visually
and with `aria-current="page"`, on its list and on every detail page under it, whatever the query parameters.
#### Scenario: Rail pinned on desktop
- **GIVEN** a Large viewport
- **WHEN** the user pins the sidenav as a rail
- **THEN** entries show icons with tooltips, the choice persists across reloads and the drawer remains side mode
- **THEN** entries show icons with tooltips, the choice persists across reloads and the sidenav is in side mode
#### Scenario: Drawer on a phone
- **GIVEN** an XSmall viewport
@ -50,12 +49,12 @@ parameters.
Edition: CE. The header SHALL show, left to right: burger, logo and product name linking to Overview, global search
(≤ 400 px wide), the time range picker on range-scoped routes or the zone chip on other unlocked timed pages, refresh
control (only on live-capable routes whose page registered a refresh scheduler, none on a locked page), theme toggle and
user menu (username, Settings, Sign out), the last three vertically aligned, equally spaced; no edition badge.
user menu (username, Settings, Sign out), the last three vertically aligned, equally spaced.
#### Scenario: Header on a detail route without range
- **GIVEN** the route metadata is neither range-scoped nor live-capable
- **WHEN** the page renders
- **THEN** the picker and the refresh control are hidden and the other controls remain
- **THEN** the picker and the refresh control are hidden and the other controls are shown
#### Scenario: Header collapses on narrow viewports
- **GIVEN** an XSmall viewport
@ -114,7 +113,7 @@ Participants, Agents, Egress and Ingress, the daily rollup retention on Analytic
- **THEN** it reads "Data older than 90 days is not retained."
### Requirement: Theme toggle
CE. The theme toggle SHALL cycle system → light → dark, store the choice as a viewer preference and apply it
Edition: CE. The theme toggle SHALL cycle system → light → dark, store the choice as a viewer preference and apply it
without reload. System mode follows `prefers-color-scheme`; the applied scheme is exposed so charts and icons
re-theme in place.
@ -146,10 +145,9 @@ the trail.
- **THEN** they read `Egress / EG_ab12` with one copy button and the identifier appears once
### Requirement: Footer version line
CE. The bottom of the sidenav SHALL show the OpenVidu version ("OpenVidu {version}", the dashboard version), the
edition badge, a Documentation link and, when the server enables it, an OpenAPI link; it shows no server or schema
version, and pages have no footer. In rail mode the sidenav bottom shows the edition badge and a documentation icon
link with a tooltip.
Edition: CE. The bottom of the sidenav SHALL show only the OpenVidu version ("OpenVidu {version}", the dashboard
version, linking to About), the edition badge, a Documentation link and, when the server enables it, an OpenAPI link.
In rail mode the sidenav bottom shows the short edition badge and a documentation icon link with a tooltip.
#### Scenario: Version line reflects the info endpoint
- **GIVEN** `GET /api/v1/info` reports dashboard 3.10.0, edition CE, server 3.10.0, schema 1 and `openapiDocsEnabled: false`
@ -162,10 +160,10 @@ link with a tooltip.
- **THEN** an OpenAPI link to `api/v1/docs` follows the Documentation link
### Requirement: Banner slot
CE. A non-blocking banner slot under the header SHALL show, on every page, migration progress, schema-version
Edition: CE. A non-blocking banner slot under the header SHALL show, on every page, migration progress, schema-version
warnings and the silent writer warning reported by `GET /api/v1/status`, and connectivity errors reported by failed
requests; each banner is dismissible except the schema mismatch warning. The banners follow every response of the
server status poll.
requests; each banner is dismissible except the schema warnings (mismatch and uninitialized database). The banners
follow every response of the server status poll.
#### Scenario: Migration running
- **GIVEN** the status reports a migration in progress
@ -183,11 +181,10 @@ server status poll.
- **THEN** the banner disappears within 10 s without a reload
### Requirement: Login page
CE. `/login` SHALL show username and password (with a show/hide toggle), submit on Enter, show the edition and
Edition: CE. `/login` SHALL show username and password (with a show/hide toggle), submit on Enter, show the edition and
version (when the info endpoint exposes them without a session) and a documentation link, and never store
credentials. Errors are inline
text: invalid credentials (focus back to username, password cleared), rate limited (`429` with a countdown),
server unreachable (banner with retry).
credentials. Errors are inline text: invalid credentials (focus back to username, password cleared), rate limited
(`429` with a countdown), server unreachable (banner with retry).
#### Scenario: Successful sign-in redirects
- **GIVEN** `/login?redirect=/rooms?status=active`
@ -205,7 +202,7 @@ server unreachable (banner with retry).
- **THEN** the application redirects to `redirect` or `/overview`
### Requirement: Session handling in the browser
CE. The shell SHALL implement the "Frontend session flow" of the `auth` capability
Edition: CE. The shell SHALL implement the "Frontend session flow" of the `auth` capability
through one HTTP interceptor and the auth service: in-memory token, bearer header, single shared refresh on
`401` with one replay, silent restore on load, redirect to `/login?redirect=` on failure, nothing in browser
storage. In addition, Sign out SHALL call the logout endpoint, clear the in-memory session and navigate to
@ -222,7 +219,8 @@ storage. In addition, Sign out SHALL call the logout endpoint, clear the in-memo
- **THEN** the application navigates to `/login?redirect=%2Fsettings%3Ftab%3Dabout`
### Requirement: Catch-all route
CE. Any unknown path SHALL redirect to `/overview` when authenticated and to `/login?redirect=<path>` otherwise.
Edition: CE. Any unknown path SHALL redirect to `/overview` when authenticated and to `/login?redirect=<path>`
otherwise.
#### Scenario: Unknown path while signed out
- **GIVEN** no session
@ -236,14 +234,14 @@ makes no request to the locked endpoint. Full-page locks keep header and breadcr
silhouette of three tiles and one block, with no refresh control in the header; in-card locks size to their message.
#### Scenario: Room detail in CE
- **GIVEN** the info endpoint reports `roomDetail` disabled
- **WHEN** the user follows a room name link
- **GIVEN** the info endpoint does not report the feature `room-detail`
- **WHEN** the user follows a room SID link of the Rooms list
- **THEN** `/rooms/RM_ab12cd` shows the room header from the list data and the full-page lock, and no detail request is sent
#### Scenario: Locked sidenav entry
- **GIVEN** a sidenav entry whose route declares a feature that `GET /api/v1/info` does not report
- **WHEN** the sidenav renders
- **THEN** the entry shows a lock icon with a tooltip and opens the full-page lock; in 3.10 no sidenav entry declares a feature, so the CE sidenav shows no lock
- **THEN** the entry shows a lock icon with a tooltip and opens the full-page lock; no sidenav entry of the CE routes declares a feature, so the CE sidenav shows no lock
#### Scenario: Compact in-card lock
- **GIVEN** the CE Analytics page
@ -251,23 +249,24 @@ silhouette of three tiles and one block, with no refresh control in the header;
- **THEN** each locked card is as tall as its lock message, not as the chart it replaces
### Requirement: 403 PRO_FEATURE response
CE. When a request returns `403` with code `PRO_FEATURE`, the UI SHALL render the lock for that feature instead
Edition: CE. When a request returns `403` with code `PRO_FEATURE`, the UI SHALL render the lock for that feature instead
of an error banner and refresh its cached feature flags.
#### Scenario: Stale feature flags
- **GIVEN** the cached flags enable a feature the server now reports as PRO-only
- **GIVEN** the cached flags enable a feature that the server reports as PRO-only
- **WHEN** the request returns `403 PRO_FEATURE`
- **THEN** the page shows the lock and the info endpoint is re-read
### Requirement: URL query parameters as the single source of truth
CE. List and chart state SHALL live only in the query parameters `from`, `to`, `tz`, `granularity`, `scope`, `status`,
`kind`, `features`, `nodeId`, `room`, `type`, `inputType`, `q`, `sort`, `order`, `limit`, `cursor` and `page`, read by
components as signals with no parallel state. Invalid values fall back to defaults silently. Every change is a new history
entry merged into the current parameters; changing a filter, sort, range or `limit` removes `cursor` and `page`.
Edition: CE. List and chart state SHALL live only in the query parameters `from`, `to`, `tz`, `granularity`, `scope`,
`status`, `kind`, `publisher`, `features`, `nodeId`, `room`, `type`, `inputType`, `q`, `sort`, `order`, `limit`,
`cursor` and `page`, read by components as signals with no parallel state. Invalid values fall back to defaults
silently. Every change is a new history entry merged into the current parameters; changing a filter, sort, range or
`limit` removes `cursor` and `page`.
#### Scenario: Link reproduces a view
- **GIVEN** `/rooms?from=now-7d&to=now&tz=UTC&status=active&sort=participants&order=desc&limit=50`
- **GIVEN** `/rooms?from=now-7d&to=now&tz=UTC&status=active&sort=uniqueIdentities&order=desc&limit=50`
- **WHEN** the URL is opened in a new browser
- **THEN** the page shows the same range, filter, sort and page size
@ -282,7 +281,7 @@ entry merged into the current parameters; changing a filter, sort, range or `lim
- **THEN** the new URL has the new status, no `cursor` and no `page`, and Back returns to the third page
### Requirement: Relative range serialisation
CE. Presets SHALL be serialised as relative expressions and kept relative in the URL: `now`, `now-<n><unit>`
Edition: CE. Presets SHALL be serialised as relative expressions and kept relative in the URL: `now`, `now-<n><unit>`
(`m`, `h`, `d`) and `now/M` (start of the current month in `tz`). Absolute values are ISO 8601. The UI
resolves relative values to absolute instants at request time so bookmarks stay fresh.
@ -318,8 +317,8 @@ this memory: it follows the session zone of the Timezone requirement.
- **THEN** the participants list opens with the default preset and `tz=UTC`, the zone kept by the detail's URL
### Requirement: Back navigation restores list state
CE. Navigating from a list to a detail and back SHALL restore the list's URL state, including `cursor`, `page` and
`limit`, so the same page, rows and "Showing x–y" range are shown; reloading the URL shows the same.
Edition: CE. Navigating from a list to a detail and back SHALL restore the list's URL state, including `cursor`, `page`
and `limit`, so the same page, rows and "Showing x–y" range are shown; reloading the URL shows the same.
#### Scenario: Detail and back
- **GIVEN** `/egress?cursor=abc&page=3&limit=25`
@ -327,7 +326,7 @@ CE. Navigating from a list to a detail and back SHALL restore the list's URL sta
- **THEN** the list shows the page for `cursor=abc` with 25 rows and reads "Showing 51–75"
### Requirement: Page size preference
CE. `limit` SHALL accept 10, 25, 50 or 100; the URL value wins, otherwise the stored preference (default 25)
Edition: CE. `limit` SHALL accept 10, 25, 50 or 100; the URL value wins, otherwise the stored preference (default 25)
applies. Changing the page size writes both the URL and the preference and drops the cursor.
#### Scenario: Preference applied
@ -361,7 +360,8 @@ chip change it; it shows in them, in chart notes and in the header tooltip of ti
Edition: CE. Live-capable routes other than Analytics SHALL offer intervals off/10 s/30 s/1 min/5 min, default 10 s,
stored as one viewer preference for all live views, never in the URL. Analytics has no auto-refresh: it loads when
opened, on each URL state change and on the manual refresh button. A live view that cannot poll now keeps the preference.
opened, on each URL state change and on the manual refresh button. A live view paused with a reason keeps the stored
preference.
#### Scenario: Hidden tab
- **GIVEN** a list refreshing every 10 s
@ -393,7 +393,7 @@ opened, on each URL state change and on the manual refresh button. A live view t
- **WHEN** it polls
- **THEN** a tick is skipped while a request is in flight, a request unanswered after 30 s fails, failures back off 10 →
20 → 40 s up to 5 min and show the banner once, and refresh pauses while the tab is hidden, resumes with an immediate
fetch, and stops on route leave or when the entity closes
fetch, and stops on route leave or while the page is paused with a reason
### Requirement: API read timeout
@ -414,7 +414,7 @@ too long to answer; the data shown may be out of date." and a later refresh send
long to answer; the data shown may be out of date." with Retry
### Requirement: M3 theme tokens
CE. The theme SHALL derive from the OpenVidu palette (seed `#0088aa`, accent `#00c8f7`) with the light and dark
Edition: CE. The theme SHALL derive from the OpenVidu palette (seed `#0088aa`, accent `#00c8f7`) with the light and dark
token values: `primary` `#006b86` / `#5fd4fb`, `tertiary` `#00677f` / `#00c8f7`, surfaces
`#f8fafb` / `#0f1416`, `on-surface` `#171c1e` / `#dfe3e6`, error `#ba1a1a` / `#ffb4ab`. Every text pair meets
4.5:1 and UI pairs 3:1; `#00c8f7` is never text on light surfaces; the seed is used only for non-text accents.
@ -425,9 +425,9 @@ token values: `primary` `#006b86` / `#5fd4fb`, `tertiary` `#00677f` / `#00c8f7`,
- **THEN** its colour is `#006b86` with contrast ≥ 4.5:1
### Requirement: Typography, density and icons
CE. Fonts SHALL be Inter (variable, tabular numerals for figures) and JetBrains Mono for SIDs, JSON and code,
served from the application bundle. Icons are Material Symbols Rounded served as a local SVG subset. No asset
loads from a CDN. Density is -1 on fine pointers and 0 on coarse pointers; minimum text size 12 px.
Edition: CE. Fonts SHALL be Inter (variable, tabular numerals for figures) and JetBrains Mono for SIDs and other
identifiers, served from the application bundle. Icons are Material Symbols Rounded served as a local SVG subset. No
asset loads from a CDN. Density is -1 on fine pointers and 0 on coarse pointers; minimum text size 12 px.
#### Scenario: Offline deployment
- **GIVEN** a deployment with no outbound internet access
@ -449,10 +449,10 @@ English formatting, and enum values and event types read their labels from one s
- **THEN** its texts are in English and a count of 1203 reads "1,203"
### Requirement: Keyboard navigation and focus order
CE. Focus order SHALL be skip link → header → sidenav → breadcrumb → page actions → content. `/` focuses the
global search; Escape closes drawers, menus and the JSON drawer and returns focus to the trigger; a route change
moves focus to the page `h1` and announces its title. Every interactive element shows a 2 px primary focus ring
with 2 px offset; chart canvases are focusable with a text summary.
Edition: CE. Focus order SHALL be skip link → header → sidenav → breadcrumb → page actions → content. `/` focuses the
global search; Escape closes drawers and menus and returns focus to the trigger; a route change moves focus to the page
`h1` and announces its title. Every interactive element shows a 2 px primary focus ring with 2 px offset; chart
canvases are focusable with a text summary.
#### Scenario: Slash focuses search
- **GIVEN** focus is in the content area
@ -465,7 +465,7 @@ with 2 px offset; chart canvases are focusable with a text summary.
- **THEN** focus lands on its `h1` and the title is announced
### Requirement: Landmarks and reduced motion
CE. Each page SHALL expose `banner`, `navigation` and `main` landmarks and exactly one `h1`;
Edition: CE. Each page SHALL expose `banner`, `navigation` and `main` landmarks and exactly one `h1`;
toasts and refresh updates use polite live regions. With `prefers-reduced-motion`, pulsing live dots, chart
animations and value highlights are disabled.
@ -492,10 +492,10 @@ top.
- **THEN** they span the full width at their 280 px height
### Requirement: Server status poll
CE. While signed in, the shell SHALL request `GET /api/v1/status` at once and then every 30 s, or every 10 s while the
migration runs or the schema is uninitialized, on every page and whatever the auto-refresh preference. The poll pauses
while the tab is hidden and fetches at once on return, backs off on failures like the page refresh, and does not
appear in the refresh control.
Edition: CE. While signed in, the shell SHALL request `GET /api/v1/status` at once and then every 30 s, or every 10 s
while the migration runs or the schema is uninitialized, on every page and whatever the auto-refresh preference. The
poll pauses while the tab is hidden and fetches at once on return, backs off on failures like the page refresh, and does
not appear in the refresh control.
#### Scenario: Auto-refresh off
- **GIVEN** the auto-refresh set to Off on the Rooms page
@ -517,8 +517,8 @@ appear in the refresh control.
- **THEN** no further status request is sent
### Requirement: Info re-read on server changes
CE. The shell SHALL read `GET /api/v1/info` at start and after sign-in, and read it again once when a status reports a
schema status or a migration state different from the last info document; it SHALL NOT poll the info endpoint.
Edition: CE. The shell SHALL read `GET /api/v1/info` at start and after sign-in, and read it again once when a status
reports a schema status or a migration state different from the last info document; it SHALL NOT poll the info endpoint.
#### Scenario: Migration ends
- **GIVEN** an info document with a running migration