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 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<InfoLimits | 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 { 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);
});
});

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;
}
/** 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 { 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<EgressSort>(() =>
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<EgressListRequest>(() => ({
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<TableColumn[]>(() => {
const reasons = this.sortReasons();
const ranked = (sort: EgressSort) => ({
protected readonly columns = computed<TableColumn[]>(() => [
{ 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;

View File

@ -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<IngressSort>(() =>
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<IngressListRequest>(() => ({
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<TableColumn[]>(() => {
const reasons = this.sortReasons();
const metric = (sort: IngressSort) => ({
protected readonly columns = computed<TableColumn[]>(() => [
{ 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;

View File

@ -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<ParticipantSort>(() =>
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<TableColumn[]>(() => {
const reasons = sortReasons();
const ranked = (key: ParticipantSort) => ({
sort: ranked.sort,
order: ranked.order,
columns: computed<TableColumn[]>(() => [
{ 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() }),

View File

@ -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<RoomSort>(() => {
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<RoomsListRequest>(() => ({
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<TableColumn[]>(() => {
const reasons = this.sortReasons();
const ranked = (sort: RoomSort) => ({
protected readonly columns = computed<TableColumn[]>(() => [
{ 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;