openvidu-dashboard: one ranked sort state for the Rooms, Participants, Egress and Ingress lists

injectRankedSort() resolves the URL's sort and order with the fallback a wide range forces, and the sort
fields of the ranked columns; rangeSpanMs() replaces the per-page span of the range.
pull/924/head
juancarmore 2026-10-26 14:50:49 +01:00
parent 2461c21181
commit e34946d44d
7 changed files with 213 additions and 214 deletions

View File

@ -1,7 +1,8 @@
import { computed, inject, type Signal } from '@angular/core'; import { computed, inject, type Signal } from '@angular/core';
import type { InfoLimits } from '@openvidu-dashboard/typings'; import { type InfoLimits, SortOrder } from '@openvidu-dashboard/typings';
import { InfoService } from '../info.js'; import { InfoService } from '../info.js';
import { QueryStateService } from './query-state.js';
const HOUR_MS = 3_600_000; const HOUR_MS = 3_600_000;
const DAY_MS = 24 * HOUR_MS; const DAY_MS = 24 * HOUR_MS;
@ -61,3 +62,46 @@ export function injectRankedSortLimits(): Signal<InfoLimits | null> {
return computed(() => info.info()?.limits ?? null); return computed(() => info.info()?.limits ?? null);
} }
/** The sort of a list whose ranked sorts a wide range rules out. */
export interface RankedSort<S extends string> {
/** The URL's sort, or the default sort while the range rules the URL's out. */
readonly sort: Signal<S>;
/** The URL's order, or descending with the default sort. */
readonly order: Signal<SortOrder>;
/** The sort fields of a ranked column: disabled, with the reason, while the range rules its sort out. */
column(sort: S): { sortable: true; sortDisabledReason?: string };
}
/**
* The sort of a list from the URL's `sort` and `order`. Call it in an injection context.
*
* @param options.fallback The default sort, which every range allows.
* @param options.ranked Column name of each ranked sort.
* @param options.rangeMs Span of the list's range.
* @param options.limitMs The ranked sorts' limit out of `GET /info`; `rankedSortMaxRangeMs` by default.
*/
export function injectRankedSort<S extends string>(options: {
sorts: readonly S[];
fallback: S;
ranked: Readonly<Partial<Record<S, string>>>;
rangeMs: Signal<number>;
limitMs?: (limits: InfoLimits | null) => number | undefined;
}): RankedSort<S> {
const query = inject(QueryStateService);
const limits = injectRankedSortLimits();
const limitMs = options.limitMs ?? ((value: InfoLimits | null) => value?.rankedSortMaxRangeMs);
const reasons = computed(() => rankedSortReasons(options.ranked, options.rangeMs(), limitMs(limits())));
const urlSort = computed(() => query.oneOf('sort', options.sorts, options.fallback));
const sort = computed(() => (reasons()[urlSort()] === undefined ? urlSort() : options.fallback));
return {
sort,
order: computed(() => (sort() === urlSort() ? query.order() : SortOrder.DESC)),
column(key: S) {
const reason = reasons()[key];
return reason === undefined ? { sortable: true } : { sortable: true, sortDisabledReason: reason };
}
};
}

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { isTimeExpression, isTimeZone, resolveRange, resolveTime } from './relative-time.js'; import { isTimeExpression, isTimeZone, rangeSpanMs, resolveRange, resolveTime } from './relative-time.js';
const NOW = new Date('2026-10-13T12:40:00.000Z'); const NOW = new Date('2026-10-13T12:40:00.000Z');
@ -65,4 +65,9 @@ describe('relative time', () => {
expect(resolveRange({ from: 'now', to: 'now-1h', tz: 'UTC' }, NOW)).toBeUndefined(); expect(resolveRange({ from: 'now', to: 'now-1h', tz: 'UTC' }, NOW)).toBeUndefined();
expect(resolveRange({ from: 'bogus', to: 'now', tz: 'UTC' }, NOW)).toBeUndefined(); expect(resolveRange({ from: 'bogus', to: 'now', tz: 'UTC' }, NOW)).toBeUndefined();
}); });
it('spans a resolved range and is infinite otherwise', () => {
expect(rangeSpanMs({ from: 'now-24h', to: 'now', tz: 'UTC' }, NOW)).toBe(86_400_000);
expect(rangeSpanMs({ from: 'bogus', to: 'now', tz: 'UTC' }, NOW)).toBe(Number.POSITIVE_INFINITY);
});
}); });

