From e34946d44d78ed7dd5447c7f4fc9d622d87fa886 Mon Sep 17 00:00:00 2001 From: juancarmore Date: Mon, 26 Oct 2026 14:50:49 +0100 Subject: [PATCH] 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. --- .../src/lib/core/state/ranked-sort.ts | 46 ++++++- .../src/lib/core/state/relative-time.spec.ts | 7 +- .../src/lib/core/state/relative-time.ts | 7 ++ .../src/lib/features/egress/egress-page.ts | 66 ++++------ .../src/lib/features/ingress/ingress-page.ts | 94 +++++++------- .../participants/participant-sessions.ts | 92 ++++++-------- .../src/lib/features/rooms/rooms-page.ts | 115 ++++++++---------- 7 files changed, 213 insertions(+), 214 deletions(-) diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/ranked-sort.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/ranked-sort.ts index b66523a71..0128746ac 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/ranked-sort.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/ranked-sort.ts @@ -1,7 +1,8 @@ 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 { QueryStateService } from './query-state.js'; const HOUR_MS = 3_600_000; const DAY_MS = 24 * HOUR_MS; @@ -61,3 +62,46 @@ export function injectRankedSortLimits(): Signal { return computed(() => info.info()?.limits ?? null); } + +/** The sort of a list whose ranked sorts a wide range rules out. */ +export interface RankedSort { + /** The URL's sort, or the default sort while the range rules the URL's out. */ + readonly sort: Signal; + /** The URL's order, or descending with the default sort. */ + readonly order: Signal; + /** 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(options: { + sorts: readonly S[]; + fallback: S; + ranked: Readonly>>; + rangeMs: Signal; + limitMs?: (limits: InfoLimits | null) => number | undefined; +}): RankedSort { + 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 }; + } + }; +} diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.spec.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.spec.ts index da64509a7..67441e2d8 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.spec.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.spec.ts @@ -1,6 +1,6 @@ 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'); @@ -65,4 +65,9 @@ describe('relative time', () => { expect(resolveRange({ from: 'now', to: 'now-1h', 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); + }); }); diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.ts index 05e29adfa..90b0dc76b 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/core/state/relative-time.ts @@ -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; } + +/** 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(); +} diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts index 868413dd6..f4f2dd94d 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/egress/egress-page.ts @@ -32,8 +32,8 @@ import { injectTicker } from '../../core/refresh/ticker.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 { injectRankedSort } from '../../core/state/ranked-sort.js'; +import { rangeSpanMs } from '../../core/state/relative-time.js'; import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js'; import { EGRESS_STATUS_LABELS, EGRESS_TYPE_LABELS, RECONCILER_ESTIMATE } from '../../format/labels.js'; import { isRoomSid } from '../rooms/room-display.js'; @@ -96,7 +96,6 @@ export class EgressPage { private readonly listState = inject(ListStateService); private readonly previews = inject(EntityPreviewService); private readonly rangePicker = inject(RangePickerService); - private readonly limits = injectRankedSortLimits(); /** Last summary received, kept on the tiles when a summary request fails. */ private lastSummary: EgressSummary | null = null; @@ -124,20 +123,15 @@ export class EgressPage { 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(); + private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), new Date())); + private readonly rankedSort = injectRankedSort({ + sorts: SORTS, + fallback: EgressSort.STARTED_AT, + ranked: RANKED_SORTS, + rangeMs: this.rangeMs }); - private readonly sortReasons = computed(() => - rankedSortReasons(RANKED_SORTS, this.rangeMs(), this.limits()?.rankedSortMaxRangeMs) - ); - 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(() => - this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : EgressSort.STARTED_AT - ); - protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc')); + protected readonly sort = this.rankedSort.sort; + protected readonly order = this.rankedSort.order; private readonly request = computed(() => ({ range: this.listState.range(), @@ -175,31 +169,23 @@ export class EgressPage { protected readonly rows = computed(() => this.page()?.items ?? []); protected readonly summary = computed(() => this.loader.data()?.summary ?? null); - protected readonly columns = computed(() => { - const reasons = this.sortReasons(); - const ranked = (sort: EgressSort) => ({ + protected readonly columns = computed(() => [ + { key: 'egressId', header: 'Egress ID', hideable: false, card: 'title' }, + { 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, - ...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) - }); - - return [ - { key: 'egressId', header: 'Egress ID', hideable: false, card: 'title' }, - { 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, - 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' } - ]; - }); + hideable: false, + card: 'timestamp', + headerHint: this.tz() + }, + { key: EgressSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...this.rankedSort.column('endedAt') }, + { key: EgressSort.DURATION, header: 'Duration', align: 'end', ...this.rankedSort.column('duration') }, + { key: 'outputs', header: 'Outputs', card: 'metric' } + ]); protected readonly trackEgress = (item: EgressListItem): string => item.egressId; diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/ingress/ingress-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/ingress/ingress-page.ts index ea2dd9232..ef24f1d5e 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/ingress/ingress-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/ingress/ingress-page.ts @@ -29,8 +29,8 @@ 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 { injectRankedSort } from '../../core/state/ranked-sort.js'; +import { rangeSpanMs } from '../../core/state/relative-time.js'; import { formatDuration, formatNumber, formatTimestamp } from '../../format/formatters.js'; import { INFO_TEXTS } from '../../format/info-texts.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 previews = inject(EntityPreviewService); private readonly rangePicker = inject(RangePickerService); - private readonly limits = injectRankedSortLimits(); /** Last summary received, kept on the tiles when a summary request fails. */ private lastSummary: IngressSummary | null = null; @@ -113,20 +112,15 @@ export class IngressPage { }); protected readonly q = computed(() => listSearchQuery(this.query.q(), 'ingress')); protected readonly exactId = computed(() => exactListId(this.q(), 'ingress')); - private readonly rangeMs = computed(() => { - const resolved = resolveRange(this.listState.range(), new Date()); - - return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime(); + private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), new Date())); + private readonly rankedSort = injectRankedSort({ + sorts: SORTS, + fallback: IngressSort.CREATED_AT, + ranked: METRIC_SORTS, + rangeMs: this.rangeMs }); - private readonly sortReasons = computed(() => - rankedSortReasons(METRIC_SORTS, this.rangeMs(), this.limits()?.rankedSortMaxRangeMs) - ); - 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(() => - this.sortReasons()[this.urlSort()] === undefined ? this.urlSort() : IngressSort.CREATED_AT - ); - protected readonly order = computed(() => (this.sort() === this.urlSort() ? this.query.order() : 'desc')); + protected readonly sort = this.rankedSort.sort; + protected readonly order = this.rankedSort.order; private readonly request = computed(() => ({ range: this.listState.range(), @@ -163,42 +157,40 @@ export class IngressPage { protected readonly rows = computed(() => this.page()?.items ?? []); protected readonly summary = computed(() => this.loader.data()?.summary ?? null); - protected readonly columns = computed(() => { - const reasons = this.sortReasons(); - const metric = (sort: IngressSort) => ({ + protected readonly columns = computed(() => [ + { key: 'ingressId', header: 'Ingress ID', hideable: false, card: 'title' }, + { 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, - ...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) - }); - - return [ - { key: 'ingressId', header: 'Ingress ID', hideable: false, card: 'title' }, - { 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, - hideable: false, - card: 'timestamp', - headerHint: this.tz() - }, - { - key: IngressSort.TOTAL_DURATION, - header: 'Total duration', - align: 'end', - ...metric('totalDuration') - }, - { key: IngressSort.SESSIONS, header: 'Sessions', align: 'end', card: 'metric', ...metric('sessions') }, - { - key: IngressSort.LAST_SESSION, - header: 'Last session', - headerHint: this.tz(), - ...metric('lastSession') - } - ]; - }); + hideable: false, + card: 'timestamp', + headerHint: this.tz() + }, + { + key: IngressSort.TOTAL_DURATION, + header: 'Total duration', + align: 'end', + ...this.rankedSort.column('totalDuration') + }, + { + key: IngressSort.SESSIONS, + header: 'Sessions', + align: 'end', + card: 'metric', + ...this.rankedSort.column('sessions') + }, + { + key: IngressSort.LAST_SESSION, + header: 'Last session', + headerHint: this.tz(), + ...this.rankedSort.column('lastSession') + } + ]); protected readonly trackIngress = (item: IngressListItem): string => item.ingressId; 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 index 211929ffd..bd73bec4a 100644 --- 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 @@ -7,8 +7,8 @@ import { exactListId, listSearchQuery } from '../../components/search/list-searc 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 { injectRankedSort } from '../../core/state/ranked-sort.js'; +import { rangeSpanMs } 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'; @@ -34,7 +34,6 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) { 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(() => { @@ -42,24 +41,15 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) { 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(); + const rangeMs = computed(() => rangeSpanMs(listState.range(), new Date())); + const ranked = injectRankedSort({ + sorts: SORTS, + 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(() => - sortReasons()[urlSort()] === undefined ? urlSort() : ParticipantSort.JOINED_AT - ); return { q, @@ -70,42 +60,34 @@ export function injectParticipantSessions(options: { kindColumn: boolean }) { 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) => ({ + sort: ranked.sort, + order: ranked.order, + columns: computed(() => [ + { 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, - ...(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 } - ]; - }), + hideable: false, + card: 'timestamp', + headerHint: query.tz() + }, + { key: ParticipantSort.LEFT_AT, header: 'Left', headerHint: query.tz(), ...ranked.column('leftAt') }, + { + key: ParticipantSort.DURATION, + header: 'Duration', + align: 'end', + card: 'metric', + ...ranked.column('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() }), diff --git a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts index 5400a5cd6..958f31cf7 100644 --- a/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts +++ b/openvidu-dashboard/dashboard-ce/frontend/projects/dashboard-ui/src/lib/features/rooms/rooms-page.ts @@ -32,8 +32,8 @@ import { injectTicker } from '../../core/refresh/ticker.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 { injectRankedSort } from '../../core/state/ranked-sort.js'; +import { rangeSpanMs } from '../../core/state/relative-time.js'; import { formatNumber, formatTimestamp } from '../../format/formatters.js'; import { INFO_TEXTS } from '../../format/info-texts.js'; import { RECONCILER_ESTIMATE } from '../../format/labels.js'; @@ -95,7 +95,6 @@ export class RoomsPage { private readonly listState = inject(ListStateService); private readonly previews = inject(EntityPreviewService); private readonly rangePicker = inject(RangePickerService); - private readonly limits = injectRankedSortLimits(); protected readonly features = ROOM_FEATURES; protected readonly estimate = RECONCILER_ESTIMATE; @@ -113,29 +112,21 @@ export class RoomsPage { protected readonly exactId = computed(() => exactListId(this.q(), 'room')); protected readonly selectedFeatures = computed(() => this.query.csv('features', FEATURE_KEYS)); protected readonly nodeId = computed(() => (this.nodeFilter() ? this.query.value('nodeId') : null)); - private readonly rangeMs = computed(() => { - const resolved = resolveRange(this.listState.range(), this.now()); - - return resolved === undefined ? Number.POSITIVE_INFINITY : resolved.to.getTime() - resolved.from.getTime(); - }); + private readonly rangeMs = computed(() => rangeSpanMs(this.listState.range(), this.now())); /** Range span when the range last changed, so the ticker never re-renders the headers. */ private readonly requestRangeMs = computed(() => { this.listState.range(); return untracked(this.rangeMs); }); - private readonly sortReasons = computed(() => - rankedSortReasons(RANKED_SORTS, this.requestRangeMs(), this.limits()?.rankedSortMaxRangeMs) - ); - /** The URL's sort, or Started descending when the range is too wide for it. */ - protected readonly sort = computed(() => { - const sort = this.query.oneOf('sort', SORTS, RoomSort.CREATED_AT); - - return this.sortReasons()[sort] === undefined ? sort : RoomSort.CREATED_AT; + private readonly rankedSort = injectRankedSort({ + sorts: SORTS, + fallback: RoomSort.CREATED_AT, + ranked: RANKED_SORTS, + rangeMs: this.requestRangeMs }); - protected readonly order = computed(() => - this.sort() === this.query.oneOf('sort', SORTS, RoomSort.CREATED_AT) ? this.query.order() : 'desc' - ); + protected readonly sort = this.rankedSort.sort; + protected readonly order = this.rankedSort.order; private readonly request = computed(() => ({ range: this.listState.range(), @@ -176,54 +167,46 @@ export class RoomsPage { protected readonly page = computed(() => this.loader.data()?.rooms ?? null); protected readonly summary = computed(() => this.loader.data()?.summary ?? null); - protected readonly columns = computed(() => { - const reasons = this.sortReasons(); - const ranked = (sort: RoomSort) => ({ + protected readonly columns = computed(() => [ + { key: 'sid', header: 'SID', hideable: false, card: 'title' }, + { key: 'name', header: 'Name' }, + { key: 'status', header: 'Status', hideable: false, card: 'status' }, + { + key: RoomSort.CREATED_AT, + header: 'Started', sortable: true, - ...(reasons[sort] === undefined ? {} : { sortDisabledReason: reasons[sort] }) - }); - - return [ - { key: 'sid', header: 'SID', hideable: false, card: 'title' }, - { key: 'name', header: 'Name' }, - { key: 'status', header: 'Status', hideable: false, card: 'status' }, - { - key: RoomSort.CREATED_AT, - header: 'Started', - sortable: true, - hideable: false, - card: 'timestamp', - headerHint: this.tz() - }, - { key: RoomSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...ranked('endedAt') }, - { - key: RoomSort.DURATION, - header: 'Duration', - align: 'end', - card: 'metric', - ...ranked('duration') - }, - { - key: RoomSort.UNIQUE_IDENTITIES, - header: 'Unique identities', - align: 'end', - card: 'metric', - info: INFO_TEXTS.uniqueIdentities, - ...ranked('uniqueIdentities') - }, - { - 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 } - ]; - }); + hideable: false, + card: 'timestamp', + headerHint: this.tz() + }, + { key: RoomSort.ENDED_AT, header: 'Ended', headerHint: this.tz(), ...this.rankedSort.column('endedAt') }, + { + key: RoomSort.DURATION, + header: 'Duration', + align: 'end', + card: 'metric', + ...this.rankedSort.column('duration') + }, + { + key: RoomSort.UNIQUE_IDENTITIES, + header: 'Unique identities', + align: 'end', + card: 'metric', + info: INFO_TEXTS.uniqueIdentities, + ...this.rankedSort.column('uniqueIdentities') + }, + { + key: RoomSort.ACTIVE, + header: 'Active', + align: 'end', + card: 'metric', + info: INFO_TEXTS.activeParticipants, + ...this.rankedSort.column('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;