openvidu-dashboard: the Participants and Agents pages share their sessions table and URL state

injectParticipantSessions() holds the search, status, room, ranked sort and columns of both lists, and
ovd-participant-sessions-table their cells and empty states. The Agents status badge gains the stale hint.
pull/924/head
juancarmore 2026-10-26 14:46:12 +01:00
parent 9571f1b3f0
commit 2461c21181
12 changed files with 362 additions and 483 deletions

View File

@ -25,7 +25,7 @@
<mat-form-field class="status" subscriptSizing="dynamic">
<mat-label>Status</mat-label>
<mat-select [value]="status()" (selectionChange)="setStatus($event.value)">
<mat-select [value]="sessions.status()" (selectionChange)="sessions.setStatus($event.value)">
<mat-option [value]="null">All</mat-option>
@for (status of statuses; track status) {
<mat-option [value]="status">{{ statusLabels[status] }}</mat-option>
@ -33,88 +33,30 @@
</mat-select>
</mat-form-field>
@if (room(); as room) {
@if (sessions.room(); as room) {
<div class="ovd-filter-slot"><ovd-room-chip [sid]="room" /></div>
}
</div>
<ovd-data-table
<ovd-participant-sessions-table
tableId="agents"
caption="Agents"
[columns]="columns()"
noun="agents"
[columns]="sessions.columns()"
[rows]="rows()"
[trackBy]="trackAgent"
[sort]="sort()"
[order]="order()"
[sort]="sessions.sort()"
[order]="sessions.order()"
[loading]="loading()"
[error]="error()"
[pageSize]="limit()"
(sortChange)="setSort($event)"
[exactId]="sessions.exactId()"
[time]="sessions.time"
(sortChange)="sessions.setSort($event)"
(retry)="retry()"
>
<ng-template ovdCell="sid" let-item>
<span class="cell">
<a class="ovd-mono" [routerLink]="['/participants', item.sid]">{{ item.sid }}</a>
<ovd-copy-button [value]="item.sid" label="participant SID" />
</span>
</ng-template>
<ng-template ovdCell="identity" let-item>
<span class="cell">
{{ item.identity }}
<ovd-copy-button [value]="item.identity" label="identity" />
</span>
</ng-template>
<ng-template ovdCell="name" let-item>{{ item.name ?? '—' }}</ng-template>
<ng-template ovdCell="room" let-item>
<a [routerLink]="['/rooms', item.roomSid]">{{ item.roomName }}</a>
</ng-template>
<ng-template ovdCell="status" let-item>
<ovd-status-badge [status]="item.status" domain="participant" [stale]="item.stale === true" />
</ng-template>
<ng-template ovdCell="joinedAt" let-item>
<time class="ovd-tabular" [attr.datetime]="item.joinedAt">{{ time(item.joinedAt) }}</time>
</ng-template>
<ng-template ovdCell="leftAt" let-item>
@if (item.leftAt) {
<time class="ovd-tabular" [attr.datetime]="item.leftAt">{{ time(item.leftAt) }}</time>
} @else {
<span aria-label="Not left">—</span>
}
</ng-template>
<ng-template ovdCell="duration" let-item>
<ovd-duration
[start]="item.joinedAt"
[end]="item.leftAt ?? null"
[storedMs]="item.durationMs ?? null"
[running]="item.leftAt === undefined"
/>
</ng-template>
<ng-template ovdCell="published" let-item>
@if (item.publishedSources.length > 0) {
<span class="cell">
@for (source of item.publishedSources; track source) {
<ovd-source-icon [source]="source" />
}
</span>
} @else {
—
}
</ng-template>
<ng-template ovdCell="node" let-item>
<span class="ovd-mono">{{ item.nodeId ?? '—' }}</span>
</ng-template>
@if (exactId(); as sid) {
<ovd-empty-state ovdTableEmpty [heading]="'Participant ' + sid + ' was not found'">
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearSearch()">Clear search</button>
</ovd-empty-state>
} @else {
<ovd-empty-state ovdTableEmpty heading="No agents match" hint="Change the filters or widen the time range.">
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearFilters()">Clear filters</button>
<button matButton="outlined" type="button" ovdEmptyAction (click)="widenRange()">Widen range</button>
</ovd-empty-state>
}
</ovd-data-table>
(clearSearch)="sessions.clearSearch()"
(clearFilters)="clearFilters()"
(widenRange)="sessions.widenRange()"
/>
<ovd-cursor-paginator
[limit]="limit()"

View File

@ -13,10 +13,3 @@
.status {
width: 180px;
}
.cell {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}

View File

@ -153,7 +153,9 @@ describe('AgentsPage', () => {
'Change the filters or widen the time range.'
);
(harness.routeDebugElement?.componentInstance as { setStatus(value: string): void }).setStatus('active');
(
harness.routeDebugElement?.componentInstance as { sessions: { setStatus(value: string): void } }
).sessions.setStatus('active');
await harness.fixture.whenStable();
const url = TestBed.inject(Router).url;

View File

@ -1,52 +1,29 @@
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
import { RouterLink } from '@angular/router';
import {
type ListResponse,
type ParticipantListItem,
type ParticipantsSummary,
ParticipantSort,
ParticipantStatus
} from '@openvidu-dashboard/typings';
import { type ListResponse, type ParticipantListItem, type ParticipantsSummary } from '@openvidu-dashboard/typings';
import { RoomChip } from '../../components/badges/room-chip.js';
import { SourceIcon } from '../../components/badges/source-icon.js';
import { StatusBadge } from '../../components/badges/status-badge.js';
import { CopyButton } from '../../components/copy-button/copy-button.js';
import { CursorPaginator } from '../../components/data-table/cursor-paginator.js';
import { DataTable, type SortChange, TableCell, type TableColumn } from '../../components/data-table/data-table.js';
import { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js';
import { Duration } from '../../components/duration/duration.js';
import { EmptyState } from '../../components/feedback/empty-state.js';
import { Notice } from '../../components/feedback/notice.js';
import { exactListId, ListSearch, listSearchQuery } from '../../components/search/list-search.js';
import { ListSearch } from '../../components/search/list-search.js';
import { KpiTile } from '../../components/tiles/kpi-tile.js';
import { TileRow } from '../../components/tiles/tile-row.js';
import { StorageService } from '../../core/preferences/storage.js';
import { injectListLoader } from '../../core/refresh/list-loader.js';
import { LiveCountersService } from '../../core/refresh/live-counters.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js';
import { formatNumber, formatTimestamp } from '../../format/formatters.js';
import { formatNumber } from '../../format/formatters.js';
import { injectLiveCounters } from '../overview/overview-data.js';
import { participantPreview } from '../participants/participant-display.js';
import {
injectParticipantSessions,
PARTICIPANT_STATUS_LABELS,
PARTICIPANT_STATUSES
} from '../participants/participant-sessions.js';
import { ParticipantSessionsTable } from '../participants/participant-sessions-table/participant-sessions-table.js';
import { type AgentsListRequest, ParticipantsDataService } from '../participants/participants-data.js';
import { isRoomSid } from '../rooms/room-display.js';
const STATUSES = Object.values(ParticipantStatus);
const SORTS = Object.values(ParticipantSort);
const STATUS_LABELS: Readonly<Record<ParticipantStatus, string>> = {
joining: 'Joining',
active: 'Active',
left: 'Left',
aborted: 'Aborted'
};
/** Sorts ranked in memory over the range, with their column names. */
const RANKED_SORTS: Readonly<Partial<Record<ParticipantSort, string>>> = { leftAt: 'Left', duration: 'Duration' };
const NOTICE_KEY = 'ovd.agents-notice-dismissed';
interface AgentsData {
@ -58,24 +35,17 @@ interface AgentsData {
@Component({
selector: 'ovd-agents-page',
imports: [
CopyButton,
CursorPaginator,
DataTable,
Duration,
EmptyState,
KpiTile,
ListSearch,
MatButton,
MatFormField,
MatLabel,
MatOption,
MatSelect,
Notice,
ParticipantSessionsTable,
RoomChip,
RouterLink,
SourceIcon,
StatusBadge,
TableCell,
TileRow
],
templateUrl: './agents-page.html',
@ -85,58 +55,23 @@ export class AgentsPage {
private readonly data = inject(ParticipantsDataService);
private readonly query = inject(QueryStateService);
private readonly listState = inject(ListStateService);
private readonly previews = inject(EntityPreviewService);
private readonly rangePicker = inject(RangePickerService);
private readonly storage = inject(StorageService);
private readonly limits = injectRankedSortLimits();
protected readonly statuses = STATUSES;
protected readonly statusLabels = STATUS_LABELS;
protected readonly tz = this.query.tz;
protected readonly statuses = PARTICIPANT_STATUSES;
protected readonly statusLabels = PARTICIPANT_STATUS_LABELS;
protected readonly limit = this.query.limit;
protected readonly sessions = injectParticipantSessions({ kindColumn: false });
protected readonly activeAgents = inject(LiveCountersService).activeAgents;
protected readonly noticeOpen = signal(this.storage.get(NOTICE_KEY) !== 'true');
protected readonly participantsLink = computed(() => this.listState.withRange({ kind: 'AGENT' }));
protected readonly q = computed(() => listSearchQuery(this.query.q(), 'participant'));
protected readonly exactId = computed(() => exactListId(this.q(), 'participant'));
protected readonly status = computed(() => {
const value = this.query.value('status');
return STATUSES.includes(value as ParticipantStatus) ? (value as ParticipantStatus) : null;
});
protected readonly room = computed(() => {
const value = this.query.value('room');
return value !== null && isRoomSid(value) ? value : null;
});
private readonly rangeMs = computed(() => {
const resolved = resolveRange(this.listState.range(), new Date());
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime();
});
/** The ranked sorts apply up to the participants limit, or with any range inside one room. */
private readonly sortReasons = computed(() =>
rankedSortReasons(
RANKED_SORTS,
this.rangeMs(),
this.room() === null ? this.limits()?.participantRankedSortMaxRangeMs : undefined
)
);
private readonly urlSort = computed(() => this.query.oneOf('sort', SORTS, ParticipantSort.JOINED_AT));
/** The URL's sort, or Joined descending when the range is too wide for it. */
protected readonly sort = computed<ParticipantSort>(() =>
this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : ParticipantSort.JOINED_AT
);
protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc'));
private readonly request = computed<AgentsListRequest>(() => ({
range: this.listState.range(),
q: this.q(),
status: this.status(),
room: this.room(),
sort: this.sort(),
order: this.order(),
q: this.sessions.q(),
status: this.sessions.status(),
room: this.sessions.room(),
sort: this.sessions.sort(),
order: this.sessions.order(),
cursor: this.query.cursor(),
limit: this.limit()
}));
@ -152,14 +87,7 @@ export class AgentsPage {
this.data.summary(request.range).catch(() => null)
]);
for (const item of page.items) {
this.previews.set('participant', item.sid, participantPreview(item));
if (this.previews.get('room', item.roomSid) === undefined) {
this.previews.set('room', item.roomSid, { name: item.roomName });
}
}
this.sessions.remember(page.items);
this.lastSummary = summary ?? this.lastSummary;
return { page, summary: this.lastSummary };
@ -181,45 +109,6 @@ export class AgentsPage {
});
protected readonly approximate = computed(() => this.summary()?.approximate === true);
protected readonly rows = computed(() => this.page()?.items ?? []);
protected readonly columns = computed<TableColumn[]>(() => {
const reasons = this.sortReasons();
const ranked = (sort: ParticipantSort) => ({
sortable: true,
...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] })
});
return [
{ key: 'sid', header: 'SID', hideable: false, card: 'title' },
{ key: 'identity', header: 'Identity' },
{ key: 'name', header: 'Name' },
{ key: 'room', header: 'Room', card: 'metric' },
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: ParticipantSort.JOINED_AT,
header: 'Joined',
sortable: true,
hideable: false,
card: 'timestamp',
headerHint: this.tz()
},
{
key: ParticipantSort.LEFT_AT,
header: 'Left',
headerHint: this.tz(),
...ranked('leftAt')
},
{
key: ParticipantSort.DURATION,
header: 'Duration',
align: 'end',
card: 'metric',
...ranked('duration')
},
{ key: 'published', header: 'Published', card: 'metric' },
{ key: 'node', header: 'Node', defaultHidden: true }
];
});
protected readonly trackAgent = (item: ParticipantListItem): string => item.sid;
constructor() {
injectLiveCounters();
@ -246,36 +135,15 @@ export class AgentsPage {
return count === null ? null : formatNumber(count);
}
/** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */
protected time(value: string): string {
return formatTimestamp(value, { tz: this.tz(), now: new Date(), rangeMs: this.rangeMs() });
}
protected setStatus(status: ParticipantStatus | null): void {
void this.query.navigate({ status });
}
protected setSort(change: SortChange): void {
void this.query.navigate({ sort: change.key, order: change.order });
}
protected clearFilters(): void {
void this.query.navigate({ q: null, status: null, room: null });
}
protected clearSearch(): void {
void this.query.navigate({ q: null });
}
protected dismissNotice(): void {
this.noticeOpen.set(false);
this.storage.set(NOTICE_KEY, 'true');
}
protected widenRange(): void {
this.rangePicker.open();
}
protected retry(): void {
void this.loader.scheduler.refresh();
}

View File

@ -0,0 +1,89 @@
<ovd-data-table
[tableId]="tableId()"
[caption]="caption()"
[columns]="columns()"
[rows]="rows()"
[trackBy]="trackSession"
[sort]="sort()"
[order]="order()"
[loading]="loading()"
[error]="error()"
[pageSize]="pageSize()"
(sortChange)="sortChange.emit($event)"
(retry)="retry.emit()"
>
<ng-template ovdCell="sid" let-item>
<span class="cell">
<a class="ovd-mono" [routerLink]="['/participants', item.sid]">{{ item.sid }}</a>
<ovd-copy-button [value]="item.sid" label="participant SID" />
</span>
</ng-template>
<ng-template ovdCell="identity" let-item>
<span class="cell">
{{ item.identity }}
<ovd-copy-button [value]="item.identity" label="identity" />
</span>
</ng-template>
<ng-template ovdCell="name" let-item>{{ item.name ?? '—' }}</ng-template>
<ng-template ovdCell="room" let-item>
<a [routerLink]="['/rooms', item.roomSid]">{{ item.roomName }}</a>
</ng-template>
<ng-template ovdCell="kind" let-item><ovd-kind-badge [kind]="item.kind" /></ng-template>
<ng-template ovdCell="status" let-item>
<ovd-status-badge
[status]="item.status"
domain="participant"
[stale]="item.stale === true"
[hint]="staleHint(item)"
/>
</ng-template>
<ng-template ovdCell="joinedAt" let-item>
<time class="ovd-tabular" [attr.datetime]="item.joinedAt">{{ time()(item.joinedAt) }}</time>
</ng-template>
<ng-template ovdCell="leftAt" let-item>
@if (item.leftAt) {
<time class="ovd-tabular" [attr.datetime]="item.leftAt">{{ time()(item.leftAt) }}</time>
} @else {
<span aria-label="Not left">—</span>
}
</ng-template>
<ng-template ovdCell="duration" let-item>
<ovd-duration
[start]="item.joinedAt"
[end]="item.leftAt ?? null"
[storedMs]="item.durationMs ?? null"
[running]="item.leftAt === undefined"
/>
</ng-template>
<ng-template ovdCell="published" let-item>
@if (item.publishedSources.length > 0) {
<span class="sources">
@for (source of item.publishedSources; track source) {
<ovd-source-icon [source]="source" />
}
</span>
} @else {
—
}
</ng-template>
<ng-template ovdCell="node" let-item>
<span class="ovd-mono">{{ item.nodeId ?? '—' }}</span>
</ng-template>
@if (exactId(); as sid) {
<ovd-empty-state ovdTableEmpty [heading]="'Participant ' + sid + ' was not found'">
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearSearch.emit()">Clear search</button>
</ovd-empty-state>
} @else {
<ovd-empty-state
ovdTableEmpty
[heading]="'No ' + noun() + ' match'"
hint="Change the filters or widen the time range."
>
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearFilters.emit()">
Clear filters
</button>
<button matButton="outlined" type="button" ovdEmptyAction (click)="widenRange.emit()">Widen range</button>
</ovd-empty-state>
}
</ovd-data-table>

View File

@ -0,0 +1,11 @@
:host {
display: block;
}
.cell,
.sources {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}

View File

@ -0,0 +1,60 @@
import { Component, input, output } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { RouterLink } from '@angular/router';
import type { ParticipantListItem, SortOrder } from '@openvidu-dashboard/typings';
import { KindBadge } from '../../../components/badges/kind-badge.js';
import { SourceIcon } from '../../../components/badges/source-icon.js';
import { StatusBadge } from '../../../components/badges/status-badge.js';
import { CopyButton } from '../../../components/copy-button/copy-button.js';
import { DataTable, type SortChange, TableCell, type TableColumn } from '../../../components/data-table/data-table.js';
import { Duration } from '../../../components/duration/duration.js';
import { EmptyState } from '../../../components/feedback/empty-state.js';
/** The table of a list of participant sessions, with its cells and its empty states. */
@Component({
selector: 'ovd-participant-sessions-table',
imports: [
CopyButton,
DataTable,
Duration,
EmptyState,
KindBadge,
MatButton,
RouterLink,
SourceIcon,
StatusBadge,
TableCell
],
templateUrl: './participant-sessions-table.html',
styleUrl: './participant-sessions-table.scss'
})
export class ParticipantSessionsTable {
readonly tableId = input.required<string>();
readonly caption = input.required<string>();
readonly columns = input.required<readonly TableColumn[]>();
readonly rows = input.required<readonly ParticipantListItem[]>();
readonly sort = input.required<string>();
readonly order = input.required<SortOrder>();
readonly loading = input(false);
readonly error = input<unknown>(null);
readonly pageSize = input.required<number>();
/** The exact participant SID searched for, which gets its own empty state. */
readonly exactId = input<string | null>(null);
/** What the list holds, for its empty state: "No participants match". */
readonly noun = input.required<string>();
readonly time = input.required<(value: string) => string>();
readonly sortChange = output<SortChange>();
readonly retry = output();
readonly clearSearch = output();
readonly clearFilters = output();
readonly widenRange = output();
protected readonly trackSession = (item: ParticipantListItem): string => item.sid;
/** Hint of an active session the list response marks stale. */
protected staleHint(item: ParticipantListItem): string | undefined {
return item.stale === true ? 'No recent stats from this session' : undefined;
}
}

View File

@ -0,0 +1,135 @@
import { computed, inject } from '@angular/core';
import { type ParticipantListItem, ParticipantSort, ParticipantStatus } from '@openvidu-dashboard/typings';
import type { SortChange, TableColumn } from '../../components/data-table/data-table.js';
import { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js';
import { exactListId, listSearchQuery } from '../../components/search/list-search.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js';
import { formatTimestamp } from '../../format/formatters.js';
import { isRoomSid } from '../rooms/room-display.js';
import { participantPreview } from './participant-display.js';
export const PARTICIPANT_STATUSES = Object.values(ParticipantStatus);
export const PARTICIPANT_STATUS_LABELS: Readonly<Record<ParticipantStatus, string>> = {
joining: 'Joining',
active: 'Active',
left: 'Left',
aborted: 'Aborted'
};
const SORTS = Object.values(ParticipantSort);
/** Sorts ranked in memory over the range, with their column names. */
const RANKED_SORTS: Readonly<Partial<Record<ParticipantSort, string>>> = { leftAt: 'Left', duration: 'Duration' };
/**
* The URL state of a list of participant sessions (search, status, room, range and sort), its columns and actions,
* shared by the Participants and Agents pages. Call it in an injection context.
*/
export function injectParticipantSessions(options: { kindColumn: boolean }) {
const query = inject(QueryStateService);
const listState = inject(ListStateService);
const previews = inject(EntityPreviewService);
const rangePicker = inject(RangePickerService);
const limits = injectRankedSortLimits();
const q = computed(() => listSearchQuery(query.q(), 'participant'));
const room = computed(() => {
const value = query.value('room');
return value !== null && isRoomSid(value) ? value : null;
});
const rangeMs = computed(() => {
const resolved = resolveRange(listState.range(), new Date());
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime();
});
/** The ranked sorts apply up to the participants limit, or with any range inside one room. */
const sortReasons = computed(() =>
rankedSortReasons(
RANKED_SORTS,
rangeMs(),
room() === null ? limits()?.participantRankedSortMaxRangeMs : undefined
)
);
const urlSort = computed(() => query.oneOf('sort', SORTS, ParticipantSort.JOINED_AT));
/** The URL's sort, or Joined descending when the range is too wide for it. */
const sort = computed<ParticipantSort>(() =>
sortReasons()[urlSort()] === undefined ? urlSort() : ParticipantSort.JOINED_AT
);
return {
q,
exactId: computed(() => exactListId(q(), 'participant')),
status: computed(() => {
const value = query.value('status');
return PARTICIPANT_STATUSES.includes(value as ParticipantStatus) ? (value as ParticipantStatus) : null;
}),
room,
sort,
order: computed(() => (sort() === urlSort() ? query.order() : 'desc')),
columns: computed<TableColumn[]>(() => {
const reasons = sortReasons();
const ranked = (key: ParticipantSort) => ({
sortable: true,
...(reasons[key] === undefined ? {} : { sortDisabledReason: reasons[key] })
});
return [
{ key: 'sid', header: 'SID', hideable: false, card: 'title' },
{ key: 'identity', header: 'Identity' },
{ key: 'name', header: 'Name' },
{ key: 'room', header: 'Room', card: 'metric' },
...(options.kindColumn ? [{ key: 'kind', header: 'Kind' }] : []),
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: ParticipantSort.JOINED_AT,
header: 'Joined',
sortable: true,
hideable: false,
card: 'timestamp',
headerHint: query.tz()
},
{ key: ParticipantSort.LEFT_AT, header: 'Left', headerHint: query.tz(), ...ranked('leftAt') },
{
key: ParticipantSort.DURATION,
header: 'Duration',
align: 'end',
card: 'metric',
...ranked('duration')
},
{ key: 'published', header: 'Published', card: 'metric' },
{ key: 'node', header: 'Node', defaultHidden: true }
];
}),
/** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */
time: (value: string): string =>
formatTimestamp(value, { tz: query.tz(), now: new Date(), rangeMs: rangeMs() }),
/** Keeps the participant and room headers of the listed sessions for their detail pages. */
remember(items: readonly ParticipantListItem[]): void {
for (const item of items) {
previews.set('participant', item.sid, participantPreview(item));
if (previews.get('room', item.roomSid) === undefined) {
previews.set('room', item.roomSid, { name: item.roomName });
}
}
},
setStatus(status: ParticipantStatus | null): void {
void query.navigate({ status });
},
setSort(change: SortChange): void {
void query.navigate({ sort: change.key, order: change.order });
},
clearSearch(): void {
void query.navigate({ q: null });
},
widenRange(): void {
rangePicker.open();
}
};
}

View File

@ -37,7 +37,7 @@
<mat-form-field class="status" subscriptSizing="dynamic">
<mat-label>Status</mat-label>
<mat-select [value]="status()" (selectionChange)="setStatus($event.value)">
<mat-select [value]="sessions.status()" (selectionChange)="sessions.setStatus($event.value)">
<mat-option [value]="null">All</mat-option>
@for (status of statuses; track status) {
<mat-option [value]="status">{{ statusLabels[status] }}</mat-option>
@ -45,7 +45,7 @@
</mat-select>
</mat-form-field>
@if (room(); as room) {
@if (sessions.room(); as room) {
<div class="ovd-filter-slot"><ovd-room-chip [sid]="room" /></div>
}
@if (publisher()) {
@ -60,93 +60,25 @@
}
</div>
<ovd-data-table
<ovd-participant-sessions-table
tableId="participants"
caption="Participants"
[columns]="columns()"
noun="participants"
[columns]="sessions.columns()"
[rows]="rows()"
[trackBy]="trackParticipant"
[sort]="sort()"
[order]="order()"
[sort]="sessions.sort()"
[order]="sessions.order()"
[loading]="loading()"
[error]="error()"
[pageSize]="limit()"
(sortChange)="setSort($event)"
[exactId]="sessions.exactId()"
[time]="sessions.time"
(sortChange)="sessions.setSort($event)"
(retry)="retry()"
>
<ng-template ovdCell="sid" let-item>
<span class="cell">
<a class="ovd-mono" [routerLink]="['/participants', item.sid]">{{ item.sid }}</a>
<ovd-copy-button [value]="item.sid" label="participant SID" />
</span>
</ng-template>
<ng-template ovdCell="identity" let-item>
<span class="cell">
{{ item.identity }}
<ovd-copy-button [value]="item.identity" label="identity" />
</span>
</ng-template>
<ng-template ovdCell="name" let-item>{{ item.name ?? '—' }}</ng-template>
<ng-template ovdCell="room" let-item>
<a [routerLink]="['/rooms', item.roomSid]">{{ item.roomName }}</a>
</ng-template>
<ng-template ovdCell="kind" let-item><ovd-kind-badge [kind]="item.kind" /></ng-template>
<ng-template ovdCell="status" let-item>
<ovd-status-badge
[status]="item.status"
domain="participant"
[stale]="item.stale === true"
[hint]="staleHint(item)"
/>
</ng-template>
<ng-template ovdCell="joinedAt" let-item>
<time class="ovd-tabular" [attr.datetime]="item.joinedAt">{{ time(item.joinedAt) }}</time>
</ng-template>
<ng-template ovdCell="leftAt" let-item>
@if (item.leftAt) {
<time class="ovd-tabular" [attr.datetime]="item.leftAt">{{ time(item.leftAt) }}</time>
} @else {
<span aria-label="Not left">—</span>
}
</ng-template>
<ng-template ovdCell="duration" let-item>
<ovd-duration
[start]="item.joinedAt"
[end]="item.leftAt ?? null"
[storedMs]="item.durationMs ?? null"
[running]="item.leftAt === undefined"
/>
</ng-template>
<ng-template ovdCell="published" let-item>
@if (item.publishedSources.length > 0) {
<span class="sources">
@for (source of item.publishedSources; track source) {
<ovd-source-icon [source]="source" />
}
</span>
} @else {
—
}
</ng-template>
<ng-template ovdCell="node" let-item>
<span class="ovd-mono">{{ item.nodeId ?? '—' }}</span>
</ng-template>
@if (exactId(); as sid) {
<ovd-empty-state ovdTableEmpty [heading]="'Participant ' + sid + ' was not found'">
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearSearch()">Clear search</button>
</ovd-empty-state>
} @else {
<ovd-empty-state
ovdTableEmpty
heading="No participants match"
hint="Change the filters or widen the time range."
>
<button matButton="outlined" type="button" ovdEmptyAction (click)="clearFilters()">Clear filters</button>
<button matButton="outlined" type="button" ovdEmptyAction (click)="widenRange()">Widen range</button>
</ovd-empty-state>
}
</ovd-data-table>
(clearSearch)="sessions.clearSearch()"
(clearFilters)="clearFilters()"
(widenRange)="sessions.widenRange()"
/>
<ovd-cursor-paginator
[limit]="limit()"

View File

@ -6,11 +6,3 @@
.status {
width: 180px;
}
.cell,
.sources {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}

View File

@ -270,12 +270,9 @@ describe('ParticipantsPage', () => {
it('drops the cursor on a filter change and toggles the joined order', async () => {
const { harness, http } = await open('/participants?cursor=abc');
const page$ = harness.routeDebugElement?.componentInstance as {
setStatus(value: string): void;
setSort(change: { key: string; order: 'asc' | 'desc' }): void;
};
const page$ = harness.routeDebugElement?.componentInstance as { sessions: { setStatus(value: string): void } };
page$.setStatus('active');
page$.sessions.setStatus('active');
await harness.fixture.whenStable();
expect(query().get('status')).toBe('active');
expect(query().has('cursor')).toBe(false);

View File

@ -1,57 +1,32 @@
import { Component, computed, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatChip, MatChipRemove } from '@angular/material/chips';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatOption, MatSelect } from '@angular/material/select';
import { RouterLink } from '@angular/router';
import {
type ListResponse,
type ParticipantKind,
type ParticipantListItem,
type ParticipantsSummary,
ParticipantSort,
ParticipantStatus,
VISIBLE_PARTICIPANT_KINDS
} from '@openvidu-dashboard/typings';
import { KindBadge } from '../../components/badges/kind-badge.js';
import { RoomChip } from '../../components/badges/room-chip.js';
import { SourceIcon } from '../../components/badges/source-icon.js';
import { StatusBadge } from '../../components/badges/status-badge.js';
import { CopyButton } from '../../components/copy-button/copy-button.js';
import { CursorPaginator } from '../../components/data-table/cursor-paginator.js';
import { DataTable, type SortChange, TableCell, type TableColumn } from '../../components/data-table/data-table.js';
import { RangePickerService } from '../../components/date-time-range-picker/date-time-range-picker.js';
import { Duration } from '../../components/duration/duration.js';
import { EmptyState } from '../../components/feedback/empty-state.js';
import { exactListId, ListSearch, listSearchQuery } from '../../components/search/list-search.js';
import { ListSearch } from '../../components/search/list-search.js';
import { KpiTile } from '../../components/tiles/kpi-tile.js';
import { TileRow } from '../../components/tiles/tile-row.js';
import { injectListLoader } from '../../core/refresh/list-loader.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js';
import { formatNumber, formatTimestamp } from '../../format/formatters.js';
import { formatNumber } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js';
import { PARTICIPANT_KIND_LABELS } from '../../format/labels.js';
import { isRoomSid } from '../rooms/room-display.js';
import { kindFilter, kindParam, participantPreview, servicesHint } from './participant-display.js';
import { kindFilter, kindParam, servicesHint } from './participant-display.js';
import { injectParticipantSessions, PARTICIPANT_STATUS_LABELS, PARTICIPANT_STATUSES } from './participant-sessions.js';
import { ParticipantSessionsTable } from './participant-sessions-table/participant-sessions-table.js';
import { ParticipantsDataService, type ParticipantsListRequest } from './participants-data.js';
const STATUSES = Object.values(ParticipantStatus);
const SORTS = Object.values(ParticipantSort);
const STATUS_LABELS: Readonly<Record<ParticipantStatus, string>> = {
joining: 'Joining',
active: 'Active',
left: 'Left',
aborted: 'Aborted'
};
/** Sorts ranked in memory over the range, with their column names. */
const RANKED_SORTS: Readonly<Partial<Record<ParticipantSort, string>>> = { leftAt: 'Left', duration: 'Duration' };
interface ParticipantsData {
page: ListResponse<ParticipantListItem>;
summary: ParticipantsSummary | null;
@ -61,15 +36,9 @@ interface ParticipantsData {
@Component({
selector: 'ovd-participants-page',
imports: [
CopyButton,
CursorPaginator,
DataTable,
Duration,
EmptyState,
KindBadge,
KpiTile,
ListSearch,
MatButton,
MatChip,
MatChipRemove,
MatFormField,
@ -77,11 +46,8 @@ interface ParticipantsData {
MatLabel,
MatOption,
MatSelect,
ParticipantSessionsTable,
RoomChip,
RouterLink,
SourceIcon,
StatusBadge,
TableCell,
TileRow
],
templateUrl: './participants-page.html',
@ -92,59 +58,25 @@ export class ParticipantsPage {
private readonly data = inject(ParticipantsDataService);
private readonly query = inject(QueryStateService);
private readonly listState = inject(ListStateService);
private readonly previews = inject(EntityPreviewService);
private readonly rangePicker = inject(RangePickerService);
private readonly limits = injectRankedSortLimits();
protected readonly kinds = VISIBLE_PARTICIPANT_KINDS;
protected readonly kindLabels = PARTICIPANT_KIND_LABELS;
protected readonly statuses = STATUSES;
protected readonly statusLabels = STATUS_LABELS;
protected readonly tz = this.query.tz;
protected readonly statuses = PARTICIPANT_STATUSES;
protected readonly statusLabels = PARTICIPANT_STATUS_LABELS;
protected readonly limit = this.query.limit;
protected readonly q = computed(() => listSearchQuery(this.query.q(), 'participant'));
protected readonly exactId = computed(() => exactListId(this.q(), 'participant'));
protected readonly sessions = injectParticipantSessions({ kindColumn: true });
protected readonly selectedKinds = computed(() => kindFilter(this.query.value('kind')));
protected readonly status = computed(() => {
const value = this.query.value('status');
return STATUSES.includes(value as ParticipantStatus) ? (value as ParticipantStatus) : null;
});
protected readonly room = computed(() => {
const value = this.query.value('room');
return value !== null && isRoomSid(value) ? value : null;
});
protected readonly publisher = computed(() => this.query.value('publisher') === 'true');
private readonly rangeMs = computed(() => {
const resolved = resolveRange(this.listState.range(), new Date());
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime();
});
/** The ranked sorts apply up to the participants limit, or with any range inside one room. */
private readonly sortReasons = computed(() =>
rankedSortReasons(
RANKED_SORTS,
this.rangeMs(),
this.room() === null ? this.limits()?.participantRankedSortMaxRangeMs : undefined
)
);
private readonly urlSort = computed(() => this.query.oneOf('sort', SORTS, ParticipantSort.JOINED_AT));
/** The URL's sort, or Joined descending when the range is too wide for it. */
protected readonly sort = computed<ParticipantSort>(() =>
this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : ParticipantSort.JOINED_AT
);
protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc'));
private readonly request = computed<ParticipantsListRequest>(() => ({
range: this.listState.range(),
q: this.q(),
q: this.sessions.q(),
kind: this.selectedKinds(),
status: this.status(),
room: this.room(),
status: this.sessions.status(),
room: this.sessions.room(),
publisher: this.publisher(),
sort: this.sort(),
order: this.order(),
sort: this.sessions.sort(),
order: this.sessions.order(),
cursor: this.query.cursor(),
limit: this.limit()
}));
@ -160,14 +92,7 @@ export class ParticipantsPage {
this.data.summary(request.range).catch(() => null)
]);
for (const item of page.items) {
this.previews.set('participant', item.sid, participantPreview(item));
if (this.previews.get('room', item.roomSid) === undefined) {
this.previews.set('room', item.roomSid, { name: item.roomName });
}
}
this.sessions.remember(page.items);
this.lastSummary = summary ?? this.lastSummary;
return { page, summary: this.lastSummary };
@ -185,57 +110,6 @@ export class ParticipantsPage {
});
protected readonly rows = computed(() => this.page()?.items ?? []);
protected readonly columns = computed<TableColumn[]>(() => {
const reasons = this.sortReasons();
const ranked = (sort: ParticipantSort) => ({
sortable: true,
...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] })
});
return [
{ key: 'sid', header: 'SID', hideable: false, card: 'title' },
{ key: 'identity', header: 'Identity' },
{ key: 'name', header: 'Name' },
{ key: 'room', header: 'Room', card: 'metric' },
{ key: 'kind', header: 'Kind' },
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: ParticipantSort.JOINED_AT,
header: 'Joined',
sortable: true,
hideable: false,
card: 'timestamp',
headerHint: this.tz()
},
{
key: ParticipantSort.LEFT_AT,
header: 'Left',
headerHint: this.tz(),
...ranked('leftAt')
},
{
key: ParticipantSort.DURATION,
header: 'Duration',
align: 'end',
card: 'metric',
...ranked('duration')
},
{ key: 'published', header: 'Published', card: 'metric' },
{ key: 'node', header: 'Node', defaultHidden: true }
];
});
protected readonly trackParticipant = (item: ParticipantListItem): string => item.sid;
protected time(value: string): string {
return formatTimestamp(value, { tz: this.tz(), now: new Date(), rangeMs: this.rangeMs() });
}
/** Hint of an active session the list response marks stale. */
protected staleHint(item: ParticipantListItem): string | undefined {
return item.stale === true ? 'No recent stats from this session' : undefined;
}
protected setKinds(kinds: ParticipantKind[]): void {
void this.query.navigate({ kind: kindParam(kinds) });
}
@ -249,30 +123,14 @@ export class ParticipantsPage {
return this.summary()?.approximate === true;
}
protected setStatus(status: ParticipantStatus | null): void {
void this.query.navigate({ status });
}
protected removePublisher(): void {
void this.query.navigate({ publisher: null });
}
protected setSort(change: SortChange): void {
void this.query.navigate({ sort: change.key, order: change.order });
}
protected clearFilters(): void {
void this.query.navigate({ q: null, kind: null, status: null, room: null, publisher: null });
}
protected clearSearch(): void {
void this.query.navigate({ q: null });
}
protected widenRange(): void {
this.rangePicker.open();
}
protected retry(): void {
void this.loader.scheduler.refresh();
}