View File

@ -69,3 +69,10 @@ export function resolveRange(range: { from: string; to: string; tz: string }, no
return from !== undefined && to !== undefined && from < to ? { from, to } : undefined; return from !== undefined && to !== undefined && from < to ? { from, to } : undefined;
} }
/** Span of a range at `now` in milliseconds; infinite when the range does not resolve. */
export function rangeSpanMs(range: { from: string; to: string; tz: string }, now: Date): number {
const resolved = resolveRange(range, now);
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime();
}

View File

@ -32,8 +32,8 @@ import { injectTicker } from '../../core/refresh/ticker.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js'; import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js'; import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js'; import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js'; import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js'; import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js';
import { EGRESS_STATUS_LABELS, EGRESS_TYPE_LABELS, RECONCILER_ESTIMATE } from '../../format/labels.js'; import { EGRESS_STATUS_LABELS, EGRESS_TYPE_LABELS, RECONCILER_ESTIMATE } from '../../format/labels.js';
import { isRoomSid } from '../rooms/room-display.js'; import { isRoomSid } from '../rooms/room-display.js';
@ -96,7 +96,6 @@ export class EgressPage {
private readonly listState = inject(ListStateService); private readonly listState = inject(ListStateService);
private readonly previews = inject(EntityPreviewService); private readonly previews = inject(EntityPreviewService);
private readonly rangePicker = inject(RangePickerService); private readonly rangePicker = inject(RangePickerService);
private readonly limits = injectRankedSortLimits();
/** Last summary received, kept on the tiles when a summary request fails. */ /** Last summary received, kept on the tiles when a summary request fails. */
private lastSummary: EgressSummary | null = null; private lastSummary: EgressSummary | null = null;
@ -124,20 +123,15 @@ export class EgressPage {
return value !== null && isRoomSid(value) ? value : null; return value !== null && isRoomSid(value) ? value : null;
}); });
private readonly rangeMs = computed(() => { private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), new Date()));
const resolved = resolveRange(this.listState.range(), new Date()); private readonly rankedSort = injectRankedSort({
sorts: SORTS,
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime(); fallback: EgressSort.STARTED_AT,
ranked: RANKED_SORTS,
rangeMs: this.rangeMs
}); });
private readonly sortReasons = computed(() => protected readonly sort = this.rankedSort.sort;
rankedSortReasons(RANKED_SORTS, this.rangeMs(), this.limits()?.rankedSortMaxRangeMs) protected readonly order = this.rankedSort.order;
);
private readonly urlSort = computed(() => this.query.oneOf('sort', SORTS, EgressSort.STARTED_AT));
/** The URL's sort, or Started descending when the range is too wide for it. */
protected readonly sort = computed<EgressSort>(() =>
this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : EgressSort.STARTED_AT
);
protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc'));
private readonly request = computed<EgressListRequest>(() => ({ private readonly request = computed<EgressListRequest>(() => ({
range: this.listState.range(), range: this.listState.range(),
@ -175,31 +169,23 @@ export class EgressPage {
protected readonly rows = computed(() => this.page()?.items ?? []); protected readonly rows = computed(() => this.page()?.items ?? []);
protected readonly summary = computed(() => this.loader.data()?.summary ?? null); protected readonly summary = computed(() => this.loader.data()?.summary ?? null);
protected readonly columns = computed<TableColumn[]>(() => { protected readonly columns = computed<TableColumn[]>(() => [
const reasons = this.sortReasons(); { key: 'egressId', header: 'Egress ID', hideable: false, card: 'title' },
const ranked = (sort: EgressSort) => ({ { key: 'room', header: 'Room', card: 'metric' },
{ key: 'type', header: 'Type' },
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: EgressSort.STARTED_AT,
header: 'Started',
sortable: true, sortable: true,
...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) hideable: false,
}); card: 'timestamp',
headerHint: this.tz()
return [ },
{ key: 'egressId', header: 'Egress ID', hideable: false, card: 'title' }, { key: EgressSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...this.rankedSort.column('endedAt') },
{ key: 'room', header: 'Room', card: 'metric' }, { key: EgressSort.DURATION, header: 'Duration', align: 'end', ...this.rankedSort.column('duration') },
{ key: 'type', header: 'Type' }, { key: 'outputs', header: 'Outputs', card: 'metric' }
{ key: 'status', header: 'Status', hideable: false, card: 'status' }, ]);
{
key: EgressSort.STARTED_AT,
header: 'Started',
sortable: true,
hideable: false,
card: 'timestamp',
headerHint: this.tz()
},
{ key: EgressSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...ranked('endedAt') },
{ key: EgressSort.DURATION, header: 'Duration', align: 'end', ...ranked('duration') },
{ key: 'outputs', header: 'Outputs', card: 'metric' }
];
});
protected readonly trackEgress = (item: EgressListItem): string => item.egressId; protected readonly trackEgress = (item: EgressListItem): string => item.egressId;

View File

@ -29,8 +29,8 @@ import { injectListLoader } from '../../core/refresh/list-loader.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js'; import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js'; import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js'; import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js'; import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js'; import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js'; import { INFO_TEXTS } from '../../format/info-texts.js';
import { INGRESS_INPUT_LABELS, INGRESS_STATUS_LABELS } from '../../format/labels.js'; import { INGRESS_INPUT_LABELS, INGRESS_STATUS_LABELS } from '../../format/labels.js';
@ -90,7 +90,6 @@ export class IngressPage {
private readonly listState = inject(ListStateService); private readonly listState = inject(ListStateService);
private readonly previews = inject(EntityPreviewService); private readonly previews = inject(EntityPreviewService);
private readonly rangePicker = inject(RangePickerService); private readonly rangePicker = inject(RangePickerService);
private readonly limits = injectRankedSortLimits();
/** Last summary received, kept on the tiles when a summary request fails. */ /** Last summary received, kept on the tiles when a summary request fails. */
private lastSummary: IngressSummary | null = null; private lastSummary: IngressSummary | null = null;
@ -113,20 +112,15 @@ export class IngressPage {
}); });
protected readonly q = computed(() => listSearchQuery(this.query.q(), 'ingress')); protected readonly q = computed(() => listSearchQuery(this.query.q(), 'ingress'));
protected readonly exactId = computed(() => exactListId(this.q(), 'ingress')); protected readonly exactId = computed(() => exactListId(this.q(), 'ingress'));
private readonly rangeMs = computed(() => { private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), new Date()));
const resolved = resolveRange(this.listState.range(), new Date()); private readonly rankedSort = injectRankedSort({
sorts: SORTS,
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime(); fallback: IngressSort.CREATED_AT,
ranked: METRIC_SORTS,
rangeMs: this.rangeMs
}); });
private readonly sortReasons = computed(() => protected readonly sort = this.rankedSort.sort;
rankedSortReasons(METRIC_SORTS, this.rangeMs(), this.limits()?.rankedSortMaxRangeMs) protected readonly order = this.rankedSort.order;
);
private readonly urlSort = computed(() => this.query.oneOf('sort', SORTS, IngressSort.CREATED_AT));
/** The URL's sort, or Created descending when the range is too wide for it. */
protected readonly sort = computed<IngressSort>(() =>
this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : IngressSort.CREATED_AT
);
protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc'));
private readonly request = computed<IngressListRequest>(() => ({ private readonly request = computed<IngressListRequest>(() => ({
range: this.listState.range(), range: this.listState.range(),
@ -163,42 +157,40 @@ export class IngressPage {
protected readonly rows = computed(() => this.page()?.items ?? []); protected readonly rows = computed(() => this.page()?.items ?? []);
protected readonly summary = computed(() => this.loader.data()?.summary ?? null); protected readonly summary = computed(() => this.loader.data()?.summary ?? null);
protected readonly columns = computed<TableColumn[]>(() => { protected readonly columns = computed<TableColumn[]>(() => [
const reasons = this.sortReasons(); { key: 'ingressId', header: 'Ingress ID', hideable: false, card: 'title' },
const metric = (sort: IngressSort) => ({ { key: 'name', header: 'Name' },
{ key: 'room', header: 'Room', card: 'metric' },
{ key: 'input', header: 'Input' },
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: IngressSort.CREATED_AT,
header: 'Created',
sortable: true, sortable: true,
...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) hideable: false,
}); card: 'timestamp',
headerHint: this.tz()
return [ },
{ key: 'ingressId', header: 'Ingress ID', hideable: false, card: 'title' }, {
{ key: 'name', header: 'Name' }, key: IngressSort.TOTAL_DURATION,
{ key: 'room', header: 'Room', card: 'metric' }, header: 'Total duration',
{ key: 'input', header: 'Input' }, align: 'end',
{ key: 'status', header: 'Status', hideable: false, card: 'status' }, ...this.rankedSort.column('totalDuration')
{ },
key: IngressSort.CREATED_AT, {
header: 'Created', key: IngressSort.SESSIONS,
sortable: true, header: 'Sessions',
hideable: false, align: 'end',
card: 'timestamp', card: 'metric',
headerHint: this.tz() ...this.rankedSort.column('sessions')
}, },
{ {
key: IngressSort.TOTAL_DURATION, key: IngressSort.LAST_SESSION,
header: 'Total duration', header: 'Last session',
align: 'end', headerHint: this.tz(),
...metric('totalDuration') ...this.rankedSort.column('lastSession')
}, }
{ key: IngressSort.SESSIONS, header: 'Sessions', align: 'end', card: 'metric', ...metric('sessions') }, ]);
{
key: IngressSort.LAST_SESSION,
header: 'Last session',
headerHint: this.tz(),
...metric('lastSession')
}
];
});
protected readonly trackIngress = (item: IngressListItem): string => item.ingressId; protected readonly trackIngress = (item: IngressListItem): string => item.ingressId;

View File

@ -7,8 +7,8 @@ import { exactListId, listSearchQuery } from '../../components/search/list-searc
import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js'; import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js'; import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js'; import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js'; import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatTimestamp } from '../../format/formatters.js'; import { formatTimestamp } from '../../format/formatters.js';
import { isRoomSid } from '../rooms/room-display.js'; import { isRoomSid } from '../rooms/room-display.js';
import { participantPreview } from './participant-display.js'; import { participantPreview } from './participant-display.js';
@ -34,7 +34,6 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
const listState = inject(ListStateService); const listState = inject(ListStateService);
const previews = inject(EntityPreviewService); const previews = inject(EntityPreviewService);
const rangePicker = inject(RangePickerService); const rangePicker = inject(RangePickerService);
const limits = injectRankedSortLimits();
const q = computed(() => listSearchQuery(query.q(), 'participant')); const q = computed(() => listSearchQuery(query.q(), 'participant'));
const room = computed(() => { const room = computed(() => {
@ -42,24 +41,15 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
return value !== null && isRoomSid(value) ? value : null; return value !== null && isRoomSid(value) ? value : null;
}); });
const rangeMs = computed(() => { const rangeMs = computed(() => rangeSpanMs(listState.range(), new Date()));
const resolved = resolveRange(listState.range(), new Date()); const ranked = injectRankedSort({
sorts: SORTS,
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime(); fallback: ParticipantSort.JOINED_AT,
ranked: RANKED_SORTS,
rangeMs,
// The ranked sorts apply up to the participants limit, or with any range inside one room.
limitMs: (limits) => (room() === null ? limits?.participantRankedSortMaxRangeMs : undefined)
}); });
/** 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 { return {
q, q,
@ -70,42 +60,34 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) {
return PARTICIPANT_STATUSES.includes(value as ParticipantStatus) ? (value as ParticipantStatus) : null; return PARTICIPANT_STATUSES.includes(value as ParticipantStatus) ? (value as ParticipantStatus) : null;
}), }),
room, room,
sort, sort: ranked.sort,
order: computed(() => (sort() === urlSort() ? query.order() : 'desc')), order: ranked.order,
columns: computed<TableColumn[]>(() => { columns: computed<TableColumn[]>(() => [
const reasons = sortReasons(); { key: 'sid', header: 'SID', hideable: false, card: 'title' },
const ranked = (key: ParticipantSort) => ({ { 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, sortable: true,
...(reasons[key] === undefined ? {} : { sortDisabledReason: reasons[key] }) hideable: false,
}); card: 'timestamp',
headerHint: query.tz()
return [ },
{ key: 'sid', header: 'SID', hideable: false, card: 'title' }, { key: ParticipantSort.LEFT_AT, header: 'Left', headerHint: query.tz(), ...ranked.column('leftAt') },
{ key: 'identity', header: 'Identity' }, {
{ key: 'name', header: 'Name' }, key: ParticipantSort.DURATION,
{ key: 'room', header: 'Room', card: 'metric' }, header: 'Duration',
...(options.kindColumn ? [{ key: 'kind', header: 'Kind' }] : []), align: 'end',
{ key: 'status', header: 'Status', hideable: false, card: 'status' }, card: 'metric',
{ ...ranked.column('duration')
key: ParticipantSort.JOINED_AT, },
header: 'Joined', { key: 'published', header: 'Published', card: 'metric' },
sortable: true, { key: 'node', header: 'Node', defaultHidden: 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. */ /** Dates as on the other lists: the time alone inside the current day of a range up to 24 h. */
time: (value: string): string => time: (value: string): string =>
formatTimestamp(value, { tz: query.tz(), now: new Date(), rangeMs: rangeMs() }), formatTimestamp(value, { tz: query.tz(), now: new Date(), rangeMs: rangeMs() }),

View File

@ -32,8 +32,8 @@ import { injectTicker } from '../../core/refresh/ticker.js';
import { EntityPreviewService } from '../../core/state/entity-preview.js'; import { EntityPreviewService } from '../../core/state/entity-preview.js';
import { ListStateService } from '../../core/state/list-state.js'; import { ListStateService } from '../../core/state/list-state.js';
import { QueryStateService } from '../../core/state/query-state.js'; import { QueryStateService } from '../../core/state/query-state.js';
import { injectRankedSortLimits, rankedSortReasons } from '../../core/state/ranked-sort.js'; import { injectRankedSort } from '../../core/state/ranked-sort.js';
import { resolveRange } from '../../core/state/relative-time.js'; import { rangeSpanMs } from '../../core/state/relative-time.js';
import { formatNumber, formatTimestamp } from '../../format/formatters.js'; import { formatNumber, formatTimestamp } from '../../format/formatters.js';
import { INFO_TEXTS } from '../../format/info-texts.js'; import { INFO_TEXTS } from '../../format/info-texts.js';
import { RECONCILER_ESTIMATE } from '../../format/labels.js'; import { RECONCILER_ESTIMATE } from '../../format/labels.js';
@ -95,7 +95,6 @@ export class RoomsPage {
private readonly listState = inject(ListStateService); private readonly listState = inject(ListStateService);
private readonly previews = inject(EntityPreviewService); private readonly previews = inject(EntityPreviewService);
private readonly rangePicker = inject(RangePickerService); private readonly rangePicker = inject(RangePickerService);
private readonly limits = injectRankedSortLimits();
protected readonly features = ROOM_FEATURES; protected readonly features = ROOM_FEATURES;
protected readonly estimate = RECONCILER_ESTIMATE; protected readonly estimate = RECONCILER_ESTIMATE;
@ -113,29 +112,21 @@ export class RoomsPage {
protected readonly exactId = computed(() => exactListId(this.q(), 'room')); protected readonly exactId = computed(() => exactListId(this.q(), 'room'));
protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS)); protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS));
protected readonly nodeId = computed(() => (this.nodeFilter() ? this.query.value('nodeId') : null)); protected readonly nodeId = computed(() => (this.nodeFilter() ? this.query.value('nodeId') : null));
private readonly rangeMs = computed(() => { private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), this.now()));
const resolved = resolveRange(this.listState.range(), this.now());
return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime();
});
/** Range span when the range last changed, so the ticker never re-renders the headers. */ /** Range span when the range last changed, so the ticker never re-renders the headers. */
private readonly requestRangeMs = computed(() => { private readonly requestRangeMs = computed(() => {
this.listState.range(); this.listState.range();
return untracked(this.rangeMs); return untracked(this.rangeMs);
}); });
private readonly sortReasons = computed(() => private readonly rankedSort = injectRankedSort({
rankedSortReasons(RANKED_SORTS, this.requestRangeMs(), this.limits()?.rankedSortMaxRangeMs) sorts: SORTS,
); fallback: RoomSort.CREATED_AT,
/** The URL's sort, or Started descending when the range is too wide for it. */ ranked: RANKED_SORTS,
protected readonly sort = computed<RoomSort>(() => { rangeMs: this.requestRangeMs
const sort = this.query.oneOf('sort', SORTS, RoomSort.CREATED_AT);
return this.sortReasons()[sort] === undefined ? sort : RoomSort.CREATED_AT;
}); });
protected readonly order = computed(() => protected readonly sort = this.rankedSort.sort;
this.sort() === this.query.oneOf('sort', SORTS, RoomSort.CREATED_AT) ? this.query.order() : 'desc' protected readonly order = this.rankedSort.order;
);
private readonly request = computed<RoomsListRequest>(() => ({ private readonly request = computed<RoomsListRequest>(() => ({
range: this.listState.range(), range: this.listState.range(),
@ -176,54 +167,46 @@ export class RoomsPage {
protected readonly page = computed(() => this.loader.data()?.rooms ?? null); protected readonly page = computed(() => this.loader.data()?.rooms ?? null);
protected readonly summary = computed(() => this.loader.data()?.summary ?? null); protected readonly summary = computed(() => this.loader.data()?.summary ?? null);
protected readonly columns = computed<TableColumn[]>(() => { protected readonly columns = computed<TableColumn[]>(() => [
const reasons = this.sortReasons(); { key: 'sid', header: 'SID', hideable: false, card: 'title' },
const ranked = (sort: RoomSort) => ({ { key: 'name', header: 'Name' },
{ key: 'status', header: 'Status', hideable: false, card: 'status' },
{
key: RoomSort.CREATED_AT,
header: 'Started',
sortable: true, sortable: true,
...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) hideable: false,
}); card: 'timestamp',
headerHint: this.tz()
return [ },
{ key: 'sid', header: 'SID', hideable: false, card: 'title' }, { key: RoomSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...this.rankedSort.column('endedAt') },
{ key: 'name', header: 'Name' }, {
{ key: 'status', header: 'Status', hideable: false, card: 'status' }, key: RoomSort.DURATION,
{ header: 'Duration',
key: RoomSort.CREATED_AT, align: 'end',
header: 'Started', card: 'metric',
sortable: true, ...this.rankedSort.column('duration')
hideable: false, },
card: 'timestamp', {
headerHint: this.tz() key: RoomSort.UNIQUE_IDENTITIES,
}, header: 'Unique identities',
{ key: RoomSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...ranked('endedAt') }, align: 'end',
{ card: 'metric',
key: RoomSort.DURATION, info: INFO_TEXTS.uniqueIdentities,
header: 'Duration', ...this.rankedSort.column('uniqueIdentities')
align: 'end', },
card: 'metric', {
...ranked('duration') key: RoomSort.ACTIVE,
}, header: 'Active',
{ align: 'end',
key: RoomSort.UNIQUE_IDENTITIES, card: 'metric',
header: 'Unique identities', info: INFO_TEXTS.activeParticipants,
align: 'end', ...this.rankedSort.column('active')
card: 'metric', },
info: INFO_TEXTS.uniqueIdentities, { key: 'bytes', header: 'Data up / down', align: 'end' },
...ranked('uniqueIdentities') { key: 'features', header: 'Features', info: INFO_TEXTS.roomFeatures },
}, { key: 'node', header: 'Node', defaultHidden: true }
{ ]);
key: RoomSort.ACTIVE,
header: 'Active',
align: 'end',
card: 'metric',
info: INFO_TEXTS.activeParticipants,
...ranked('active')
},
{ key: 'bytes', header: 'Data up / down', align: 'end' },
{ key: 'features', header: 'Features', info: INFO_TEXTS.roomFeatures },
{ key: 'node', header: 'Node', defaultHidden: true }
];
});
protected readonly trackRoom = (room: RoomListItem): string => room.sid; protected readonly trackRoom = (room: RoomListItem): string => room.sid;