mirror of https://github.com/OpenVidu/openvidu.git
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
parent
9571f1b3f0
commit
2461c21181
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -13,10 +13,3 @@
|
|||
.status {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cell,
|
||||
.sources {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -6,11 +6,3 @@
|
|||
.status {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.cell,
|
||||
.sources {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue