diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.html
index 067b33a6e..510c90605 100644
--- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.html
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.html
@@ -25,7 +25,7 @@
Status
-
+
All
@for (status of statuses; track status) {
{{ statusLabels[status] }}
@@ -33,88 +33,30 @@
- @if (room(); as room) {
+ @if (sessions.room(); as room) {
}
-
-
-
- {{ item.sid }}
-
-
-
-
-
- {{ item.identity }}
-
-
-
- {{ item.name ?? '—' }}
-
- {{ item.roomName }}
-
-
-
-
-
-
-
-
- @if (item.leftAt) {
-
- } @else {
- —
- }
-
-
-
-
-
- @if (item.publishedSources.length > 0) {
-
- @for (source of item.publishedSources; track source) {
-
- }
-
- } @else {
- —
- }
-
-
- {{ item.nodeId ?? '—' }}
-
-
- @if (exactId(); as sid) {
-
-
-
- } @else {
-
-
-
-
- }
-
+ (clearSearch)="sessions.clearSearch()"
+ (clearFilters)="clearFilters()"
+ (widenRange)="sessions.widenRange()"
+/>
{
'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;
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.ts
index c3039e030..f6555135a 100644
--- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.ts
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/agents/agents-page.ts
@@ -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> = {
- joining: 'Joining',
- active: 'Active',
- left: 'Left',
- aborted: 'Aborted'
-};
-/** Sorts ranked in memory over the range, with their column names. */
-const RANKED_SORTS: Readonly>> = { 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(() =>
- 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(() => ({
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(() => {
- 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();
}
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html
new file mode 100644
index 000000000..0c7dcbe4c
--- /dev/null
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.html
@@ -0,0 +1,89 @@
+
+
+
+ {{ item.sid }}
+
+
+
+
+
+ {{ item.identity }}
+
+
+
+ {{ item.name ?? '—' }}
+
+ {{ item.roomName }}
+
+
+
+
+
+
+
+
+
+ @if (item.leftAt) {
+
+ } @else {
+ —
+ }
+
+
+
+
+
+ @if (item.publishedSources.length > 0) {
+
+ @for (source of item.publishedSources; track source) {
+
+ }
+
+ } @else {
+ —
+ }
+
+
+ {{ item.nodeId ?? '—' }}
+
+
+ @if (exactId(); as sid) {
+
+
+
+ } @else {
+
+
+
+
+ }
+
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.scss b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.scss
new file mode 100644
index 000000000..b1eb8aa10
--- /dev/null
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.scss
@@ -0,0 +1,11 @@
+:host {
+ display: block;
+}
+
+.cell,
+.sources {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
+}
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.ts
new file mode 100644
index 000000000..2e2e11eb7
--- /dev/null
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions-table/participant-sessions-table.ts
@@ -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();
+ readonly caption = input.required();
+ readonly columns = input.required();
+ readonly rows = input.required();
+ readonly sort = input.required();
+ readonly order = input.required();
+ readonly loading = input(false);
+ readonly error = input(null);
+ readonly pageSize = input.required();
+ /** The exact participant SID searched for, which gets its own empty state. */
+ readonly exactId = input(null);
+ /** What the list holds, for its empty state: "No participants match". */
+ readonly noun = input.required();
+ readonly time = input.required<(value: string) => string>();
+
+ readonly sortChange = output();
+ 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;
+ }
+}
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts
new file mode 100644
index 000000000..211929ffd
--- /dev/null
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participant-sessions.ts
@@ -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> = {
+ 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>> = { 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(() =>
+ 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(() => {
+ 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();
+ }
+ };
+}
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.html b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.html
index af4723331..7caf52f57 100644
--- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.html
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.html
@@ -37,7 +37,7 @@
Status
-
+
All
@for (status of statuses; track status) {
{{ statusLabels[status] }}
@@ -45,7 +45,7 @@
- @if (room(); as room) {
+ @if (sessions.room(); as room) {
}
@if (publisher()) {
@@ -60,93 +60,25 @@
}
-
-
-
- {{ item.sid }}
-
-
-
-
-
- {{ item.identity }}
-
-
-
- {{ item.name ?? '—' }}
-
- {{ item.roomName }}
-
-
-
-
-
-
-
-
-
- @if (item.leftAt) {
-
- } @else {
- —
- }
-
-
-
-
-
- @if (item.publishedSources.length > 0) {
-
- @for (source of item.publishedSources; track source) {
-
- }
-
- } @else {
- —
- }
-
-
- {{ item.nodeId ?? '—' }}
-
-
- @if (exactId(); as sid) {
-
-
-
- } @else {
-
-
-
-
- }
-
+ (clearSearch)="sessions.clearSearch()"
+ (clearFilters)="clearFilters()"
+ (widenRange)="sessions.widenRange()"
+/>
{
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);
diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.ts
index ab7a9d16e..2651638a9 100644
--- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.ts
+++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/participants/participants-page.ts
@@ -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> = {
- joining: 'Joining',
- active: 'Active',
- left: 'Left',
- aborted: 'Aborted'
-};
-/** Sorts ranked in memory over the range, with their column names. */
-const RANKED_SORTS: Readonly>> = { leftAt: 'Left', duration: 'Duration' };
-
interface ParticipantsData {
page: ListResponse;
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(() =>
- 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(() => ({
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(() => {
- 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();
}