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(); }