diff --git a/.env.example b/.env.example index 168615e..7a38a75 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,18 @@ MEMBY_EMBY_HEALTH_INTERVAL=60s # settings page overrides it without a redeployment. MEMBY_SLOW_REQUEST_THRESHOLD=500ms +# Which reverse proxies the gateway believes when working out where a request came from, +# for the admin console's Remote IP column and the login/trailer logs. X-Forwarded-For +# and X-Real-IP are only honoured when the immediate peer is one of these, so a direct +# client cannot spoof its address with a header. Blank trusts loopback and the private +# ranges (10/8, 172.16/12, 192.168/16, fc00::/7, link-local) — every proxy a home +# deployment puts in front of Memby. Set a comma-separated list of addresses or CIDR +# ranges to narrow or widen it (add your CDN's ranges here if TLS terminates off-site), +# or "none" to trust no proxy. The reverse proxy must be configured to send the headers +# — e.g. nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; and +# proxy_set_header X-Real-IP $remote_addr; +MEMBY_TRUSTED_PROXIES= + # Optional Tracearr-powered For You signals. Create a read-only public API key in # Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers. MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/ diff --git a/CLAUDE.md b/CLAUDE.md index c2f1314..fe893fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -967,9 +967,24 @@ it hides itself on devices that actually support it. **Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s, -on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows -failures, because telemetry must never surface on a TV. Aggregates are read at query time -in `store.RowStats`; raw events are pruned after 90 days. +on `ON_STOP`, and on dispose. Aggregates are read at query time in `store.RowStats`; raw +events are pruned after 90 days. Three things keep the focus/dwell figures honest: + +- **The periodic flush *checkpoints* dwell, it does not end it** (`checkpointFocus`): a + viewer sitting on one row for two minutes must contribute the whole two minutes, not one + ~20s chunk ending at the first flush and nothing after until they move to a different + row. `endFocus` (genuine departure — a selection, the rail, the hero, an overlay, leaving + home) is the only thing that clears the measurement, and the UI calls it explicitly + (`notifyFocusLeftRows`) so time spent above the shelves is never credited to the last row + focused. +- **A batch the upload cannot deliver is put back** (`restore`), so a transient network + failure costs a delay rather than the batch — still bounded by `maxBuffered`, still lost + to a crash. `reportRowEvents` hands the events back through `onUnsent` on failure or + before a session exists; it stays silent on screen and on the direct path. +- **The server drops free text in the controlled fields.** Row id, kind and item id are + vocabulary the television composes (`safeAnalyticsValue` in `toRowEvent`), so an event + carrying anything else was misassembled and a pathological value would distort the + aggregates — dropped whole, as the journey path does. **Server logging** answers "who did what, from which television, on which build". Three pieces make that true and each is easy to undo: diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index efdf137..0797160 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -257,6 +257,8 @@ export interface RemoteSectionDefinition { component: string; maxItems?: number; destination?: string; + /** Card shape override for a mediaRow: "poster", "thumb", or absent for automatic. */ + layout?: string; settings?: Record; } @@ -802,6 +804,10 @@ export interface GatewaySettings { embyHealthSeconds: number; slowRequestMillis: number; librarySyncMinutes: number; + homeTtlSeconds: number; + recommendTtlHours: number; + /** null means "whatever was deployed"; 0 is a legitimate override (midnight). */ + forYouRebuildHour: number | null; updatedAt?: string; updatedBy?: string; } @@ -818,6 +824,9 @@ export interface GatewaySettingValues { embyHealthSeconds: number; slowRequestMillis: number; librarySyncMinutes: number; + homeTtlSeconds: number; + recommendTtlHours: number; + forYouRebuildHour: number; } export interface GatewaySettingsResponse { diff --git a/admin-ui/src/components/GatewaySettingsSection.tsx b/admin-ui/src/components/GatewaySettingsSection.tsx new file mode 100644 index 0000000..e9f11e4 --- /dev/null +++ b/admin-ui/src/components/GatewaySettingsSection.tsx @@ -0,0 +1,385 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api/client'; +import type { GatewaySettings, GatewaySettingsResponse } from '../api/types'; +import { useAction, useQuery } from '../lib/hooks'; +import { useToast } from '../lib/toast'; +import { Banner, Button, Card, Field, KeyValue, Loading, Note, Tag } from './ui'; + +/* The gateway's own server-level settings, rendered as a section rather than a page so it + * can sit on Client configuration beside the thin-client control plane and still be + * reachable on its own at /admin/settings. + * + * Everything here is an *override* of the value the container was started with: blank (or + * zero) means "whatever .env says", which is printed beside each field. The two alert + * windows, the health probe and the slow-request threshold can additionally be switched + * off, which is a third state distinct from "deployed" — the word off in the field. */ + +const OVERRIDE_OFF = -1; + +function numberFieldValue(value: number): string { + if (value === 0) return ''; + if (value < 0) return 'off'; + return String(value); +} + +function parseNumberField(raw: string, offAllowed: boolean): number { + const text = raw.trim().toLowerCase(); + if (text === '') return 0; + if (offAllowed && (text === 'off' || text === 'none' || text === '0')) return OVERRIDE_OFF; + const parsed = Number.parseInt(text, 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** The For You rebuild hour is a plain 0–23 with no "off": an empty field is "deployed" + * (null on the wire) and every other value is the hour it runs at. */ +function parseHourField(raw: string): number | null { + const text = raw.trim(); + if (text === '') return null; + const parsed = Number.parseInt(text, 10); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 23) return null; + return parsed; +} + +function describe(value: number, unit: string): string { + if (value <= 0) return 'off'; + return `${value} ${unit}${value === 1 ? '' : 's'}`; +} + +function notificationDisplayLabel(value: string): string { + switch (value) { + case 'everywhere': return 'Everywhere'; + case 'off': return 'Off'; + default: return 'Home only'; + } +} + +interface Draft { + timezone: string; + logLevel: string; + sessionIdleDays: string; + sonarrAlertMinutes: string; + radarrAlertMinutes: string; + notificationDisplay: string; + embyHealthSeconds: string; + slowRequestMillis: string; + librarySyncMinutes: string; + homeTtlSeconds: string; + recommendTtlHours: string; + forYouRebuildHour: string; +} + +function draftFrom(settings: GatewaySettings): Draft { + return { + timezone: settings.timezone ?? '', + logLevel: settings.logLevel ?? '', + sessionIdleDays: numberFieldValue(settings.sessionIdleDays), + sonarrAlertMinutes: numberFieldValue(settings.sonarrAlertMinutes), + radarrAlertMinutes: numberFieldValue(settings.radarrAlertMinutes), + notificationDisplay: settings.notificationDisplay || 'home_only', + embyHealthSeconds: numberFieldValue(settings.embyHealthSeconds), + slowRequestMillis: numberFieldValue(settings.slowRequestMillis), + librarySyncMinutes: numberFieldValue(settings.librarySyncMinutes), + homeTtlSeconds: settings.homeTtlSeconds ? String(settings.homeTtlSeconds) : '', + recommendTtlHours: settings.recommendTtlHours ? String(settings.recommendTtlHours) : '', + forYouRebuildHour: settings.forYouRebuildHour == null ? '' : String(settings.forYouRebuildHour), + }; +} + +function bodyFrom(draft: Draft): GatewaySettings { + return { + timezone: draft.timezone.trim(), + logLevel: draft.logLevel.trim(), + sessionIdleDays: parseNumberField(draft.sessionIdleDays, false), + sonarrAlertMinutes: parseNumberField(draft.sonarrAlertMinutes, true), + radarrAlertMinutes: parseNumberField(draft.radarrAlertMinutes, true), + notificationDisplay: draft.notificationDisplay, + embyHealthSeconds: parseNumberField(draft.embyHealthSeconds, true), + slowRequestMillis: parseNumberField(draft.slowRequestMillis, true), + librarySyncMinutes: parseNumberField(draft.librarySyncMinutes, true), + homeTtlSeconds: parseNumberField(draft.homeTtlSeconds, false), + recommendTtlHours: parseNumberField(draft.recommendTtlHours, false), + forYouRebuildHour: parseHourField(draft.forYouRebuildHour), + }; +} + +export function GatewaySettingsSection() { + const { data, error, loading, reload } = useQuery('/admin/api/gateway-settings'); + const { wrap } = useToast(); + const { busy, run } = useAction(); + const [draft, setDraft] = useState(null); + + // A refresh must never take a half-typed field away: the draft is adopted once, and + // after that the operator owns it until they save or discard. + useEffect(() => { + if (!draft && data) setDraft(draftFrom(data.settings)); + }, [data, draft]); + + const set = (key: K, value: string) => + setDraft((current) => (current ? { ...current, [key]: value } : current)); + + const save = () => + run('save', async () => { + if (!draft) return; + const saved = await wrap( + () => api.post('/admin/api/gateway-settings', bodyFrom(draft)), + 'Gateway settings saved.', + ); + // Adopt what the server stored rather than what was typed — normalisation clamps and + // refuses. A failed save leaves the draft alone. + if (saved) setDraft(draftFrom(saved.settings)); + await reload(); + }); + + const clearAll = () => + run('clear', async () => { + const saved = await wrap( + () => api.post('/admin/api/gateway-settings', { + timezone: '', logLevel: '', sessionIdleDays: 0, + sonarrAlertMinutes: 0, radarrAlertMinutes: 0, embyHealthSeconds: 0, slowRequestMillis: 0, + librarySyncMinutes: 0, notificationDisplay: 'home_only', + homeTtlSeconds: 0, recommendTtlHours: 0, forYouRebuildHour: null, + }), + 'Every setting is back to its deployed or server default.', + ); + if (saved) setDraft(draftFrom(saved.settings)); + await reload(); + }); + + const deployed = data?.deployed; + const effective = data?.effective; + const levels = data?.logLevels ?? []; + const notificationDisplays = data?.notificationDisplays ?? ['everywhere', 'home_only', 'off']; + + return ( + <> + + + {loading || !draft || !deployed || !effective ? ( + + ) : ( + <> + {data?.version ?? 'unknown'}} + > + + + + + + + + } + > +
+ + set('timezone', event.target.value)} + /> + + + + + +
+ +
+ + set('sessionIdleDays', event.target.value)} + /> + + + + set('slowRequestMillis', event.target.value)} + /> + + + + set('embyHealthSeconds', event.target.value)} + /> + +
+ +
+ + set('librarySyncMinutes', event.target.value)} + /> + + + + set('homeTtlSeconds', event.target.value)} + /> + +
+ +
+ + set('recommendTtlHours', event.target.value)} + /> + + + + set('forYouRebuildHour', event.target.value)} + /> + +
+ +
+ + + +
+ +
+ + set('sonarrAlertMinutes', event.target.value)} + /> + + + + set('radarrAlertMinutes', event.target.value)} + /> + +
+ + + These settings live in the database and survive a restart. A deployment rewrites + .env, not this document, so environment-backed values can then disagree; + permanent environment changes belong in .env.example as well. + + {data?.settings.updatedBy ? ( + + Last changed by {data.settings.updatedBy} + {data.settings.updatedAt ? ` on ${new Date(data.settings.updatedAt).toLocaleString('en-NZ')}` : ''}. + + ) : null} +
+ + )} + + ); +} diff --git a/admin-ui/src/components/Layout.tsx b/admin-ui/src/components/Layout.tsx index 05fa8ac..da09829 100644 --- a/admin-ui/src/components/Layout.tsx +++ b/admin-ui/src/components/Layout.tsx @@ -232,9 +232,8 @@ export function Layout() { Signed in as{currentUser} - {/* The gateway's own settings live here rather than on the rail: the rail - is the household — its users, its content, its televisions — and this - is the server process those pages are served by. */} + {/* Gateway settings now live on Client configuration in the rail; this is a + shortcut to the standalone view of the same editor. */} setAccountOpen(false)}> Gateway settings diff --git a/admin-ui/src/components/RowsEditor.tsx b/admin-ui/src/components/RowsEditor.tsx index 8c16012..dcd2163 100644 --- a/admin-ui/src/components/RowsEditor.tsx +++ b/admin-ui/src/components/RowsEditor.tsx @@ -53,6 +53,11 @@ function describeRow(row: RemoteSectionDefinition, rowTypes: RowTypeDefinition[] return row.type ? `Custom · ${row.type}` : 'Custom'; } +const LAYOUT_LABEL: Record = { + poster: 'Poster cards', + thumb: 'Thumbnail cards', +}; + export function RowsEditor({ page, rows, @@ -142,7 +147,7 @@ export function RowsEditor({ isNew: true, row: { id: '', type: availableTypes[0]?.type ?? 'custom', title: '', enabled: true, - position: 0, dataSource: '', component: '', maxItems: 20, destination: '', + position: 0, dataSource: '', component: '', maxItems: 20, destination: '', layout: '', }, })} > @@ -165,6 +170,7 @@ export function RowsEditor({
{describeRow(row, rowTypes)} {row.maxItems ? {row.maxItems} items : null} + {row.layout && LAYOUT_LABEL[row.layout] ? {LAYOUT_LABEL[row.layout]} : null} {row.destination ? {row.destination} : null}
@@ -290,6 +296,7 @@ function RowEditorDialog({ const [customDataSource, setCustomDataSource] = useState(row.dataSource); const [customComponent, setCustomComponent] = useState(row.component || 'mediaRow'); const [customDestination, setCustomDestination] = useState(row.destination ?? ''); + const [layout, setLayout] = useState(row.layout ?? ''); // A row saved by an older console, or through the raw JSON view, can carry a type this // page's catalogue does not offer (moved off this page, or never catalogued at all). It @@ -306,6 +313,12 @@ function RowEditorDialog({ const valid = trimmedTitle.length > 0 && validMaxItems && (!isCustom || (customDataSource.trim().length > 0 && customComponent.trim().length > 0)); + // Layout only means something for a horizontal card row. A genre browser or a paged + // library grid draws neither poster nor thumbnail cards, so the control is hidden and any + // stray value is dropped on save. + const resolvedComponent = isCustom ? customComponent.trim() : rowType?.component ?? ''; + const layoutApplies = resolvedComponent === 'mediaRow'; + const submit = () => { if (!valid) return; const resolvedType = rowType?.type ?? 'custom'; @@ -324,6 +337,7 @@ function RowEditorDialog({ component: isCustom ? customComponent.trim() : rowType!.component, maxItems, destination, + ...(layoutApplies && (layout === 'poster' || layout === 'thumb') ? { layout } : {}), }); }; @@ -359,6 +373,22 @@ function RowEditorDialog({ {rowType?.description ?

{rowType.description}

: null} + {layoutApplies ? ( +
+ + + +
+ ) : null} + {rowType?.requiresMatchingDestination ? ( Browses the {PAGE_LABEL[page]} catalogue — this row can only ever point at the page it lives on. ) : null} diff --git a/admin-ui/src/components/ui.tsx b/admin-ui/src/components/ui.tsx index 75d2ef7..4259736 100644 --- a/admin-ui/src/components/ui.tsx +++ b/admin-ui/src/components/ui.tsx @@ -91,6 +91,38 @@ export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactN ); } +/** Tabs splits one page into a handful of named views, anchored on a hairline under the + * page head. It is for a page that is genuinely several things about one subject — the + * client control plane, its rows, its server settings — where a single scroll buries the + * section somebody came for. Segments is the control for an exclusive *choice*; this is + * navigation within a page. The active tab rides `?tab=` so a view is linkable. */ +export function Tabs({ + tabs, + active, + onChange, +}: { + tabs: readonly { id: T; label: string; icon?: IconName }[]; + active: T; + onChange: (id: T) => void; +}) { + return ( + + ); +} + /* ---------- tiles ---------- */ export interface TileSpec { diff --git a/admin-ui/src/nav.ts b/admin-ui/src/nav.ts index 41b72c1..26c79aa 100644 --- a/admin-ui/src/nav.ts +++ b/admin-ui/src/nav.ts @@ -44,6 +44,13 @@ export const nav: NavGroup[] = [ item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewer’s journey through Memby.', 'journey', { hidden: true }), ], }, + { + id: 'config', label: 'Configuration', defaultCollapsed: false, collapsible: false, + items: [ + item('features', '/admin/features', 'Client configuration', 'Client configuration', + 'Everything the thin TV client renders, and the gateway’s own server-level settings.', 'sliders'), + ], + }, { id: 'history', label: 'History', defaultCollapsed: false, items: [ @@ -98,7 +105,6 @@ export const nav: NavGroup[] = [ { id: 'rules', label: 'Rules', defaultCollapsed: false, items: [ - item('features', '/admin/features', 'Client configuration', 'Client configuration', 'Control everything the thin TV client renders.', 'sliders'), item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'), item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'), ], @@ -113,7 +119,7 @@ export const nav: NavGroup[] = [ item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'), item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }), item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'), - item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings.', 'sliders', { hidden: true }), + item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings. Also editable on Client configuration.', 'sliders', { hidden: true }), ], }, ]; diff --git a/admin-ui/src/pages/Account.tsx b/admin-ui/src/pages/Account.tsx index 351dc26..f0fe55d 100644 --- a/admin-ui/src/pages/Account.tsx +++ b/admin-ui/src/pages/Account.tsx @@ -103,6 +103,9 @@ interface WatchTime { matched: boolean; tracearrUsername?: string; weekMs: number; + /* This week against the same point last week, so the card can say whether viewing is + up or down without the operator holding last week's figure in their head. */ + weekTrend?: { direction: 'up' | 'down' | 'steady' | 'none'; priorMs: number; deltaMs: number }; monthMs: number; totalMs: number; weekSessions: number; @@ -145,6 +148,17 @@ function cssColour(value: string | undefined): string { return `#${hex.slice(2)}${hex.slice(0, 2)}`; } +/* weekTrendClause is the " · ▲ 1h more than last week" tail on the this-week tile. Neutral + wording — more television is a fact, not a verdict — and empty when there is nothing to + compare against. */ +function weekTrendClause(trend: WatchTime['weekTrend']): string { + if (!trend || trend.direction === 'none') return ''; + if (trend.direction === 'steady') return ' · level with last week'; + const glyph = trend.direction === 'up' ? '▲' : '▼'; + const word = trend.direction === 'up' ? 'more' : 'less'; + return ` · ${glyph} ${watchTime(Math.abs(trend.deltaMs))} ${word} than last week`; +} + type Pending = | { kind: 'remove-device'; deviceId: string; name: string } | { kind: 'remove-account' } @@ -494,7 +508,7 @@ export function AccountPage() { —; } +/* Whether this week's viewing is up or down on the same point last week. Neutral by + design — more television is not a verdict — so it is the quiet caption the version + column already uses, not a coloured tag. Absent when there is nothing to compare. */ +function WeekTrend({ trend }: { trend?: WeekTrend }) { + if (!trend || trend.direction === 'none') return null; + if (trend.direction === 'steady') return ≈ level with last week; + const glyph = trend.direction === 'up' ? '▲' : '▼'; + const word = trend.direction === 'up' ? 'more' : 'less'; + const size = watchTime(Math.abs(trend.deltaMs)); + return {glyph} {size} vs last week; +} + export function AccountsPage() { const { data, error, loading, reload } = useQuery('/admin/api/accounts', { pollMs: 60_000 }); const { data: status } = useQuery('/admin/api/status', { pollMs: 60_000 }); @@ -62,7 +75,7 @@ export function AccountsPage() { {first?.version || Unknown}{first?.version && current(first.version) ? current : first?.version && latest ? update : null}{list.length > 1 ? {list.length - 1} more device{list.length === 2 ? '' : 's'} : null} {first?.lastIp || account.lastIp || } - {watched?.matched ? watchTime(watched.weekMs) : }{watched?.matched ? watchTime(watched.monthMs) : } + {watched?.matched ? <>{watchTime(watched.weekMs)} : }{watched?.matched ? watchTime(watched.monthMs) : } {ago(account.lastSeen)} void saveNotifications(account, next)} /> setConfirm(account)} /> diff --git a/admin-ui/src/pages/Features.tsx b/admin-ui/src/pages/Features.tsx index d512a8a..55784be 100644 --- a/admin-ui/src/pages/Features.tsx +++ b/admin-ui/src/pages/Features.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { api } from '../api/client'; import { useAction } from '../lib/hooks'; import { useGateway } from '../lib/gateway'; @@ -15,22 +16,39 @@ import { Loading, PageHead, PlainTiles, + Tabs, Tag, Toggle, } from '../components/ui'; import { RowsEditor } from '../components/RowsEditor'; +import { GatewaySettingsSection } from '../components/GatewaySettingsSection'; import type { RemoteSectionDefinition } from '../api/types'; -/* The three configuration keys that hold a page's row composition. They are edited - * visually through RowsEditor rather than through the generic configuration list below, - * which is why they are carved out of `configuration` before that list renders — a - * technical JSON textarea for these three is exactly what the visual editor replaces. */ +/* One page for everything the household is served, split into four views so the section an + * operator came for is not at the bottom of a single scroll: + * + * Overview — is anything wrong, and the whole-plane actions (safe mode, rollback). + * Page rows — what Home, Movies and TV show and in what order. + * Features — the optional feature flags and the typed configuration values. + * Server — the gateway process's own settings (timezone, logging, cache lifetimes). + * + * The active view rides `?tab=` so a link goes straight to it. */ + const ROW_CONFIGURATION_KEYS: Record<'home' | 'movies' | 'tv', string> = { home: 'home.sectionDefinitions', movies: 'movies.sectionDefinitions', tv: 'tv.sectionDefinitions', }; +const TABS = [ + { id: 'overview', label: 'Overview', icon: 'overview' }, + { id: 'rows', label: 'Page rows', icon: 'list' }, + { id: 'features', label: 'Features', icon: 'sliders' }, + { id: 'server', label: 'Server settings', icon: 'chip' }, +] as const; + +type TabId = (typeof TABS)[number]['id']; + interface Pending { action: 'safe-mode' | 'rollback' | 'reset' | 'feature'; title: string; @@ -45,13 +63,21 @@ export function FeaturesPage() { const { wrap } = useToast(); const { busy, run } = useAction(); const [pending, setPending] = useState(null); + const [params, setParams] = useSearchParams(); + + const requested = params.get('tab'); + const tab: TabId = TABS.some((entry) => entry.id === requested) ? (requested as TabId) : 'overview'; + const setTab = (next: TabId) => { + const copy = new URLSearchParams(params); + if (next === 'overview') copy.delete('tab'); + else copy.set('tab', next); + setParams(copy, { replace: true }); + }; const policy = status?.features; const features = policy?.features ?? []; const rowTypes = policy?.rowTypes ?? []; const rowConfiguration = policy?.configuration ?? []; - // Everything but the three row-composition values, which the visual editors below - // render instead of the generic list. const rowKeys = new Set(Object.values(ROW_CONFIGURATION_KEYS)); const configuration = rowConfiguration.filter((item) => !rowKeys.has(item.key)); const rowsFor = (key: string) => (rowConfiguration.find((item) => item.key === key)?.value as RemoteSectionDefinition[] | undefined) ?? []; @@ -104,238 +130,262 @@ export function FeaturesPage() { }); }; + const saveValue = (key: string, value: unknown) => + void run(key, async () => { + await wrap( + () => + api.post('/admin/api/features', { + action: 'save', + expectedRevision: revision, + values: valuesFor(key, value), + overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])), + }), + 'Configuration saved.', + ); + await reload(); + }); + return ( <> + - {loading || !policy ? ( - - ) : ( - <> - - - + {tab === 'overview' ? ( + loading || !policy ? ( + + ) : ( + <> + + + - - - + + safeMode + ? void act('leave-safe-mode', 'safe', 'Safe mode ended.') + : setPending({ + action: 'safe-mode', + title: 'Enable safe mode?', + body: + 'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.', + label: 'Enable safe mode', + }) + } + > + {safeMode ? 'Leave safe mode' : 'Enable safe mode'} + + } + > + feature.enabled).length} / ${features.length}`, + }, + { + label: 'explicit overrides', + value: num(features.filter((feature) => feature.source === 'override').length), + }, + { + label: 'televisions reporting the control plane', + value: `${capable} / ${clients.length}`, + }, + { label: 'published revision', value: `r${num(revision)}` }, + ]} + /> + - - safeMode - ? void act('leave-safe-mode', 'safe', 'Safe mode ended.') - : setPending({ - action: 'safe-mode', - title: 'Enable safe mode?', - body: - 'Every optional feature is disabled immediately on every television. Core sign-in, browsing and playback remain available.', - label: 'Enable safe mode', - }) - } - > - {safeMode ? 'Leave safe mode' : 'Enable safe mode'} - - } - > - feature.enabled).length} / ${features.length}`, - }, - { - label: 'explicit overrides', - value: num(features.filter((feature) => feature.source === 'override').length), - }, - { - label: 'televisions reporting the control plane', - value: `${capable} / ${clients.length}`, - }, - { label: 'published revision', value: `r${num(revision)}` }, - ]} + +
+ + + + {safeMode ? ( + safe mode · optional features off + ) : ( + live · revision r{num(revision)} + )} +
+
+ + ) + ) : null} + + {tab === 'rows' ? ( + loading || !policy ? ( + + ) : ( + <> + -
+ + + + ) + ) : null} - -
- {configuration.map((item) => { - const value = item.value; - return ( -
-
- {item.name} -
{item.description} · {item.scopes.join(', ')}
- {item.key} -
- {item.type === 'boolean' ? ( - void run(item.key, async () => { - await wrap(() => api.post('/admin/api/features', { action: 'save', expectedRevision: revision, values: valuesFor(item.key, next), overrides: Object.fromEntries(features.filter((f) => f.source === 'override').map((f) => [f.key, f.enabled])) }), 'Configuration saved.'); - await reload(); - })} - /> - ) : item.type === 'enum' ? ( - - ) : item.type === 'json' ? ( -