0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+106
View File
@@ -0,0 +1,106 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Layout } from './components/Layout';
import { GatewayProvider } from './lib/gateway';
import { NotificationProvider } from './lib/notifications';
import { ToastProvider } from './lib/toast';
import { PageHead } from './components/ui';
import { OverviewPage } from './pages/Overview';
import { ActivityPage } from './pages/Activity';
import { AccountsPage } from './pages/Accounts';
import { AccountPage } from './pages/Account';
import { SettingsHistoryPage } from './pages/SettingsHistory';
import { ClientsPage } from './pages/Clients';
import { LoginsPage } from './pages/Logins';
import { DevicePage } from './pages/Device';
import { LibraryPage } from './pages/Library';
import { RatingsPage } from './pages/Ratings';
import { RequestsPage } from './pages/Requests';
import { RecommendationsPage } from './pages/Recommendations';
import { InspectorPage } from './pages/Inspector';
import { HeroPage } from './pages/Hero';
import { FeaturesPage } from './pages/Features';
import { PlaybackPage } from './pages/Playback';
import { SubtitlesPage } from './pages/Subtitles';
import { UpdatesPage } from './pages/Updates';
import { TasksPage } from './pages/Tasks';
import { IntegrationsPage } from './pages/Integrations';
import { MaintenancePage } from './pages/Maintenance';
import { ImportsPage } from './pages/Imports';
import { LogsPage } from './pages/Logs';
import { JourneysPage } from './pages/Journeys';
import { JourneyViewerPage } from './pages/JourneyViewer';
import { EngagementPage } from './pages/Engagement';
import { SearchesPage } from './pages/Searches';
import { ViewsPage } from './pages/Views';
/* The console's routing table.
*
* The console is mounted at /admin. It is expressed as the parent route rather than as a
* router basename, because the navigation catalogue and gateway event links already carry
* their public /admin URLs. A basename would prepend that prefix a second time. */
function NotFound() {
return (
<PageHead
title="No such page"
intro="That address is not part of the console. Use the search in the bar above, or the sections on the left."
/>
);
}
export function App() {
return (
<BrowserRouter>
<GatewayProvider>
<NotificationProvider>
<ToastProvider>
<Routes>
<Route path="/admin" element={<Layout />}>
<Route index element={<OverviewPage />} />
<Route path="activity" element={<ActivityPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="accounts/:userId" element={<AccountPage />} />
<Route path="accounts/:userId/settings" element={<SettingsHistoryPage />} />
<Route path="clients" element={<ClientsPage />} />
<Route path="logins" element={<LoginsPage />} />
<Route path="devices/:deviceId" element={<DevicePage />} />
<Route path="library" element={<LibraryPage />} />
<Route path="ratings" element={<RatingsPage />} />
<Route path="requests" element={<RequestsPage />} />
<Route path="recommendations" element={<RecommendationsPage />} />
<Route path="inspector" element={<InspectorPage />} />
<Route path="hero" element={<HeroPage />} />
<Route path="features" element={<FeaturesPage />} />
<Route path="playback" element={<PlaybackPage />} />
<Route path="subtitles" element={<SubtitlesPage />} />
<Route path="updates" element={<UpdatesPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="integrations" element={<IntegrationsPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="imports" element={<ImportsPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="journeys" element={<JourneysPage />} />
<Route path="journeys/:userId" element={<JourneyViewerPage />} />
<Route path="views" element={<ViewsPage />} />
<Route path="engagement" element={<EngagementPage />} />
<Route path="searches" element={<SearchesPage />} />
{/* The old console redirected /admin/ to /admin/overview. Anything that
still links there lands on the overview rather than on a 404. */}
<Route path="overview" element={<Navigate to="/admin" replace />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</ToastProvider>
</NotificationProvider>
</GatewayProvider>
</BrowserRouter>
);
}
+97
View File
@@ -0,0 +1,97 @@
/* The console's one HTTP client.
*
* Everything it needs to authenticate is already on the request: the admin token is an
* HttpOnly cookie scoped to /admin, and the Emby-verified browser session is a second
* cookie beside it. The console therefore never holds a credential, which is why there is
* no token to store, refresh or accidentally log — and why an expired session is handled
* by reloading into the gateway's own sign-in form rather than by anything here.
*/
/** ApiError carries the status so a caller can tell "not configured" (404) from "broken". */
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
/* The sign-in behind this console lasts twelve hours and slides forward only for requests
an operator actually caused, so the poll of a tab nobody is reading cannot keep it
alive. Anything the console does while somebody is working it says so with this header;
see operatorPresent on the gateway. */
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
let lastInteraction = Date.now();
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll'] as const) {
window.addEventListener(name, () => {
lastInteraction = Date.now();
}, { passive: true });
}
const operatorPresent = () => Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
/* A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
the gateway's login form with `next` pointing back at it, so the operator signs in once
and lands where they were, instead of reading a banner the page can never clear. The
timestamp is what stops a 401 that survives the reload from looping for ever. */
const RELOGIN_KEY = 'memby-admin-relogin';
function reauthenticate(): boolean {
try {
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) ?? 0) < 30_000) return false;
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
} catch {
// Private-mode storage refusals must not cost the reload; take the loop risk.
}
window.location.reload();
return true;
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(operatorPresent() ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers ?? {}),
},
});
if (response.status === 401) {
throw new ApiError(
reauthenticate()
? 'Your sign-in has expired. Signing in again…'
: 'Your sign-in has expired. Reload this page to sign in again.',
401,
);
}
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new ApiError(body.error ?? `Request failed (${response.status})`, response.status);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
/** query turns a filter object into a query string, dropping anything unset.
*
* Dropping empties is what lets every page pass its whole filter state straight through:
* a blank control means "any" on the server, and sending `user=` would narrow to rows
* whose user is the empty string — which is none of them. */
export function query(params: Record<string, string | number | boolean | undefined | null>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '' || value === false) continue;
search.set(key, String(value));
}
const encoded = search.toString();
return encoded ? `?${encoded}` : '';
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body === undefined ? undefined : JSON.stringify(body) }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
+413
View File
@@ -0,0 +1,413 @@
/* The wire contract, as the console reads it.
*
* These mirror the Go structs in server/internal/api and server/internal/store. They are
* hand-written rather than generated, deliberately: the console reads a subset of what the
* gateway sends, and a generated file would carry every field of every response whether or
* not a page uses it — which makes it impossible to tell, from this file, what the console
* actually depends on.
*
* A field the console needs and the gateway does not send is a compile error here, which
* is the point. A field the gateway sends and this omits is ignored, which is also the
* point: an older console must keep working against a newer gateway. */
export interface KnownUser {
id: string;
username: string;
lastSeen: string;
}
export interface DeviceVersion {
version: string;
firstSeen: string;
lastSeen: string;
}
export interface KnownClient {
deviceId: string;
deviceName: string;
username: string;
version: string;
protocol: string;
capabilities: string[];
lastSeen: string;
versions: DeviceVersion[] | null;
}
export interface LibraryStats {
total: number;
byType: Record<string, number> | null;
lastSynced: string | null;
}
export interface SyncRun {
id: number;
kind: string;
trigger: string;
status: string;
startedAt: string;
finishedAt: string | null;
itemsSeen: number;
itemsUpserted: number;
itemsRemoved: number;
error?: string;
}
export interface ForYouStats {
tracearrSessions: number;
profiles: number;
candidates: number;
lastFullImport?: string | null;
}
export interface Maintenance {
enabled: boolean;
message: string;
updatedAt?: string;
}
export interface UpdatePolicy {
enabled: boolean;
latestVersion: string;
minimumVersion: string;
retireBelowVersion?: string;
downloadUrl: string;
sha256?: string;
sizeBytes?: number;
notes: string;
updatedAt?: string;
}
export interface RequestPolicy {
allowedUserIds: string[] | null;
updatedAt?: string;
}
export interface PlaybackPolicy {
prerollEnabled: boolean;
prerollDurationMs: number;
updatedAt?: string;
}
export interface HeroItem {
id: string;
name: string;
type: string;
year?: number;
}
export interface HeroPolicy {
pinnedItems: HeroItem[] | null;
primeSubtitle: string;
schedules?: HeroSchedule[] | null;
}
export interface RequestUsage {
userId: string;
requests: number;
lastRequest?: string;
}
export interface HeroSchedule {
id: string;
itemId: string;
startAt: string;
endAt: string;
weekdays?: number[];
priority: number;
userId?: string;
enabled: boolean;
}
export interface MDBListSettings {
enabled: boolean;
apiKeyConfigured: boolean;
sources: string[] | null;
availableSources: string[] | null;
cachedTitles: number;
staleTitles: number;
}
export interface SubtitleStoredStats {
files?: number;
bytes?: number;
titles?: number;
}
export interface SubtitleSettings {
bazarrConfigured: boolean;
bazarrEnabled: boolean;
bazarrUrl?: string;
openSubtitlesEnabled: boolean;
openSubtitlesKeyConfigured: boolean;
openSubtitlesAccount: boolean;
openSubtitlesUsername?: string;
featureEnabled: boolean;
available: boolean;
stored: SubtitleStoredStats;
}
export interface Feature {
key: string;
name: string;
description: string;
area: string;
defaultEnabled: boolean;
minimumProtocol: number;
capability: string;
/** recovery is the sentence saying how a change takes effect — the thing an operator
* most wants to know before flipping a switch on a household's televisions. */
recovery: string;
enabled: boolean;
/** source is "default" or "override" — whether an operator has touched this one. */
source: string;
compatible: boolean;
}
export interface FeaturePolicy {
schemaVersion: number;
revision: number;
safeMode: boolean;
updatedAt?: string;
canRollback: boolean;
features: Feature[] | null;
}
export interface AdminStatus {
serverVersion: string;
maintenance: Maintenance;
updatePolicy: UpdatePolicy;
library: LibraryStats;
syncRunning: boolean;
runs: SyncRun[] | null;
syncEvery: string;
forYou: ForYouStats;
forYouRunning: boolean;
requestPolicy: RequestPolicy;
playbackPolicy: PlaybackPolicy;
heroPolicy: HeroPolicy;
mdblist: MDBListSettings;
subtitles: SubtitleSettings;
features: FeaturePolicy;
requestUsers: KnownUser[] | null;
requestUsage: RequestUsage[] | null;
clients: KnownClient[] | null;
sonarrReady: boolean;
radarrReady: boolean;
}
export interface ViewsReport {
today: { visits: number; viewers: number };
lastWeek: { visits: number; viewers: number };
daily: { label: string; visits: number; viewers: number }[];
hourly: { label: string; visits: number; viewers: number }[];
busiestHour: string;
}
/* ---------- sign-in history ---------- */
export interface LoginEvent {
id: number;
occurredAt: string;
embyUserId: string;
username: string;
deviceId: string;
deviceName: string;
clientVersion: string;
clientProtocol: string;
ipAddress: string;
success: boolean;
method: string;
failureReason?: string;
newDevice: boolean;
}
export interface LoginTotals {
logins: number;
failures: number;
devices: number;
users: number;
addresses: number;
first: string;
last: string;
}
export interface LoginDay {
day: string;
logins: number;
failures: number;
devices: number;
}
export interface LoginAddress {
ipAddress: string;
logins: number;
failures: number;
firstSeen: string;
lastSeen: string;
}
export interface LoginDeviceSummary {
deviceId: string;
deviceName: string;
embyUserId: string;
username: string;
clientVersion: string;
logins: number;
failures: number;
firstLogin: string;
lastLogin: string;
lastIp: string;
distinctIps: number;
loginsToday: number;
}
export interface LoginsResponse {
events: LoginEvent[];
total: number;
limit: number;
offset: number;
totals: LoginTotals;
days: LoginDay[];
addresses: LoginAddress[];
users: KnownUser[];
retentionDays: number;
timezone: string;
}
export interface LoginDevicesResponse {
devices: LoginDeviceSummary[];
totals: LoginTotals;
users: KnownUser[];
timezone: string;
retentionDays: number;
}
export interface DeviceDetailResponse {
deviceId: string;
summary?: LoginDeviceSummary;
events: LoginEvent[];
total: number;
days: LoginDay[];
addresses: LoginAddress[];
versions: DeviceVersion[];
timezone: string;
}
/* ---------- scheduled tasks ---------- */
export interface TaskRun {
id: number;
taskId: string;
trigger: string;
status: 'running' | 'success' | 'failed' | 'skipped';
startedAt: string;
finishedAt?: string;
durationMs: number;
detail?: string;
error?: string;
}
export interface ScheduledTask {
id: string;
name: string;
description: string;
group: string;
intervalSeconds: number;
enabled: boolean;
running: boolean;
nextRun?: string;
lastRun?: TaskRun;
}
export interface TasksResponse {
tasks: ScheduledTask[];
groups: string[];
runs: TaskRun[];
}
/* ---------- integrations ---------- */
export interface IntegrationHealth {
integrationId: string;
lastSuccess?: string;
lastFailure?: string;
lastError?: string;
deliveries: number;
failures: number;
}
export interface IntegrationDelivery {
id: number;
integrationId: string;
eventType: string;
attemptedAt: string;
success: boolean;
statusCode: number;
durationMs: number;
error?: string;
}
export interface Integration {
id: string;
kind: string;
name: string;
enabled: boolean;
events: string[] | null;
createdAt: string;
updatedAt: string;
/** hasUrl rather than the address itself: the webhook URL is the credential and the
* gateway never returns it. `hint` is the channel id, which is enough to tell two
* rows apart and carries none of the token. */
hasUrl: boolean;
hint?: string;
health: IntegrationHealth;
deliveries: IntegrationDelivery[];
}
export interface IntegrationEventOption {
type: string;
label: string;
description: string;
group: string;
}
export interface IntegrationsResponse {
integrations: Integration[];
catalogue: IntegrationEventOption[];
dropped: number;
}
/* ---------- logs ---------- */
export interface LogEvent {
occurredAt: string;
level: string;
message: string;
attributes: Record<string, unknown> | null;
}
export interface LogResponse {
events: LogEvent[] | null;
next: number;
oldest: number;
latest: number;
dropped: number;
hasMore: boolean;
}
/* ---------- runtime ---------- */
export interface RuntimeStatus {
goroutines: number;
gomaxprocs: number;
heapAlloc: number;
heapInuse: number;
heapIdle: number;
heapReleased: number;
stackInuse: number;
sys: number;
nextGc: number;
numGc: number;
memoryLimit: number;
configuredLimit?: string;
}
+88
View File
@@ -0,0 +1,88 @@
import type { Tone } from '../lib/format';
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
so a page never carries SVG markup of its own and two screens showing the same idea
cannot draw it two ways. An unknown name draws nothing rather than a broken box — a
mark is decoration, and a typo in one must never be what an operator notices about a
page. */
export const icons = {
overview: 'M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z',
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people:
'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database:
'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
upload: 'M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle:
'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: 'm14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
captions: 'M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5',
journey: 'M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3',
plug: 'M9 3v6M15 3v6M6.5 9h11v3.5a5.5 5.5 0 0 1-11 0zM12 18v3',
calendar: 'M4 6h16v15H4zM8 3v5M16 3v5M4 11h16',
trash: 'M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13M10 11v5M14 11v5',
plus: 'M12 5v14M5 12h14',
close: 'M6 6l12 12M18 6 6 18',
caret: 'm6 9 6 6 6-6',
external: 'M14 4h6v6M20 4l-9 9M18 14v5.5H4.5V6H10',
refresh: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
filter: 'M3.5 5.5h17l-6.5 7.5V20l-4-2v-5L3.5 5.5Z',
menu: 'M4 7h16M4 12h16M4 17h16',
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 9h17M3.5 15h17M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18Z',
logout: 'M15 17l5-5-5-5M20 12H9M12 4H5v16h7',
} as const;
export type IconName = keyof typeof icons;
export function Icon({ name, className }: { name: IconName; className?: string }) {
const path = icons[name];
if (!path) return null;
return (
<svg
className={className ?? 'ico'}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<path d={path} />
</svg>
);
}
/** Glyph is an icon in a tinted plate — the tile and card-heading mark.
*
* The tone is what says which area a thing belongs to, so it is passed rather than
* derived: the same idea wears the same colour on every page it appears on, which is
* most of what makes two dozen screens read as one console. */
export function Glyph({ name, tone }: { name: IconName; tone?: Tone }) {
if (!icons[name]) return null;
return (
<span className="glyph" data-tone={tone}>
<Icon name={name} />
</span>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
import { NotificationBell } from './NotificationBell';
import { nav } from '../nav';
import { useNotifications } from '../lib/notifications';
import { useGateway } from '../lib/gateway';
import { time } from '../lib/format';
/* The shell: a top bar spanning the width, a rail down the left, and the page.
*
* Everything in the bar is true of the console rather than of any one screen — which
* gateway this is, whether it is answering, the page search, the activity bell and the way
* out. Everything in the rail is a destination. Nothing else lives in either. */
function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { unread } = useNotifications();
const location = useLocation();
// A group holding the current page is open regardless of what was collapsed last time:
// a rail that hides the page you are on is a rail that has lost its place.
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
try {
return JSON.parse(localStorage.getItem('memby-admin-nav') ?? '{}') as Record<string, boolean>;
} catch {
return {};
}
});
const toggle = (id: string) => {
setCollapsed((current) => {
const next = { ...current, [id]: !current[id] };
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which groups were open, and
// nothing else.
}
return next;
});
};
return (
<nav className="rail" id="rail" data-open={open || undefined} aria-label="Console sections">
{nav.map((group) => {
const items = group.items.filter((item) => !item.hidden);
if (items.length === 0) return null;
const holdsCurrent = items.some((item) => location.pathname === item.path);
const expanded = holdsCurrent || !collapsed[group.id];
return (
<div className="rail-group" key={group.id}>
{group.label ? (
<button
type="button"
className="rail-head"
aria-expanded={expanded}
onClick={() => toggle(group.id)}
>
{group.label}
<Icon name="caret" className="ico caret" />
</button>
) : null}
{expanded
? items.map((item) => (
<NavLink
key={item.id}
to={item.path}
end={item.path === '/admin'}
onClick={onNavigate}
className={({ isActive }) => (isActive ? 'on' : '')}
aria-current={undefined}
>
{({ isActive }) => (
<span
style={{ display: 'contents' }}
// NavLink's own aria-current lands on the anchor; this mirrors it
// onto the element the stylesheet selects.
ref={(node) => {
const anchor = node?.parentElement;
if (anchor) {
if (isActive) anchor.setAttribute('aria-current', 'page');
else anchor.removeAttribute('aria-current');
}
}}
>
{item.icon ? <Icon name={item.icon} /> : null}
{item.label}
{item.badge === 'notifications' && unread > 0 ? (
<span className="rail-badge">{unread > 99 ? '99+' : unread}</span>
) : null}
</span>
)}
</NavLink>
))
: null}
</div>
);
})}
</nav>
);
}
export function Layout() {
const { version, online, checkedAt, status, setMaintenance } = useGateway();
const [railOpen, setRailOpen] = useState(false);
const [changingAvailability, setChangingAvailability] = useState(false);
const location = useLocation();
const offline = Boolean(status?.maintenance?.enabled);
const toggleAvailability = async () => {
if (changingAvailability || !status) return;
if (!offline && !window.confirm('Take Memby offline for every television?')) return;
setChangingAvailability(true);
try {
await setMaintenance(!offline);
} finally {
setChangingAvailability(false);
}
};
// The rail is an overlay on a narrow screen, so a navigation has to close it — otherwise
// it sits over the page that was just opened.
useEffect(() => setRailOpen(false), [location.pathname]);
return (
<>
<a className="skip" href="#main">
Skip to content
</a>
<header className="topbar">
<a className="topbar-brand" href="/admin">
<span className="brand-mark" aria-hidden="true">
M
</span>
<span className="brand-word">Memby Gateway</span>
</a>
<button
type="button"
className="rail-toggle"
aria-label="Sections"
aria-expanded={railOpen}
aria-controls="rail"
onClick={() => setRailOpen((current) => !current)}
>
<Icon name="menu" />
</button>
<div className="topbar-spacer" />
<div className="topbar-tools">
<OmniSearch />
<span className="topbar-version">gateway {version || 'unknown'}</span>
{/* This is both a live gateway verdict and its operator-controlled availability.
The switch is intentionally in the shared header: taking the household
offline should not require leaving the page where the operator noticed it. */}
<button
type="button"
className="topbar-status"
data-tone={online && !offline ? 'ok' : 'bad'}
aria-pressed={offline}
disabled={!status || changingAvailability}
title={offline ? 'Bring Memby back online' : 'Take Memby offline'}
onClick={() => void toggleAvailability()}
>
<span className="dot" />
<b>{offline ? 'offline' : online ? 'online' : 'not responding'}</b>
<span>{offline ? 'click to bring back online' : checkedAt ? `updated ${time(checkedAt)}` : ''}</span>
</button>
<NotificationBell />
<form method="post" action="/admin/logout">
<button type="submit" data-variant="quiet" data-size="sm" title="Sign out">
<Icon name="logout" />
</button>
</form>
</div>
</header>
<Rail open={railOpen} onNavigate={() => setRailOpen(false)} />
<main className="page" id="main">
<Outlet />
</main>
</>
);
}
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Glyph, Icon } from './Icon';
import { ago } from '../lib/format';
import { eventIcon, eventTone, useNotifications } from '../lib/notifications';
/* The bell in the top bar: rolling operational events, live.
*
* Opening it marks what it shows as read, rather than a "mark read" control per row.
* The rule is the one the television's own alerts follow in reverse — there, an alert is
* only marked seen once it has actually been drawn, because one composed behind an overlay
* was never seen. Here the panel *is* the drawing, so opening it is the receipt. */
const BADGE_MAX = 99;
export function NotificationBell() {
const { events, unread, connected, markRead, markAllRead } = useNotifications();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKey);
};
}, []);
useEffect(() => {
if (!open) return;
const visible = events.filter((event) => !event.readAt).map((event) => event.id);
if (visible.length > 0) void markRead(visible);
// Deliberately keyed on `open` alone. Re-running it as events arrive would mark an
// event read the instant it landed while the panel happened to be open, which is the
// one case where the operator genuinely has not read it yet.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<div className="bell" ref={root} data-open={open || undefined}>
<button
type="button"
className="bell-button"
aria-label={unread > 0 ? `Activity, ${unread} unread` : 'Activity'}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<Icon name="bell" />
{unread > 0 ? (
<span className="bell-badge">{unread > BADGE_MAX ? `${BADGE_MAX}+` : unread}</span>
) : null}
</button>
{open ? (
<div className="bell-panel">
<div className="bell-head">
<b>Activity</b>
<div className="row tight">
{/* Whether the stream is attached is stated, because "nothing has happened"
and "we stopped being told" look identical otherwise. */}
{connected ? null : <span className="tag" data-tone="warn">reconnecting</span>}
{unread > 0 ? (
<button type="button" data-variant="quiet" data-size="sm" onClick={() => void markAllRead()}>
Mark all read
</button>
) : null}
</div>
</div>
<div className="bell-list">
{events.length === 0 ? (
<p className="empty">Nothing has happened yet.</p>
) : (
events.slice(0, 20).map((event) => {
const body = (
<>
<Glyph name={eventIcon(event.type)} tone={eventTone(event)} />
<span className="bell-body">
<b>{event.title || event.type}</b>
{event.summary ? <p>{event.summary}</p> : null}
<time dateTime={event.occurredAt}>{ago(event.occurredAt)}</time>
</span>
</>
);
return event.link ? (
<Link
key={event.id}
className="bell-item"
data-unread={!event.readAt || undefined}
to={event.link}
onClick={() => setOpen(false)}
>
{body}
</Link>
) : (
<div key={event.id} className="bell-item" data-unread={!event.readAt || undefined}>
{body}
</div>
);
})
)}
</div>
<div className="bell-foot">
<Link to="/admin/activity" onClick={() => setOpen(false)}>
All activity
</Link>
</div>
</div>
) : null}
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Icon, type IconName } from './Icon';
import { searchableNavItems } from '../nav';
import { useGateway } from '../lib/gateway';
/* The rail is a menu; this is the same two dozen pages addressed by name.
*
* It exists because a rail seven groups deep is more than anybody reads down, and because
* it is the only thing on screen saying the console can be driven from the keyboard —
* which is why the shortcut is printed on the control rather than merely working. */
interface SearchItem {
id: string;
path: string;
label: string;
title: string;
intro: string;
group: string;
icon?: IconName;
}
interface Ranked {
item: SearchItem;
rank: number;
}
/* A page's own name outranks the sentence under it, or typing "se" answers with every page
whose description happens to contain those letters and buries Searches. Ties keep the
rail's order, so the list never reshuffles between two equally good matches. */
function score(item: SearchItem, query: string): number {
const label = item.label.toLowerCase();
const group = item.group.toLowerCase();
if (label.startsWith(query)) return 4;
if (label.includes(query)) return 3;
if (group.includes(query)) return 2;
return `${item.title} ${item.intro}`.toLowerCase().includes(query) ? 1 : 0;
}
export function OmniSearch() {
const navigate = useNavigate();
const { status } = useGateway();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [cursor, setCursor] = useState(0);
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const results = useMemo<Ranked[]>(() => {
const needle = query.trim().toLowerCase();
const destinations: SearchItem[] = [
...searchableNavItems,
...(status?.requestUsers ?? []).map((user) => ({
id: `user-${user.id}`,
path: `/admin/accounts/${encodeURIComponent(user.id)}`,
label: user.username || 'Unnamed user',
title: `User: ${user.username || 'Unnamed user'}`,
intro: 'Open this users devices and settings.',
group: 'Users',
icon: 'people' as const,
})),
...(status?.clients ?? []).map((client) => ({
id: `device-${client.deviceId}`,
path: `/admin/devices/${encodeURIComponent(client.deviceId)}`,
label: client.deviceName || 'Unnamed device',
title: `Device: ${client.deviceName || 'Unnamed device'}`,
intro: `${client.username || 'Unknown user'} · ${client.version || 'unknown version'}`,
group: 'Devices',
icon: 'tv' as const,
})),
];
return destinations
.map((item, index) => ({ item, rank: needle ? score(item, needle) : 1, index }))
.filter((entry) => entry.rank > 0)
.sort((a, b) => b.rank - a.rank || a.index - b.index)
.map(({ item, rank }) => ({ item, rank }));
}, [query]);
// Something is always selected, so Enter has an answer without an arrow press first.
useEffect(() => setCursor(0), [query]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, []);
useEffect(() => {
// Shift+S from anywhere, except from inside something being typed into — the console
// is full of fields, and a shortcut that ate a capital letter would be worse than none.
const onKey = (event: KeyboardEvent) => {
if (event.key !== 'S' || !event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
const active = document.activeElement as HTMLElement | null;
if (active && (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName))) return;
event.preventDefault();
input.current?.focus();
input.current?.select();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const go = (path: string) => {
setOpen(false);
setQuery('');
input.current?.blur();
navigate(path);
};
return (
<div className="omni" ref={root} data-open={open || undefined}>
<div className="omni-input">
<Icon name="search" />
<input
ref={input}
type="search"
value={query}
placeholder="Search pages, users and devices…"
aria-label="Search pages, users and devices"
aria-expanded={open}
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setQuery('');
setOpen(false);
input.current?.blur();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setCursor((current) => Math.min(current + 1, results.length - 1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setCursor((current) => Math.max(current - 1, 0));
} else if (event.key === 'Enter') {
const target = results[cursor];
if (!target) return;
event.preventDefault();
go(target.item.path);
}
}}
/>
<span className="omni-key">S</span>
</div>
{open ? (
<div className="omni-panel" role="listbox">
{results.length === 0 ? (
<p className="empty">No pages, users or devices match that search.</p>
) : (
results.map((entry, index) => (
<a
key={entry.item.id}
className={index === cursor ? 'omni-item on' : 'omni-item'}
href={entry.item.path}
role="option"
aria-selected={index === cursor}
onPointerEnter={() => setCursor(index)}
onClick={(event) => {
event.preventDefault();
go(entry.item.path);
}}
>
{entry.item.icon ? <Icon name={entry.item.icon} /> : null}
<span>
<b>{entry.item.label}</b>
<small>{entry.item.intro}</small>
</span>
<span className="omni-group">{entry.item.group}</span>
</a>
))
)}
</div>
) : null}
</div>
);
}
+484
View File
@@ -0,0 +1,484 @@
import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
import { Glyph, Icon, type IconName } from './Icon';
import type { Tone } from '../lib/format';
/* The component vocabulary. Every page is built from what is below and adds nothing of its
own: a screen that needs a look it cannot get from here is a missing component, not a
licence for a style attribute. */
/* ---------- structure ---------- */
export function PageHead({
title,
intro,
actions,
crumbs,
}: {
title: string;
intro?: string;
actions?: ReactNode;
crumbs?: ReactNode;
}) {
return (
<header className="page-head">
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
<div className="page-head-row">
<div>
<h1>{title}</h1>
{intro ? <p>{intro}</p> : null}
</div>
{actions ? <div className="page-head-actions">{actions}</div> : null}
</div>
</header>
);
}
export function Card({
title,
intro,
icon,
tone,
actions,
footer,
children,
}: {
title?: string;
intro?: string;
icon?: IconName;
tone?: Tone;
actions?: ReactNode;
footer?: ReactNode;
children: ReactNode;
}) {
return (
<section className="card">
{title ? (
<div className="card-head">
{icon ? <Glyph name={icon} tone={tone} /> : null}
<div className="card-head-text">
<h2>{title}</h2>
{intro ? <p>{intro}</p> : null}
</div>
{actions ? <div className="card-head-actions">{actions}</div> : null}
</div>
) : null}
{children}
{footer ? <div className="card-foot">{footer}</div> : null}
</section>
);
}
export function Grid({ cols, children }: { cols?: '2' | 'wide'; children: ReactNode }) {
return (
<div className="grid" data-cols={cols}>
{children}
</div>
);
}
/* ---------- tiles ---------- */
export interface TileSpec {
label: string;
value: ReactNode;
/** small is for a value that is a sentence rather than a number. */
small?: boolean;
icon?: IconName;
tone?: Tone;
to?: string;
}
export function Tiles({ tiles }: { tiles: TileSpec[] }) {
return (
<div className="tiles">
{tiles.map((tile) => (
<div className="tile" key={tile.label}>
{tile.icon ? <Glyph name={tile.icon} tone={tile.tone} /> : null}
<b className={tile.small ? 'small' : undefined}>{tile.value}</b>
<span>{tile.label}</span>
</div>
))}
</div>
);
}
/* ---------- verdicts ---------- */
export function Tag({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<span className="tag" data-tone={tone}>
{children}
</span>
);
}
export function Chip({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<span className="chip" data-tone={tone}>
{children}
</span>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <p className="empty">{children}</p>;
}
export function EmptyRow({ columns, children }: { columns: number; children: ReactNode }) {
return (
<tr>
<td colSpan={columns} className="muted">
<p className="empty">{children}</p>
</td>
</tr>
);
}
export function Note({ children, tone }: { children: ReactNode; tone?: Tone }) {
return (
<p className="note" data-tone={tone}>
{children}
</p>
);
}
/* ---------- controls ---------- */
export function Button({
children,
onClick,
variant,
size,
disabled,
busy,
icon,
type = 'button',
title,
}: {
children?: ReactNode;
onClick?: () => void;
variant?: 'primary' | 'danger' | 'quiet';
size?: 'sm';
disabled?: boolean;
busy?: boolean;
icon?: IconName;
type?: 'button' | 'submit';
title?: string;
}) {
return (
<button
type={type}
className=""
data-variant={variant}
data-size={size}
disabled={disabled || busy}
onClick={onClick}
title={title}
>
{busy ? <span className="spinner" /> : icon ? <Icon name={icon} /> : null}
{children}
</button>
);
}
export function Field({
label,
hint,
children,
grow,
}: {
label: string;
hint?: string;
children: ReactNode;
grow?: boolean;
}) {
return (
<label className={grow ? 'field grow' : 'field'}>
<span>{label}</span>
{children}
{hint ? <small>{hint}</small> : null}
</label>
);
}
/** Toggle is the console's on/off control. A switch rather than a checkbox: the console is
* full of policy that is on or off, and a control saying which way it points from across
* the room is worth the extra markup. */
export function Toggle({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
}) {
return (
<label className="check">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="switch" />
<span className="check-body">
<b>{label}</b>
{hint ? <p>{hint}</p> : null}
</span>
</label>
);
}
/** Segments is a row of exclusive choices — a date window, a severity — where a select
* would hide the options behind a click. */
export function Segments<T extends string | number>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (next: T) => void;
}) {
return (
<div className="segments" role="group">
{options.map((option) => (
<button
key={String(option.value)}
type="button"
aria-pressed={option.value === value}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
/* ---------- tables ---------- */
export function TableWrap({ children }: { children: ReactNode }) {
return <div className="table-wrap">{children}</div>;
}
/** SortHeader is a column header that sorts. It is a button so it is reachable from a
* keyboard, and it reports aria-sort so a screen reader can say which way. */
export function SortHeader<K extends string>({
label,
column,
sort,
onSort,
numeric,
}: {
label: string;
column: K;
sort: { key: K | null; direction: 'asc' | 'desc' };
onSort: (key: K) => void;
numeric?: boolean;
}) {
const active = sort.key === column;
return (
<th
className={numeric ? 'num' : undefined}
aria-sort={active ? (sort.direction === 'asc' ? 'ascending' : 'descending') : undefined}
>
<button type="button" onClick={() => onSort(column)}>
{label}
{active ? <span aria-hidden="true">{sort.direction === 'asc' ? '↑' : '↓'}</span> : null}
</button>
</th>
);
}
/** useSort is the state behind SortHeader: pressing the active column reverses it,
* pressing another switches to it descending — the useful default, since every numeric
* column here is read biggest-first. */
export function useSort<K extends string>(initial: K | null = null) {
const [sort, setSort] = useState<{ key: K | null; direction: 'asc' | 'desc' }>({
key: initial,
direction: 'desc',
});
const onSort = (key: K) =>
setSort((current) =>
current.key === key
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { key, direction: 'desc' },
);
return { sort, onSort };
}
/* ---------- charts ---------- */
/** Bars is the console's one chart: a day-by-day count, drawn with divs.
*
* A charting library would be the single largest dependency here for the sake of four
* bar charts, and this console's whole stance is that it fetches nothing from anywhere. */
export function Bars({
data,
labelOf,
valueOf,
toneOf,
title,
}: {
data: readonly unknown[];
labelOf: (row: never, index: number) => string;
valueOf: (row: never) => number;
toneOf?: (row: never) => Tone | undefined;
title?: (row: never) => string;
}) {
if (data.length === 0) return <Empty>Nothing in this window.</Empty>;
const values = data.map((row) => valueOf(row as never));
const peak = Math.max(1, ...values);
return (
<>
<div className="bars">
{data.map((row, index) => {
const value = valueOf(row as never);
return (
<div
key={index}
className="bar"
data-tone={toneOf?.(row as never)}
data-empty={value === 0 || undefined}
style={{ height: `${Math.max(2, (value / peak) * 100)}%` }}
title={title ? title(row as never) : `${labelOf(row as never, index)}: ${value}`}
/>
);
})}
</div>
<div className="bars-axis">
<span>{labelOf(data[0] as never, 0)}</span>
<span>{labelOf(data[data.length - 1] as never, data.length - 1)}</span>
</div>
</>
);
}
export function Meter({ value, total, tone }: { value: number; total: number; tone?: Tone }) {
const share = total > 0 ? Math.min(1, value / total) : 0;
return (
<div className="meter" data-tone={tone}>
<div style={{ width: `${share * 100}%` }} />
</div>
);
}
/* ---------- feedback ---------- */
export function Banner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
if (!message) return null;
return (
<div className="banner" role="alert">
<Icon name="alert" />
<span>{message}</span>
{onDismiss ? (
<button type="button" onClick={onDismiss} aria-label="Dismiss">
<Icon name="close" />
</button>
) : null}
</div>
);
}
/** Loading is the page's own first-load state: a shape rather than a spinner, so the
* layout does not jump when the data lands. */
export function Loading({ rows = 3 }: { rows?: number }) {
return (
<div className="loading-page" aria-busy="true" aria-label="Loading">
<div className="loading-heading"><span className="skeleton" /><span className="skeleton" /></div>
<div className="loading-tiles">{Array.from({ length: 4 }, (_, index) => <span key={index} className="skeleton" />)}</div>
{Array.from({ length: rows }, (_, index) => <section key={index} className="loading-card"><span className="skeleton" /><span className="skeleton" /><span className="skeleton" /></section>)}
</div>
);
}
/* ---------- dialog ---------- */
/** Confirm is the one destructive-action guard.
*
* Every irreversible button in the console goes through it rather than through the
* browser's own confirm(), which cannot say *what* is about to happen in more than one
* line and cannot be styled to look like it belongs to the page it interrupts. */
export function Confirm({
title,
body,
confirmLabel = 'Confirm',
destructive,
busy,
onConfirm,
onCancel,
}: {
title: string;
body: ReactNode;
confirmLabel?: string;
destructive?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const headingId = useId();
const first = useRef<HTMLDivElement>(null);
useEffect(() => {
first.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true" aria-labelledby={headingId} ref={first} tabIndex={-1}>
<h2 id={headingId}>{title}</h2>
<p>{body}</p>
<div className="dialog-actions">
<Button onClick={onCancel} variant="quiet">
Cancel
</Button>
<Button onClick={onConfirm} variant={destructive ? 'danger' : 'primary'} busy={busy}>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}
/* ---------- key/value ---------- */
/** KeyValue is a label and a verdict: the console's densest way of stating a handful of
* facts about one subject, and what the overview is built from. */
export function KeyValue({ rows }: { rows: { label: string; value: ReactNode }[] }) {
return (
<div className="kv">
{rows.map((row) => (
<div className="kv-row" key={row.label}>
<span>{row.label}</span>
<span>{row.value}</span>
</div>
))}
</div>
);
}
/** PlainTiles is a tile strip with no card around it, for a group of numbers inside one. */
export function PlainTiles({ tiles }: { tiles: TileSpec[] }) {
return (
<div className="tiles plain">
{tiles.map((tile) => (
<div className="tile" key={tile.label}>
<b className={tile.small ? 'small' : undefined}>{tile.value}</b>
<span>{tile.label}</span>
</div>
))}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
/* The formatters every page draws with. Carried over from the console this replaces, so
the same value reads the same way it always did.
A page that needs a new one adds it here; two screens formatting the same idea two ways
is exactly what one shared module exists to prevent. */
export const num = (value: number | undefined | null): string => (value ?? 0).toLocaleString();
export const when = (value: string | undefined | null): string =>
value ? new Date(value).toLocaleString() : '—';
export const time = (value: string | undefined | null): string =>
value ? new Date(value).toLocaleTimeString() : '—';
export const day = (value: string | undefined | null): string =>
value ? new Date(value).toLocaleDateString() : '—';
/** duration formats a span of milliseconds for reading, not for arithmetic. */
export function duration(ms: number | undefined | null): string {
if (!ms) return '0s';
if (ms < 1000) return `${Math.round(ms)}ms`;
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/** interval describes a schedule, where "3600s" is a worse answer than "every hour". */
export function interval(seconds: number | undefined | null): string {
if (!seconds || seconds <= 0) return 'on request only';
if (seconds < 60) return `every ${seconds}s`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `every ${minutes} min`;
const hours = Math.round(minutes / 60);
if (hours < 48) return hours === 1 ? 'hourly' : `every ${hours} hours`;
return `every ${Math.round(hours / 24)} days`;
}
export function bytes(value: number | undefined | null): string {
const units = ['B', 'KB', 'MB', 'GB'];
let amount = Number(value ?? 0);
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024;
unit += 1;
}
return `${unit === 0 ? amount : amount.toFixed(1)} ${units[unit]}`;
}
export const initials = (name: string | undefined | null): string =>
String(name ?? '?')
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0] ?? '')
.join('')
.toUpperCase();
export const percent = (value: number | undefined | null): string =>
`${Math.round((value ?? 0) * 100)}%`;
/** ago is the relative reading — "4 min ago" — which is what a feed wants where an
* absolute timestamp is what a table wants. Both, never one standing in for the other. */
export function ago(value: string | undefined | null): string {
if (!value) return '—';
const elapsed = Date.now() - new Date(value).getTime();
if (elapsed < 0) return 'just now';
const seconds = Math.floor(elapsed / 1000);
if (seconds < 45) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(value).toLocaleDateString();
}
/* "Seen in the last quarter of an hour" is what the console means by active: a television
checks in every few seconds while somebody is using it. */
const ACTIVE_MS = 15 * 60 * 1000;
const IDLE_MS = 3 * 60 * 60 * 1000;
export const recent = (value: string | undefined | null): boolean =>
Boolean(value) && Date.now() - new Date(value as string).getTime() < ACTIVE_MS;
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'note' | 'data';
/* Three states rather than two, because "not active this minute" covers both a set
somebody switched off after breakfast and one that has not been seen since lunchtime —
and only the second is worth an operator's attention. Green is on now, amber is a set
in ordinary use that happens to be off, and red is one that has stopped checking in for
three hours. A device with no timestamp at all is red: never seen is the strongest
version of not seen. */
export function presence(value: string | undefined | null): { tone: Tone; label: string } {
const seen = value ? new Date(value).getTime() : 0;
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
/** isoDay is the YYYY-MM-DD a date filter sends, in the browser's own zone.
*
* Local rather than UTC on purpose: the operator types a day they mean locally, and the
* gateway reads a bare date in the *household's* zone. Sending toISOString() here would
* shift the whole window by the browser's offset before the server ever saw it. */
export function isoDay(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export const daysAgo = (days: number): string => {
const date = new Date();
date.setDate(date.getDate() - days);
return isoDay(date);
};
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { api } from '../api/client';
import type { AdminStatus } from '../api/types';
/* /admin/api/status is the console's shared heartbeat.
*
* It is one poll for the whole console rather than one per page, for two reasons. It is
* the gateway's most frequent caller by a wide margin — somebody leaves this open on a
* second monitor — and it carries most of what the pages need: maintenance state, the
* feature policy, the library counts, the update policy, the client list. A page that
* fetched it itself would multiply the traffic and could disagree with the bar above it
* about whether the gateway is answering.
*
* It stops entirely on a hidden tab and catches up the moment the tab is looked at again. */
const POLL_MS = 30_000;
interface GatewayState {
status: AdminStatus | undefined;
version: string;
online: boolean;
/** checkedAt is the evidence behind the verdict: a page that looks alive while its
* numbers are twenty minutes old is the failure the bar exists to prevent. */
checkedAt: string;
error: string;
loading: boolean;
reload: () => Promise<void>;
setMaintenance: (enabled: boolean) => Promise<void>;
}
const GatewayContext = createContext<GatewayState | null>(null);
export function GatewayProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AdminStatus>();
const [online, setOnline] = useState(true);
const [checkedAt, setCheckedAt] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const generation = useRef(0);
const reload = useCallback(async () => {
const mine = ++generation.current;
try {
const next = await api.get<AdminStatus>('/admin/api/status');
if (mine !== generation.current) return;
setStatus(next);
setOnline(true);
setError('');
} catch (err) {
if (mine !== generation.current) return;
setOnline(false);
setError(err instanceof Error ? err.message : String(err));
} finally {
if (mine === generation.current) {
setCheckedAt(new Date().toISOString());
setLoading(false);
}
}
}, []);
// Maintenance is a gateway-wide state, so the header and the dedicated Maintenance
// page must both change it through this one path. Keeping the fresh status response
// here means every open console immediately agrees about whether Memby is online.
const setMaintenance = useCallback(
async (enabled: boolean) => {
await api.post('/admin/api/maintenance', {
enabled,
message: status?.maintenance?.message ?? '',
});
await reload();
},
[reload, status?.maintenance?.message],
);
useEffect(() => {
void reload();
let timer: number | undefined;
const start = () => {
window.clearInterval(timer);
timer = document.hidden ? undefined : window.setInterval(() => void reload(), POLL_MS);
};
const onVisibility = () => {
start();
if (!document.hidden) void reload();
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [reload]);
const value = useMemo<GatewayState>(
() => ({
status,
version: status?.serverVersion ?? '',
online,
checkedAt,
error,
loading,
reload,
setMaintenance,
}),
[status, online, checkedAt, error, loading, reload, setMaintenance],
);
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>;
}
export function useGateway(): GatewayState {
const value = useContext(GatewayContext);
if (!value) throw new Error('useGateway used outside GatewayProvider');
return value;
}
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
/* The two hooks every page is built from.
*
* They exist because the previous console's `Admin.onRefresh` did one thing this cannot:
* it guaranteed that a poll never redrew markup the operator was working inside. React
* makes that guarantee differently — a controlled input holds its own value and a re-render
* cannot take it away — but the other half of the old rule still applies here and is easy
* to lose: a response that arrives for a request the page has already moved on from must
* be dropped, not rendered. Both hooks below do that with a generation counter. */
export interface Loadable<T> {
data: T | undefined;
error: string;
/** loading is the *first* load only. A refresh keeps the last data on screen, because a
* page that blanks itself every thirty seconds is unreadable. */
loading: boolean;
refreshing: boolean;
reload: () => Promise<void>;
/** set replaces the data locally, for an optimistic update after a mutation. */
set: (next: T) => void;
}
export interface QueryOptions {
/** pollMs re-fetches on an interval. It stops entirely while the tab is hidden: a
* console left open on a second monitor is otherwise the gateway's most frequent
* caller by a wide margin, and a background tab nobody is reading has no status worth
* fetching. */
pollMs?: number;
/** enabled false holds the request — a page whose URL parameter has not resolved yet. */
enabled?: boolean;
}
export function useQuery<T>(path: string, options: QueryOptions = {}): Loadable<T> {
const { pollMs, enabled = true } = options;
const [data, setData] = useState<T>();
const [error, setError] = useState('');
const [loading, setLoading] = useState(enabled);
const [refreshing, setRefreshing] = useState(false);
// The generation counter is the whole correctness argument: a filter change fires a new
// request while the old one is still in flight, and without this the slower of the two
// wins whichever order they were asked in.
const generation = useRef(0);
const loaded = useRef(false);
const run = useCallback(async () => {
if (!enabled) return;
const mine = ++generation.current;
if (loaded.current) setRefreshing(true);
try {
const next = await api.get<T>(path);
if (mine !== generation.current) return;
setData(next);
setError('');
loaded.current = true;
} catch (err) {
if (mine !== generation.current) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (mine === generation.current) {
setLoading(false);
setRefreshing(false);
}
}
}, [path, enabled]);
useEffect(() => {
// A changed path is a different question, so the previous answer is no longer this
// component's data — but it is kept on screen until the new one lands, which is what
// makes changing a filter feel like a table updating rather than a page reloading.
loaded.current = false;
setLoading(true);
void run();
return () => {
generation.current += 1;
};
}, [run]);
useEffect(() => {
if (!pollMs || !enabled) return;
let timer: number | undefined;
const start = () => {
window.clearInterval(timer);
timer = document.hidden ? undefined : window.setInterval(() => void run(), pollMs);
};
const onVisibility = () => {
start();
if (!document.hidden) void run();
};
start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [pollMs, enabled, run]);
return { data, error, loading, refreshing, reload: run, set: setData };
}
/** useAction runs a mutation and reports whether it is in flight, so a button can disable
* itself. Every mutation in the console goes through it, which is what makes "pressed
* twice" impossible without each page having to remember to guard against it. */
export function useAction(): {
busy: string | null;
run: (key: string, fn: () => Promise<unknown>) => Promise<boolean>;
} {
const [busy, setBusy] = useState<string | null>(null);
const alive = useRef(true);
useEffect(() => () => {
alive.current = false;
}, []);
const run = useCallback(async (key: string, fn: () => Promise<unknown>) => {
setBusy(key);
try {
await fn();
return true;
} finally {
if (alive.current) setBusy(null);
}
}, []);
return { busy, run };
}
/** useDebounced delays a value, for a search field that filters as it is typed. */
export function useDebounced<T>(value: T, delayMs = 250): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setSettled(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return settled;
}
/** useSorted is the shared table sort: stable, and it never re-sorts on a refresh, so a
* row the operator is reading does not move under the pointer when the poll lands. */
export function useSorted<T>(
rows: readonly T[],
key: keyof T | null,
direction: 'asc' | 'desc',
): T[] {
return useMemo(() => {
const copy = [...rows];
if (!key) return copy;
const sign = direction === 'asc' ? 1 : -1;
return copy.sort((a, b) => {
const left = a[key];
const right = b[key];
if (typeof left === 'number' && typeof right === 'number') return (left - right) * sign;
return String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true }) * sign;
});
}, [rows, key, direction]);
}
+262
View File
@@ -0,0 +1,262 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { api } from '../api/client';
import type { Tone } from './format';
import type { IconName } from '../components/Icon';
/* The administrative feed, shared by the bell in the top bar and the activity page.
*
* One provider rather than a hook per consumer, because there must be exactly one live
* connection: the stream is a held-open HTTP request, and a bell and a page each opening
* their own would double it — and would let the badge and the list disagree about what
* has arrived, which is the one thing a notification count must never do. */
export interface AdminEvent {
id: number;
occurredAt: string;
type: string;
severity: 'info' | 'warning' | 'error';
title: string;
summary: string;
actor?: string;
target?: string;
link?: string;
metadata?: Record<string, unknown>;
readAt?: string;
}
export interface EventTypeCount {
type: string;
count: number;
}
interface NotificationState {
events: AdminEvent[];
unread: number;
types: EventTypeCount[];
/** connected is whether the live stream is attached. It is shown, because "nothing has
* happened" and "we stopped being told" look identical otherwise. */
connected: boolean;
error: string;
markRead: (ids: number[]) => Promise<void>;
markAllRead: () => Promise<void>;
reload: () => Promise<void>;
}
const NotificationContext = createContext<NotificationState | null>(null);
/* How many events the bell holds. The activity page fetches its own window with filters;
this is the dropdown's list and the source of the badge. */
const FEED_LIMIT = 50;
/* If the stream cannot be established, fall back to polling. An SSE connection can be
defeated by an intermediary the console knows nothing about, and a bell that silently
stops working is worse than one that is a little late. */
const FALLBACK_POLL_MS = 30_000;
interface FeedResponse {
events: AdminEvent[];
total: number;
unread: number;
types: EventTypeCount[];
subscribers: number;
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const [events, setEvents] = useState<AdminEvent[]>([]);
const [unread, setUnread] = useState(0);
const [types, setTypes] = useState<EventTypeCount[]>([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState('');
const latestId = useRef(0);
const reload = useCallback(async () => {
try {
const feed = await api.get<FeedResponse>(`/admin/api/notifications?limit=${FEED_LIMIT}`);
setEvents(feed.events);
setUnread(feed.unread);
setTypes(feed.types);
latestId.current = Math.max(latestId.current, feed.events[0]?.id ?? 0);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}, []);
/* Merge is where the two sources meet. An event can arrive twice — once in the stream's
opening replay and once live — so it is keyed by id rather than appended, and the list
is re-sorted rather than assumed ordered: the replay is oldest-first and the live feed
is not. */
const merge = useCallback((incoming: AdminEvent) => {
latestId.current = Math.max(latestId.current, incoming.id);
setEvents((current) => {
if (current.some((event) => event.id === incoming.id)) return current;
return [incoming, ...current].sort((a, b) => b.id - a.id).slice(0, FEED_LIMIT);
});
if (!incoming.readAt) setUnread((count) => count + 1);
// A type that has never been seen before should appear in the filter immediately
// rather than after the next full fetch.
setTypes((current) =>
current.some((entry) => entry.type === incoming.type)
? current.map((entry) =>
entry.type === incoming.type ? { ...entry, count: entry.count + 1 } : entry,
)
: [...current, { type: incoming.type, count: 1 }],
);
}, []);
useEffect(() => {
void reload();
}, [reload]);
useEffect(() => {
let source: EventSource | null = null;
let fallback: number | undefined;
let closed = false;
const openStream = () => {
if (closed) return;
// `after` is what makes a reconnection lossless: the gateway replays everything
// published while the connection was down, so an event dropped by a slow reader or
// by a sleeping laptop is caught up rather than lost.
source = new EventSource(`/admin/api/notifications/stream?after=${latestId.current}`);
source.addEventListener('open', () => {
setConnected(true);
window.clearInterval(fallback);
fallback = undefined;
});
source.addEventListener('admin', (event) => {
try {
merge(JSON.parse((event as MessageEvent<string>).data) as AdminEvent);
} catch {
// A malformed frame is not worth breaking the feed over.
}
});
source.addEventListener('error', () => {
setConnected(false);
// EventSource reconnects by itself, but only while the endpoint is answering at
// all. The poll covers the case where it never will.
if (fallback === undefined) {
fallback = window.setInterval(() => void reload(), FALLBACK_POLL_MS);
}
});
};
openStream();
return () => {
closed = true;
source?.close();
window.clearInterval(fallback);
};
}, [merge, reload]);
const markRead = useCallback(async (ids: number[]) => {
const unreadIds = ids.filter((id) => id > 0);
if (unreadIds.length === 0) return;
// Optimistic: the badge is the thing an operator watches, and a count that lags a
// click by a round trip reads as the click not having worked.
setEvents((current) =>
current.map((event) =>
unreadIds.includes(event.id) && !event.readAt
? { ...event, readAt: new Date().toISOString() }
: event,
),
);
try {
const result = await api.post<{ unread: number }>('/admin/api/notifications/read', {
ids: unreadIds,
});
setUnread(result.unread);
} catch {
// The optimistic change stands until the next reload corrects it; a failed read
// receipt is not worth a banner over somebody else's page.
void reload();
}
}, [reload]);
const markAllRead = useCallback(async () => {
setUnread(0);
setEvents((current) =>
current.map((event) => (event.readAt ? event : { ...event, readAt: new Date().toISOString() })),
);
try {
const result = await api.post<{ unread: number }>('/admin/api/notifications/read', { all: true });
setUnread(result.unread);
} catch {
void reload();
}
}, [reload]);
const value = useMemo<NotificationState>(
() => ({ events, unread, types, connected, error, markRead, markAllRead, reload }),
[events, unread, types, connected, error, markRead, markAllRead, reload],
);
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
}
export function useNotifications(): NotificationState {
const value = useContext(NotificationContext);
if (!value) throw new Error('useNotifications used outside NotificationProvider');
return value;
}
/* ---------- presentation ---------- */
/** eventTone is the colour an event wears.
*
* It is derived from the *severity* the publisher chose rather than from the type,
* because the type list is open — a service added tomorrow publishes a type this console
* has never heard of, and it must still be coloured correctly. */
export function eventTone(event: Pick<AdminEvent, 'severity'>): Tone {
if (event.severity === 'error') return 'bad';
if (event.severity === 'warning') return 'warn';
return 'info';
}
/** eventIcon is the mark beside an event. Unknown types get a bell, which is honest: it
* says "something happened" without pretending to categorise it. */
export function eventIcon(type: string): IconName {
if (type.startsWith('auth.')) return 'key';
if (type.startsWith('device.')) return 'tv';
if (type.startsWith('admin.')) return 'shield';
if (type.startsWith('task.')) return 'clock';
if (type.startsWith('integration.')) return 'plug';
if (type.startsWith('library.')) return 'library';
if (type.startsWith('emby.')) return 'globe';
if (type.startsWith('server.')) return 'power';
return 'bell';
}
/** eventTypeLabel turns a routing key into words. An unrecognised type falls back to the
* key itself with its punctuation softened, so it is readable rather than absent — the
* same stance the client takes towards a row kind it does not know. */
export function eventTypeLabel(type: string): string {
const known: Record<string, string> = {
'auth.login': 'Signed in',
'auth.login_failed': 'Sign-in refused',
'auth.logout': 'Signed out',
'device.registered': 'New device',
'device.removed': 'Device removed',
'device.renamed': 'Device renamed',
'admin.sign_in': 'Admin sign-in',
'server.started': 'Server started',
'server.maintenance': 'Maintenance',
'task.completed': 'Task finished',
'task.failed': 'Task failed',
'integration.failed': 'Integration failed',
'integration.test': 'Integration test',
'library.sync': 'Library sync',
'emby.unreachable': 'Emby unreachable',
'emby.recovered': 'Emby recovered',
};
return known[type] ?? type.replace(/[._]/g, ' ');
}
+87
View File
@@ -0,0 +1,87 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { Icon } from '../components/Icon';
import type { Tone } from './format';
/* Toasts say what a mutation did.
*
* They are separate from the error banner on purpose: the banner is for the console
* failing to reach the gateway at all, which is a state the page is in, and a toast is for
* something that just happened and is over. Mixing them means either a transient success
* occupies a permanent slot, or a persistent failure scrolls away. */
interface Toast {
id: number;
message: string;
tone: Tone;
}
interface ToastState {
show: (message: string, tone?: Tone) => void;
/** wrap runs a mutation, reporting either outcome as a toast. Every action button in
* the console goes through it, which is why no page has to remember to report its own
* failures — and why a failed action can never be silent. */
wrap: <T>(fn: () => Promise<T>, success?: string) => Promise<T | undefined>;
}
const ToastContext = createContext<ToastState | null>(null);
const VISIBLE_MS = 5000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const next = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const show = useCallback(
(message: string, tone: Tone = 'ok') => {
const id = next.current++;
setToasts((current) => [...current, { id, message, tone }]);
window.setTimeout(() => dismiss(id), VISIBLE_MS);
},
[dismiss],
);
const wrap = useCallback(
async <T,>(fn: () => Promise<T>, success?: string): Promise<T | undefined> => {
try {
const result = await fn();
if (success) show(success, 'ok');
return result;
} catch (err) {
show(err instanceof Error ? err.message : String(err), 'bad');
return undefined;
}
},
[show],
);
const value = useMemo<ToastState>(() => ({ show, wrap }), [show, wrap]);
return (
<ToastContext.Provider value={value}>
{children}
{/* Polite rather than assertive: a confirmation is not an interruption, and a screen
reader announcing every save over whatever is being read is worse than silence. */}
<div className="toasts" role="status" aria-live="polite">
{toasts.map((toast) => (
<div className="toast" key={toast.id} data-tone={toast.tone}>
<Icon name={toast.tone === 'bad' ? 'alert' : 'check'} />
<span>{toast.message}</span>
<button type="button" onClick={() => dismiss(toast.id)} aria-label="Dismiss">
<Icon name="close" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastState {
const value = useContext(ToastContext);
if (!value) throw new Error('useToast used outside ToastProvider');
return value;
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/inter';
import { App } from './App';
import './styles.css';
const root = document.getElementById('root');
if (!root) throw new Error('the console has no root element to render into');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+305
View File
@@ -0,0 +1,305 @@
import type { IconName } from './components/Icon';
/* The console's table of contents, and the only place a page is declared.
*
* The rail, the page search and the router all read it, so a page cannot be in the menu
* and 404, or be reachable and unnamed. It used to have to agree with a matching list in
* Go as well — the gateway rendered the rail and decided which /admin URLs were legal —
* which is a duplication the single-page console removes: the gateway now serves the
* console for any /admin path and the routing is entirely here.
*
* `hidden` marks a destination that is reachable and titled but not in the rail: a page
* about one person or one television belongs to the thing it is about, not to a menu. */
export interface NavItem {
id: string;
path: string;
label: string;
title: string;
intro: string;
icon?: IconName;
hidden?: boolean;
/** badge names a live count the rail should show beside this item — today only the
* unread activity count. */
badge?: 'notifications';
}
export interface NavGroup {
id: string;
label?: string;
items: NavItem[];
}
export const nav: NavGroup[] = [
{
id: 'overview',
items: [
{
id: 'overview',
path: '/admin',
label: 'Overview',
title: 'Overview',
intro: 'What the gateway is doing right now.',
icon: 'overview',
},
{
id: 'activity',
path: '/admin/activity',
label: 'Activity',
title: 'Activity',
intro: 'Every administrative event, newest first.',
icon: 'bell',
badge: 'notifications',
},
],
},
{
id: 'people',
label: 'People',
items: [
{
id: 'accounts',
path: '/admin/accounts',
label: 'Users',
title: 'Memby users',
intro: 'Who uses Memby, and the devices they are signed in on.',
icon: 'people',
},
{
id: 'account',
path: '/admin/accounts/:userId',
label: 'User',
title: 'User',
intro: 'Devices, recommendation setup and synced settings for one person.',
hidden: true,
},
{
id: 'settings-history',
path: '/admin/accounts/:userId/settings',
label: 'Settings history',
title: 'Settings history',
intro: "Every change to one person's synced settings, and which devices took it.",
hidden: true,
},
{
id: 'clients',
path: '/admin/clients',
label: 'Devices',
title: 'Devices',
intro: 'Which sets have reported in, what they are running and what their build understands.',
icon: 'tv',
},
{
id: 'logins',
path: '/admin/logins',
label: 'Sign-ins',
title: 'Sign-in history',
intro: 'Every connection attempt: who, which television, from where, and whether it got in.',
icon: 'key',
},
{
id: 'device',
path: '/admin/devices/:deviceId',
label: 'Device',
title: 'Device',
intro: 'One television: how often it connects, at what times, and from which addresses.',
hidden: true,
},
],
},
{
id: 'content',
label: 'Content',
items: [
{
id: 'library',
path: '/admin/library',
label: 'Library',
title: 'Library',
intro: 'Import and inspect the catalogue Memby ranks.',
icon: 'library',
},
{
id: 'ratings',
path: '/admin/ratings',
label: 'Movie ratings',
title: 'Movie ratings',
intro: 'Optional MDBList scores on films and shows.',
icon: 'star',
},
{
id: 'requests',
path: '/admin/requests',
label: 'Media requests',
title: 'Media requests',
intro: 'Who can ask for something the library does not have.',
icon: 'inbox',
},
],
},
{
id: 'personalisation',
label: 'Personalisation',
items: [
{
id: 'recommendations',
path: '/admin/recommendations',
label: 'For You',
title: 'For You',
intro: 'The prepared pools personalised rows are drawn from.',
icon: 'sparkle',
},
{
id: 'inspector',
path: '/admin/inspector',
label: 'Score inspector',
title: 'Score inspector',
intro: 'Re-run the ranker for one person and read every component.',
icon: 'search',
},
],
},
{
id: 'experience',
label: 'Experience',
items: [
{
id: 'hero',
path: '/admin/hero',
label: 'Home hero',
title: 'Home hero',
intro:
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
icon: 'star',
},
{
id: 'features',
path: '/admin/features',
label: 'Features',
title: 'Features',
intro: 'Roll out, stop and recover optional behaviour with no app release.',
icon: 'sliders',
},
{
id: 'playback',
path: '/admin/playback',
label: 'Playback',
title: 'Playback',
intro: 'Presentation policy sent with every playback launch.',
icon: 'play',
},
{
id: 'subtitles',
path: '/admin/subtitles',
label: 'Subtitles',
title: 'Subtitles',
intro: 'Which providers a viewer may fetch a missing subtitle from.',
icon: 'captions',
},
{
id: 'updates',
path: '/admin/updates',
label: 'App updates',
title: 'App updates',
intro: 'Publish an optional or a required client update.',
icon: 'upload',
},
],
},
{
id: 'operations',
label: 'Operations',
items: [
{
id: 'tasks',
path: '/admin/tasks',
label: 'Scheduled tasks',
title: 'Scheduled tasks',
intro: 'What the gateway does in the background, when it last ran and whether it worked.',
icon: 'clock',
},
{
id: 'integrations',
path: '/admin/integrations',
label: 'Integrations',
title: 'Integrations',
intro: 'Send administrative events to Discord and, in time, elsewhere.',
icon: 'plug',
},
{
id: 'maintenance',
path: '/admin/maintenance',
label: 'Maintenance',
title: 'Maintenance',
intro: 'Take Memby offline for every television.',
icon: 'wrench',
},
{
id: 'imports',
path: '/admin/imports',
label: 'Imports',
title: 'Imports',
intro: 'Catalogue synchronisation history.',
icon: 'database',
},
{
id: 'logs',
path: '/admin/logs',
label: 'Server logs',
title: 'Server logs',
intro: 'Structured gateway events as they happen.',
icon: 'list',
},
],
},
{
id: 'reporting',
label: 'Reporting',
items: [
{
id: 'views',
path: '/admin/views',
label: 'Views',
title: 'App views',
intro: 'Home-screen visits, viewers and the times Memby is used.',
icon: 'overview',
},
{
id: 'journeys',
path: '/admin/journeys',
label: 'Journeys',
title: 'User journeys',
intro: 'How viewers move through Memby, use features and complete flows.',
icon: 'journey',
},
{
id: 'engagement',
path: '/admin/engagement',
label: 'Row engagement',
title: 'Row engagement',
intro: 'Impressions, focus, dwell and selections per launcher row.',
icon: 'chart',
},
{
id: 'searches',
path: '/admin/searches',
label: 'Searches',
title: 'Searches',
intro: 'What the household has been looking for, and what it searched just now.',
icon: 'search',
},
],
},
];
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
/** searchableNavItems is what the omni search offers: every page that has a URL of its
* own, which is every page that is not addressed by an id in the path. */
export const searchableNavItems = nav.flatMap((group) =>
group.items.filter((item) => !item.hidden).map((item) => ({ ...item, group: group.label ?? '' })),
);
export function navItem(id: string): NavItem | undefined {
return allNavItems.find((item) => item.id === id);
}
+774
View File
@@ -0,0 +1,774 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { initials, num, presence, recent, when } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Toggle,
} from '../components/ui';
import type { DeviceVersion } from '../api/types';
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
can be linked, bookmarked and returned to after a sign-in. */
interface PreferenceOption {
value: string;
label: string;
}
interface PreferenceDefinition {
key: string;
name: string;
description: string;
area: string;
kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list';
options?: PreferenceOption[];
numbers?: number[];
unit?: string;
}
interface ThemeDefinition {
id: string;
name: string;
description: string;
palette?: Record<string, string>;
}
interface AccountDevice {
id: string;
name: string;
version: string;
signedInAt: string;
lastSeen: string;
versions: DeviceVersion[] | null;
}
interface RecommendationState {
prompted?: boolean;
completed?: boolean;
ratings?: { title: string; rating: number }[];
genres?: string[];
studios?: string[];
actors?: string[];
actresses?: string[];
directors?: string[];
contentTypes?: string[];
}
interface AccountDetail {
id: string;
username: string;
lastSeen: string;
devices: AccountDevice[] | null;
themes: string[] | null;
recommendations?: RecommendationState;
settings?: {
saved?: boolean;
revision?: number;
source?: string;
updatedAt?: string;
preferences?: Record<string, unknown>;
};
}
interface AccountsPayload {
accounts: AccountDetail[] | null;
catalogue: PreferenceDefinition[] | null;
themes: ThemeDefinition[] | null;
}
/* The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
conversion lives here rather than on the wire because the television is the end that has
to parse thousands of these and the console is the end that parses eight. */
function cssColour(value: string | undefined): string {
const hex = String(value ?? '').replace('#', '');
if (hex.length !== 8) return `#${hex}`;
return `#${hex.slice(2)}${hex.slice(0, 2)}`;
}
type Pending =
| { kind: 'remove-device'; deviceId: string; name: string }
| { kind: 'remove-account' }
| { kind: 'reset-recommendations' }
| { kind: 'cancel-prompt' }
| { kind: 'reset-preferences' }
| { kind: 'no-themes' };
export function AccountPage() {
const { userId = '' } = useParams();
const navigate = useNavigate();
const { wrap } = useToast();
const { busy, run } = useAction();
const base = `/admin/api/accounts/${encodeURIComponent(userId)}`;
const { data, error, loading, reload } = useQuery<AccountsPayload>('/admin/api/accounts', {
pollMs: 30_000,
});
/* True while the operator has edited a form without saving. The page polls, and a redraw
would take a half-finished change away mid-sentence — so a dirty form keeps what it has
until it is saved, discarded or reloaded. The two forms track this separately, so
saving one does not discard an unsaved edit to the other. */
const [prefs, setPrefs] = useState<Record<string, unknown> | null>(null);
const [themes, setThemes] = useState<string[] | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
const account = (data?.accounts ?? []).find((entry) => entry.id === userId);
const catalogue = data?.catalogue ?? [];
const themeCatalogue = data?.themes ?? [];
useEffect(() => {
if (prefs === null && account) setPrefs({ ...(account.settings?.preferences ?? {}) });
}, [account, prefs]);
useEffect(() => {
if (themes !== null || !account) return;
// An empty list from the server means unrestricted, so it draws as every box ticked.
// Storing "all" and "never configured" identically is deliberate — they are the same
// decision — and this is the one place an operator would notice if it were not.
const allowed = account.themes ?? [];
setThemes(allowed.length === 0 ? themeCatalogue.map((theme) => theme.id) : allowed);
}, [account, themes, themeCatalogue]);
const areas = useMemo(() => {
const grouped: { name: string; definitions: PreferenceDefinition[] }[] = [];
for (const definition of catalogue) {
let area = grouped.find((entry) => entry.name === definition.area);
if (!area) grouped.push((area = { name: definition.area, definitions: [] }));
area.definitions.push(definition);
}
return grouped;
}, [catalogue]);
const act = (key: string, fn: () => Promise<unknown>, message: string, after?: () => void) =>
run(key, async () => {
const ok = await wrap(fn, message);
setPending(null);
if (ok !== undefined) after?.();
await reload();
});
if (loading) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Loading />
</>
);
}
if (!account) {
return (
<>
<PageHead title="User" crumbs={<Link to="/admin/accounts"> All users</Link>} />
<Banner message={error} />
<Card>
<Empty>This user is no longer signed in to Memby.</Empty>
</Card>
</>
);
}
const devices = account.devices ?? [];
const active = devices.filter((device) => recent(device.lastSeen)).length;
const settings = account.settings ?? {};
const prompt = account.recommendations ?? {};
return (
<>
<PageHead
title={account.username || 'Unnamed user'}
intro={`Memby user · ${num(devices.length)} device${devices.length === 1 ? '' : 's'} · last seen ${when(account.lastSeen)}`}
crumbs={<Link to="/admin/accounts"> All users</Link>}
actions={
<>
<span className="avatar">{initials(account.username)}</span>
{active ? <Tag tone="ok">{active} active now</Tag> : <Tag>idle</Tag>}
<Chip>{account.id}</Chip>
</>
}
/>
<Banner message={error} />
<Grid cols="wide">
<Card
title="Devices"
intro="Every build a set has been seen running is listed under it. Signing one out revokes its Memby session, drops that history and removes it from Emby's own device list. Its Emby account is not changed."
icon="tv"
tone="info"
>
{devices.length === 0 ? (
<Empty>No devices are signed in to this user.</Empty>
) : (
<div className="list">
{devices.map((device) => {
const seen = presence(device.lastSeen);
return (
<div className="list-item" key={device.id || device.name}>
<div className="list-body">
<b>
<span className="dot-state" data-tone={seen.tone} title={seen.label} />{' '}
<Link
className="table-row-link"
to={`/admin/devices/${encodeURIComponent(device.id)}`}
>
{device.name || 'Memby TV'}
</Link>
</b>
<p>
{device.version ? `Memby ${device.version}` : 'Legacy Memby client'} · {seen.label} ·
last seen {when(device.lastSeen)} · signed in {when(device.signedInAt)}
</p>
{/* Every build this set has been seen running. One television that has
been through four releases is a different thing from four
televisions, and the history is what says which of those this is. */}
{(device.versions ?? []).length > 0 ? (
<div className="chips">
{(device.versions ?? []).map((entry) => (
<Chip key={entry.version} tone={entry.version === device.version ? 'ok' : undefined}>
{entry.version}
{entry.version === device.version ? ' · now' : ''}
</Chip>
))}
</div>
) : null}
</div>
<div className="list-actions">
<Button
size="sm"
disabled={!device.id}
onClick={() => setRenaming({ id: device.id, name: device.name })}
>
Rename
</Button>
<Button
size="sm"
variant="danger"
disabled={!device.id}
onClick={() =>
setPending({ kind: 'remove-device', deviceId: device.id, name: device.name })
}
>
Sign out
</Button>
</div>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Recommendation setup"
intro="The prompt appears the next time this person opens Memby on any of their televisions."
icon="sparkle"
tone="note"
footer={
prompt.completed ? (
<Button
busy={busy === 'reset-rec'}
onClick={() => setPending({ kind: 'reset-recommendations' })}
>
Clear stored choices
</Button>
) : prompt.prompted ? (
<Button onClick={() => setPending({ kind: 'cancel-prompt' })}>Cancel prompt</Button>
) : (
<Button
variant="primary"
busy={busy === 'prompt'}
onClick={() =>
void act(
'prompt',
() => api.put(`${base}/recommendations/prompt`),
'Setup prompt queued.',
)
}
>
Send setup prompt
</Button>
)
}
>
<div className="row tight">
{prompt.completed ? (
<Tag tone="ok">completed</Tag>
) : prompt.prompted ? (
<Tag tone="warn">prompt queued</Tag>
) : (
<Tag>not invited</Tag>
)}
</div>
<RecommendationChips prompt={prompt} />
</Card>
</Grid>
<Card
title="Settings"
intro="These live on the server and follow the person, so a change here reaches every television they use — usually within a few seconds, and on the next launch for a set that is switched off."
icon="sliders"
tone="ok"
actions={
settings.saved ? (
<Tag tone={settings.source === 'admin' ? 'warn' : 'ok'}>
r{num(settings.revision)} · {settings.source || 'device'} · {when(settings.updatedAt)}
</Tag>
) : (
<Tag>defaults · never synced</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'push'}
onClick={() =>
void act(
'push',
() => api.put(`${base}/preferences`, { preferences: prefs ?? {} }),
'Pushed to their televisions.',
// Cleared so the form is redrawn from what the server actually stored
// rather than from what was submitted.
() => setPrefs(null),
)
}
>
Push to their televisions
</Button>
<Button
onClick={() => {
setPrefs(null);
void reload();
}}
>
Discard changes
</Button>
<Button onClick={() => setPending({ kind: 'reset-preferences' })}>Restore defaults</Button>
<Link className="crumb" to={`/admin/accounts/${encodeURIComponent(userId)}/settings`}>
History and rollback
</Link>
</>
}
>
{areas.map((area) => (
<div className="group" key={area.name}>
<p className="group-label">{area.name}</p>
{area.definitions.map((definition) => (
<SettingControl
key={definition.key}
definition={definition}
value={prefs?.[definition.key]}
onChange={(next) => setPrefs((current) => ({ ...(current ?? {}), [definition.key]: next }))}
/>
))}
</div>
))}
</Card>
<Card
title="Colour schemes"
intro="Which palettes this person may choose between in Settings → Appearance. Tick everything to leave them unrestricted. Their current choice is an ordinary setting above; withdrawing it here puts them back on Midnight."
icon="sparkle"
tone="note"
actions={
(account.themes ?? []).length === 0 ? (
<Tag>all schemes</Tag>
) : (
<Tag tone="note">
{num((account.themes ?? []).length)} of {num(themeCatalogue.length)}
</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'themes'}
onClick={() =>
(themes ?? []).length === 0
? setPending({ kind: 'no-themes' })
: void act(
'themes',
() => api.put(`${base}/themes`, { themes: themes ?? [] }),
'Colour schemes saved.',
// Cleared so the boxes are redrawn from what was stored, which is how
// "every box ticked" comes back as unrestricted rather than as a list.
() => setThemes(null),
)
}
>
Save colour schemes
</Button>
<Button onClick={() => setThemes(themeCatalogue.map((theme) => theme.id))}>Allow all</Button>
<span className="hint">
Seasonal themes are not listed. They apply to every television in the house for their
dates and nobody can decline one the only switch is <em>Seasonal themes</em> on the
features page.
</span>
</>
}
>
<div className="checks columns">
{themeCatalogue.map((theme) => (
<label className="check" key={theme.id}>
<input
type="checkbox"
checked={(themes ?? []).includes(theme.id)}
onChange={(event) =>
setThemes((current) =>
event.target.checked
? [...(current ?? []), theme.id]
: (current ?? []).filter((entry) => entry !== theme.id),
)
}
/>
<span className="switch" />
<span
className="swatch"
style={
{
'--swatch-surface': cssColour(theme.palette?.surface),
'--swatch-accent': cssColour(theme.palette?.accent),
'--swatch-hairline': cssColour(theme.palette?.hairline),
} as React.CSSProperties
}
>
<i />
</span>
<span className="check-body">
<b>{theme.name}</b>
<p>{theme.description}</p>
</span>
</label>
))}
</div>
</Card>
<Card
title="Remove Memby access"
intro="Signs every one of this person's Memby devices out. Their Emby account, viewing history and library permissions are untouched."
icon="alert"
tone="bad"
>
<Button variant="danger" onClick={() => setPending({ kind: 'remove-account' })}>
Remove Memby access
</Button>
</Card>
{renaming ? (
<RenameDialog
initial={renaming.name}
busy={busy === 'rename'}
onCancel={() => setRenaming(null)}
onConfirm={(name) =>
void act(
'rename',
() => api.put(`${base}/devices/${encodeURIComponent(renaming.id)}`, { deviceName: name }),
'Device renamed.',
() => setRenaming(null),
)
}
/>
) : null}
{pending ? (
<PendingDialog
pending={pending}
busy={busy}
username={account.username}
onCancel={() => setPending(null)}
onConfirm={() => {
switch (pending.kind) {
case 'remove-device':
return void act(
'remove-device',
() => api.del(`${base}/devices/${encodeURIComponent(pending.deviceId)}`),
'Device signed out.',
);
case 'remove-account':
return void act(
'remove-account',
() => api.del(`${base}/sessions`),
'Memby access removed.',
() => navigate('/admin/accounts'),
);
case 'reset-recommendations':
case 'cancel-prompt':
return void act(
'reset-rec',
() => api.del(`${base}/recommendations`),
'Recommendation choices cleared.',
);
case 'reset-preferences':
return void act(
'reset-prefs',
() => api.del(`${base}/preferences`),
'Defaults restored.',
() => setPrefs(null),
);
case 'no-themes':
return void act(
'themes',
() => api.put(`${base}/themes`, { themes: [] }),
'Colour schemes saved.',
() => setThemes(null),
);
}
}}
/>
) : null}
</>
);
}
function RecommendationChips({ prompt }: { prompt: RecommendationState }) {
const ratings = prompt.ratings ?? [];
const dimensions: [string, string[] | undefined][] = [
['Genres', prompt.genres],
['Studios', prompt.studios],
['Actors', prompt.actors],
['Actresses', prompt.actresses],
['Directors', prompt.directors],
['Types', prompt.contentTypes],
];
const chips: ReactNode[] = [
...ratings.map((rating) => (
<Chip key={`r:${rating.title}`} tone="warn">
{rating.title} · {num(rating.rating)}
</Chip>
)),
...dimensions.flatMap(([label, values]) =>
(values ?? []).map((value) => (
<Chip key={`${label}:${value}`}>
{label}: {value}
</Chip>
)),
),
];
if (chips.length === 0) return <Empty>No recommendation selections have been saved.</Empty>;
return <div className="chips">{chips}</div>;
}
function SettingControl({
definition,
value,
onChange,
}: {
definition: PreferenceDefinition;
value: unknown;
onChange: (next: unknown) => void;
}) {
if (definition.kind === 'toggle') {
return (
<Toggle
label={definition.name}
hint={definition.description}
checked={Boolean(value)}
onChange={onChange}
/>
);
}
if (definition.kind === 'choice' || definition.kind === 'number') {
// A number's unit comes from the catalogue. Assuming minutes was safe while the only
// number was a time budget, and wrong the moment a second one counted anything else.
const unit = definition.unit ?? '';
const options =
definition.kind === 'number'
? (definition.numbers ?? []).map((amount) => ({
value: String(amount),
label: amount === 0 ? 'No limit' : unit ? `${amount} ${unit}` : String(amount),
}))
: (definition.options ?? []);
return (
<Field label={definition.name} hint={definition.description}>
<select
value={String(value ?? '')}
onChange={(event) =>
onChange(definition.kind === 'number' ? Number(event.target.value) : event.target.value)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
);
}
if (definition.kind === 'multi') {
// Rendered in the viewer's own order, then whatever they have not selected. The order
// of these rows is the order the launcher draws them in, so preserving it matters as
// much as which are ticked.
const selected = Array.isArray(value) ? (value as string[]) : [];
const ordered = [
...selected,
...(definition.options ?? []).map((option) => option.value).filter((option) => !selected.includes(option)),
];
return (
<div className="field">
<span>{definition.name}</span>
<small>{definition.description}</small>
<div className="checks">
{ordered.map((option) => {
const match = (definition.options ?? []).find((entry) => entry.value === option);
if (!match) return null;
return (
<Toggle
key={option}
label={match.label}
checked={selected.includes(option)}
onChange={(on) =>
onChange(on ? [...selected, option] : selected.filter((entry) => entry !== option))
}
/>
);
})}
</div>
</div>
);
}
// A free-form list of server row ids: one per line, which is also how the television
// stores them. There is no vocabulary to offer, because these ids ship from the gateway
// without an app release.
const entries = Array.isArray(value) ? (value as string[]) : [];
return (
<Field label={definition.name} hint={definition.description}>
<textarea
spellCheck={false}
placeholder="One row id per line"
value={entries.join('\n')}
onChange={(event) =>
onChange(
event.target.value
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</Field>
);
}
function RenameDialog({
initial,
busy,
onConfirm,
onCancel,
}: {
initial: string;
busy: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial || 'Memby TV');
return (
<div className="scrim" onPointerDown={(event) => event.target === event.currentTarget && onCancel()}>
<div className="dialog" role="dialog" aria-modal="true">
<h2>Name this device</h2>
<p>The name a viewer sees in Settings Devices, and what the console calls it.</p>
<Field label="Device name">
<input
type="text"
value={name}
autoFocus
maxLength={80}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<div className="dialog-actions">
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" busy={busy} disabled={!name.trim()} onClick={() => onConfirm(name.trim())}>
Rename
</Button>
</div>
</div>
</div>
);
}
function PendingDialog({
pending,
busy,
username,
onConfirm,
onCancel,
}: {
pending: Pending;
busy: string | null;
username: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const copy: Record<Pending['kind'], { title: string; body: string; label: string; destructive: boolean }> = {
'remove-device': {
title: 'Sign this device out of Memby?',
body: "Its Emby account will not be changed. The set can sign in again at any time.",
label: 'Sign out',
destructive: true,
},
'remove-account': {
title: `Remove Memby access for ${username || 'this user'}?`,
body: 'Every Memby device will be signed out. Their Emby account, viewing history and library permissions are untouched.',
label: 'Remove access',
destructive: true,
},
'reset-recommendations': {
title: "Clear this person's stored recommendation choices?",
body: 'Viewing history remains intact; only the explicit setup answers are removed.',
label: 'Clear',
destructive: true,
},
'cancel-prompt': {
title: "Cancel this person's queued recommendation prompt?",
body: 'They will not be invited to set up recommendations on their next launch.',
label: 'Cancel prompt',
destructive: false,
},
'reset-preferences': {
title: 'Restore the Memby defaults for this person?',
body: 'Their televisions will pick the change up the next time they check in.',
label: 'Restore defaults',
destructive: true,
},
'no-themes': {
title: 'Allow this person no colour schemes?',
body: 'They will be left on Midnight with nothing to choose between.',
label: 'Save anyway',
destructive: true,
},
};
const chosen = copy[pending.kind];
return (
<Confirm
title={chosen.title}
body={chosen.body}
confirmLabel={chosen.label}
destructive={chosen.destructive}
busy={Boolean(busy)}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { initials, num, presence, recent, when } from '../lib/format';
import { Banner, Empty, Loading, Note, PageHead, Tag, Tiles } from '../components/ui';
import type { KnownClient } from '../api/types';
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
interface Account {
id: string;
username: string;
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
}
interface AccountsResponse {
accounts: Account[] | null;
}
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
});
const accounts = data?.accounts ?? [];
const devices = accounts.flatMap((account) => account.devices ?? []);
const completed = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter(
(account) => account.recommendations?.prompted && !account.recommendations?.completed,
).length;
return (
<>
<PageHead title="Memby users" intro="Who uses Memby, and the devices they are signed in on." />
<Banner message={error} />
<Note tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only after
signing in to the Memby app. Removing access signs their Memby devices out and does not delete
or change their Emby account.
</Note>
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'Memby users', value: num(accounts.length), icon: 'people', tone: 'note' },
{ label: 'signed-in devices', value: num(devices.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last quarter hour',
value: num(devices.filter((device) => recent(device.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'recommendation setups completed', value: num(completed), icon: 'check', tone: 'ok' },
{ label: 'setup prompts queued', value: num(queued), icon: 'sparkle', tone: 'note' },
]}
/>
<section className="card flush">
{accounts.length === 0 ? (
<Empty>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</Empty>
) : (
accounts.map((account) => {
const list = account.devices ?? [];
const active = list.filter((device) => recent(device.lastSeen)).length;
const state = account.recommendations?.completed
? { label: 'personalised', tone: 'ok' as const }
: account.recommendations?.prompted
? { label: 'prompt queued', tone: 'warn' as const }
: { label: 'not invited', tone: undefined };
const seen = presence(account.lastSeen);
return (
<Link className="list-row" key={account.id} to={`/admin/accounts/${encodeURIComponent(account.id)}`}>
<span className="list-main">
<span className="avatar">{initials(account.username)}</span>
<span>
<span className="list-title">
{account.username || 'Unnamed user'}
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
</span>
<span className="list-meta">
{num(list.length)} device{list.length === 1 ? '' : 's'}
{active ? ` · ${active} active now` : ''} · last seen {when(account.lastSeen)}
</span>
</span>
</span>
<span className="list-actions">
<Tag tone={state.tone}>{state.label}</Tag>
<span className="crumb">Manage</span>
</span>
</Link>
);
})
)}
</section>
</>
)}
</>
);
}
/** The account page reads the same list to find the person it is about. */
export type { Account, AccountsResponse };
+255
View File
@@ -0,0 +1,255 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { ago, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Field,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { Glyph } from '../components/Icon';
import {
eventIcon,
eventTone,
eventTypeLabel,
useNotifications,
type AdminEvent,
type EventTypeCount,
} from '../lib/notifications';
/* The activity feed in full: the bell's dropdown with filters and no twenty-row cap.
*
* It reads its own window from the server rather than the provider's cached list, because
* the provider holds only the most recent fifty — enough for a badge and a dropdown, not
* enough to answer "what happened on Tuesday". The badge and the "mark all read" button
* still come from the provider, so this page and the bell can never disagree about how
* much is unread. */
interface FeedResponse {
events: AdminEvent[];
total: number;
unread: number;
types: EventTypeCount[];
subscribers: number;
}
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
];
export function ActivityPage() {
const { unread, connected, markAllRead, reload: reloadBell } = useNotifications();
const [days, setDays] = useState(7);
const [type, setType] = useState('');
const [severity, setSeverity] = useState('');
const [unreadOnly, setUnreadOnly] = useState(false);
const [page, setPage] = useState(0);
const limit = 100;
const path = useMemo(
() =>
`/admin/api/notifications${query({
days,
type,
severity,
unread: unreadOnly,
limit,
offset: page * limit,
})}`,
[days, type, severity, unreadOnly, page],
);
const { data, error, loading, reload } = useQuery<FeedResponse>(path);
const markAll = async () => {
await markAllRead();
await reload();
};
return (
<>
<PageHead
title="Activity"
intro="Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from."
actions={
unread > 0 ? (
<Button onClick={() => void markAll()} icon="check">
Mark all read
</Button>
) : undefined
}
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'Events in window', value: num(data?.total ?? 0), icon: 'bell', tone: 'info' },
{ label: 'Unread', value: num(unread), icon: 'alert', tone: unread > 0 ? 'warn' : undefined },
{ label: 'Kinds seen', value: num(data?.types.length ?? 0), icon: 'list', tone: 'note' },
{
label: 'Live feed',
value: connected ? 'connected' : 'reconnecting',
small: true,
icon: 'pulse',
tone: connected ? 'ok' : 'warn',
},
]}
/>
<div className="filters">
<Field label="Window">
<Segments
value={days}
options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))}
onChange={(next) => {
setDays(next);
setPage(0);
}}
/>
</Field>
<Field label="Kind">
{/* Built from what has actually been published, so the filter can neither offer a
kind that matches nothing nor miss one a service added after this shipped. */}
<select
value={type}
onChange={(event) => {
setType(event.target.value);
setPage(0);
}}
>
<option value="">Everything</option>
{(data?.types ?? []).map((entry) => (
<option key={entry.type} value={entry.type}>
{eventTypeLabel(entry.type)} ({entry.count})
</option>
))}
</select>
</Field>
<Field label="Severity">
<select
value={severity}
onChange={(event) => {
setSeverity(event.target.value);
setPage(0);
}}
>
<option value="">Any</option>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</Field>
<Field label="Read state">
<select
value={unreadOnly ? 'unread' : ''}
onChange={(event) => {
setUnreadOnly(event.target.value === 'unread');
setPage(0);
}}
>
<option value="">All</option>
<option value="unread">Unread only</option>
</select>
</Field>
<div className="filter-actions">
<Button
variant="quiet"
size="sm"
icon="refresh"
onClick={() => {
void reload();
void reloadBell();
}}
>
Refresh
</Button>
</div>
</div>
{loading ? (
<Loading />
) : (
<Card
title="Events"
icon="bell"
tone="info"
footer={
(data?.total ?? 0) > limit ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= (data?.total ?? 0)}
onClick={() => setPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Kind</th>
<th>What happened</th>
<th>Who</th>
<th>What</th>
<th />
</tr>
</thead>
<tbody>
{(data?.events.length ?? 0) === 0 ? (
<EmptyRow columns={6}>Nothing has happened in this window.</EmptyRow>
) : (
data?.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted" title={when(event.occurredAt)}>
{ago(event.occurredAt)}
</td>
<td className="nowrap">
<span className="row tight">
<Glyph name={eventIcon(event.type)} tone={eventTone(event)} />
{eventTypeLabel(event.type)}
</span>
</td>
<td>
<b>{event.title}</b>
{event.summary ? <div className="muted">{event.summary}</div> : null}
</td>
<td className="muted nowrap">{event.actor || '—'}</td>
<td className="muted nowrap">{event.target || '—'}</td>
<td className="nowrap">
{!event.readAt ? <Tag tone="ok">new</Tag> : null}
{event.link ? (
<Link className="table-row-link" to={event.link}>
Open
</Link>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
)}
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { Link } from 'react-router-dom';
import { useGateway } from '../lib/gateway';
import { num, presence, recent, when } from '../lib/format';
import {
Banner,
Card,
Chip,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { KnownClient } from '../api/types';
/* Every build this set has been seen running, newest first, with the one it is on now in
accent. The current version is already its own column; this is the column that says
whether a row is one television with a history or one of several rows a single set left
behind, which is the question duplicates used to make unanswerable. */
function VersionHistory({ client }: { client: KnownClient }) {
const versions = client.versions ?? [];
if (versions.length === 0) return <span className="muted"></span>;
return (
<span className="versions">
{versions.map((entry) => (
<Chip key={entry.version} tone={entry.version === client.version ? 'ok' : undefined}>
{entry.version}
</Chip>
))}
</span>
);
}
export function ClientsPage() {
const { status, error, loading } = useGateway();
const clients = status?.clients ?? [];
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1'));
const versions = new Set(clients.map((client) => client.version).filter(Boolean));
return (
<>
<PageHead
title="Devices"
intro="Which sets have reported in, what they are running and what their build understands."
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'devices known', value: num(clients.length), icon: 'tv', tone: 'info' },
{
label: 'active in the last quarter hour',
value: num(clients.filter((client) => recent(client.lastSeen)).length),
icon: 'pulse',
tone: 'ok',
},
{ label: 'reporting their capabilities', value: num(capable.length), icon: 'sliders', tone: 'ok' },
{ label: 'app builds in service', value: num(versions.size), icon: 'download', tone: 'note' },
]}
/>
<Card
title="Devices"
intro="Every request carries what that build understands. A feature is only presented to a device that declares its contract, which is what lets an older set keep working while a new one gets the new behaviour. Status is whether the set is reporting that list at all; a build old enough to say nothing is served the fallback."
icon="tv"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Device</th>
<th>Person</th>
<th>App</th>
<th>Builds seen</th>
<th>Status</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{clients.length === 0 ? (
<EmptyRow columns={6}>No devices have signed in yet.</EmptyRow>
) : (
clients.map((client) => {
const declares = (client.capabilities ?? []).includes('server_features_v1');
const seen = presence(client.lastSeen);
return (
<tr key={`${client.deviceId}:${client.username}`}>
<td>
<span className="row tight">
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
{/* Through to the sign-in history for this set: "what is it
running" and "when does it actually connect" are the two
halves of the same question. */}
<Link
className="table-row-link"
to={`/admin/devices/${encodeURIComponent(client.deviceId)}`}
>
{client.deviceName || 'Memby TV'}
</Link>
</span>
</td>
<td className="muted">{client.username}</td>
<td className="mono">{client.version || 'legacy'}</td>
<td>
<VersionHistory client={client} />
</td>
<td>
<Tag tone={declares ? 'ok' : 'warn'}>{declares ? 'reported' : 'missing'}</Tag>
</td>
<td className="nowrap muted">{when(client.lastSeen)}</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import {
Banner,
Bars,
Card,
Chip,
Empty,
EmptyRow,
Grid,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { DeviceDetailResponse } from '../api/types';
/* One television.
*
* The page exists to answer a question that was previously unanswerable: "how many times
* did this set connect today, at what times, and from which addresses". Everything on it
* is in service of that sentence — the headline counts are over the device's whole
* history so they describe the television, and only the table and the chart move with the
* window control, so narrowing to today does not make the totals look like the set has
* only ever connected twice. */
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 0, label: 'All' },
];
export function DevicePage() {
const { deviceId = '' } = useParams();
const [days, setDays] = useState(7);
const path = useMemo(
() =>
`/admin/api/logins/devices/${encodeURIComponent(deviceId)}${query({
days: days || undefined,
limit: 200,
})}`,
[deviceId, days],
);
const { data, error, loading } = useQuery<DeviceDetailResponse>(path, { enabled: Boolean(deviceId) });
const summary = data?.summary;
const name = summary?.deviceName || deviceId;
return (
<>
<PageHead
title={name}
intro="One television's whole relationship with the gateway."
crumbs={
<>
<Link to="/admin/clients">Devices</Link>
<span>/</span>
<Link to="/admin/logins">Sign-ins</Link>
<span>/</span>
<span>{name}</span>
</>
}
actions={
<Segments value={days} options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))} onChange={setDays} />
}
/>
<Banner message={error} />
{loading ? (
<Loading />
) : !data ? null : (
<>
<Tiles
tiles={[
{ label: 'Sign-ins today', value: num(summary?.loginsToday ?? 0), icon: 'clock', tone: 'ok' },
{ label: 'Sign-ins in total', value: num(summary?.logins ?? 0), icon: 'key', tone: 'info' },
{
label: 'Refused',
value: num(summary?.failures ?? 0),
icon: 'shield',
tone: (summary?.failures ?? 0) > 0 ? 'warn' : undefined,
},
{ label: 'Addresses seen', value: num(summary?.distinctIps ?? 0), icon: 'globe', tone: 'data' },
{
label: 'First seen',
value: summary?.firstLogin ? when(summary.firstLogin) : '—',
small: true,
icon: 'history',
},
{
label: 'Last seen',
value: summary?.lastLogin ? when(summary.lastLogin) : '—',
small: true,
icon: 'pulse',
tone: 'note',
},
]}
/>
<Grid cols="wide">
<Card title="Connections per day" icon="chart" tone="info">
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => row.day}
valueOf={(row: (typeof data.days)[number]) => row.logins + row.failures}
toneOf={(row: (typeof data.days)[number]) => (row.failures > row.logins ? 'bad' : undefined)}
title={(row: (typeof data.days)[number]) =>
`${row.day}: ${row.logins} in${row.failures ? `, ${row.failures} refused` : ''}`
}
/>
</Card>
<div className="stack">
<Card title="Identity" icon="tv" tone="info">
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Person</b>
<p>{summary?.username || 'unknown'}</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Device id</b>
<p className="mono">{data.deviceId}</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Running</b>
<p className="mono">{summary?.clientVersion || 'unknown'}</p>
</div>
</div>
</div>
</Card>
<Card
title="Builds"
intro="Kept per television rather than per session, so it survives a sign-out."
icon="upload"
tone="note"
>
{data.versions.length === 0 ? (
<Empty>No build history for this television.</Empty>
) : (
<div className="list">
{data.versions.map((version) => (
<div className="list-item" key={version.version}>
<div className="list-body">
<b className="mono">{version.version}</b>
<p>
{when(version.firstSeen)} {when(version.lastSeen)}
</p>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</Grid>
<Card title="Addresses" icon="globe" tone="data">
{data.addresses.length === 0 ? (
<Empty>No addresses recorded in this window.</Empty>
) : (
<div className="chips">
{data.addresses.map((address) => (
<Chip key={address.ipAddress} tone={address.failures > 0 ? 'warn' : 'data'}>
{address.ipAddress} · {num(address.logins)}
{address.failures > 0 ? ` (+${num(address.failures)} refused)` : ''}
</Chip>
))}
</div>
)}
</Card>
<Card
title="Every attempt"
icon="key"
tone="ok"
actions={<span className="filter-summary">{num(data.total)} in this window</span>}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Person</th>
<th className="nowrap">Address</th>
<th>Build</th>
<th>Method</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{data.events.length === 0 ? (
<EmptyRow columns={6}>
This television has not connected in the selected window.
</EmptyRow>
) : (
data.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted">{when(event.occurredAt)}</td>
<td>{event.username || <span className="quiet">unknown</span>}</td>
<td className="mono nowrap">{event.ipAddress || '—'}</td>
<td className="mono">{event.clientVersion || '—'}</td>
<td className="muted">{event.method}</td>
<td className="nowrap">
{event.success ? (
event.newDevice ? (
<Tag tone="info">first sign-in</Tag>
) : (
<Tag tone="ok">got in</Tag>
)
) : (
<Tag tone="bad">{event.failureReason || 'refused'}</Tag>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useQuery } from '../lib/hooks';
import { duration, num, percent } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Loading,
PageHead,
TableWrap,
} from '../components/ui';
interface RowStat {
rowId: string;
rowKind: string;
impressions: number;
focuses: number;
selects: number;
selectRate: number;
dwellMs: number;
viewers: number;
}
/** The row ids are wire values; this is the render, so it is spelled. `favorites` stays
* `favorites` on both sides of the wire and reads "Favourites" on screen — the same
* boundary the launcher's own row title observes. */
function label(value: string | undefined): string {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
}
export function EngagementPage() {
const [days, setDays] = useState(30);
const { data, error, loading } = useQuery<{ rows: RowStat[] | null }>(
`/admin/api/analytics?days=${days}`,
);
const rows = data?.rows ?? [];
return (
<>
<PageHead
title="Row engagement"
intro="Impressions, focus, dwell and selections per launcher row."
/>
<Banner message={error} />
<Card
title="Launcher rows"
intro="Impressions are rows drawn, focuses are rows the D-pad reached, and dwell is how long it stayed there. Open rate is what a row was worth."
icon="chart"
tone="info"
actions={
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
</Field>
}
>
{loading ? (
<Loading rows={1} />
) : (
<TableWrap>
<table>
<thead>
<tr>
<th>Row</th>
<th>Kind</th>
<th className="num">Dwell</th>
<th className="num">Impressions</th>
<th className="num">Focuses</th>
<th className="num">Opened</th>
<th className="num">Open rate</th>
<th className="num">Viewers</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={8}>No events in this window.</EmptyRow>
) : (
rows.map((row) => (
<tr key={`${row.rowId}:${row.rowKind}`}>
<td>{label(row.rowId)}</td>
<td className="muted">{label(row.rowKind)}</td>
<td className="num">{duration(row.dwellMs)}</td>
<td className="num">{num(row.impressions)}</td>
<td className="num">{num(row.focuses)}</td>
<td className="num">{num(row.selects)}</td>
<td className="num">{percent(row.selectRate)}</td>
<td className="num">{num(row.viewers)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
)}
</Card>
</>
);
}
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
PlainTiles,
Tag,
} from '../components/ui';
type Mode = 'default' | 'on' | 'off';
interface Pending {
action: 'safe-mode' | 'rollback' | 'reset';
title: string;
body: string;
label: string;
}
export function FeaturesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
// The choices the operator has made but not published. Held apart from the server's
// answer so a poll landing mid-edit cannot take a half-made decision away — the rule the
// previous console needed `Admin.settled` for.
const [draft, setDraft] = useState<Record<string, Mode>>({});
const [pending, setPending] = useState<Pending | null>(null);
const policy = status?.features;
const features = policy?.features ?? [];
const clients = status?.clients ?? [];
const revision = policy?.revision ?? 0;
useEffect(() => {
// Adopt the server's state only where the operator has not expressed a preference.
if (!policy) return;
setDraft((current) => {
const next = { ...current };
for (const feature of policy.features ?? []) {
if (next[feature.key] === undefined) {
next[feature.key] = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
}
}
return next;
});
}, [policy]);
const act = (action: string, key: string, message: string, overrides?: Record<string, boolean>) =>
run(key, async () => {
await wrap(
() =>
api.post('/admin/api/features', {
action,
// Carried on every mutation, so two operators working at once cannot silently
// overwrite each other's publish.
expectedRevision: revision,
overrides: overrides ?? {},
}),
message,
);
setPending(null);
if (action === 'save') {
// Published: the draft is now the server's state, so stop holding it.
setDraft({});
}
await reload();
});
const publish = () => {
const overrides: Record<string, boolean> = {};
for (const [key, mode] of Object.entries(draft)) {
if (mode === 'on') overrides[key] = true;
if (mode === 'off') overrides[key] = false;
}
return act('save', 'save', 'Published.', overrides);
};
const capable = clients.filter((client) => (client.capabilities ?? []).includes('server_features_v1')).length;
const safeMode = Boolean(policy?.safeMode);
return (
<>
<PageHead
title="Features"
intro="Roll out, stop and recover optional behaviour with no app release."
/>
<Banner message={error} />
{loading || !policy ? (
<Loading />
) : (
<>
<Card
title="Control plane"
intro="Every optional feature has a safe default, an explicit override and a remote recovery path. Safe mode turns all of them off at once; sign-in, browsing and playback are never optional."
icon="sliders"
tone="ok"
actions={
<Button
variant={safeMode ? undefined : 'danger'}
busy={busy === 'safe'}
onClick={() =>
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'}
</Button>
}
>
<PlainTiles
tiles={[
{
label: 'features active',
value: `${features.filter((feature) => 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)}` },
]}
/>
</Card>
<Grid cols="2">
{features.length === 0 ? (
<Card title="Nothing registered" icon="sliders">
<Empty>No server features are registered.</Empty>
</Card>
) : (
features.map((feature) => (
<Card
key={feature.key}
title={feature.name}
intro={feature.description}
actions={<Tag tone={feature.enabled ? 'ok' : undefined}>{feature.enabled ? 'active' : 'off'}</Tag>}
footer={<span className="hint"> {feature.recovery}</span>}
>
<Field label="Mode">
<select
value={draft[feature.key] ?? 'default'}
onChange={(event) =>
setDraft((current) => ({ ...current, [feature.key]: event.target.value as Mode }))
}
>
<option value="default">Safe default</option>
<option value="on">Forced on</option>
<option value="off">Forced off</option>
</select>
</Field>
<div className="chips">
<Chip>{feature.key}</Chip>
<Chip>protocol {num(feature.minimumProtocol)}+</Chip>
<Chip tone={feature.compatible ? 'ok' : 'warn'}>
{feature.compatible ? 'server compatible' : 'compatibility blocked'}
</Chip>
<Chip tone="note">{feature.area}</Chip>
</div>
</Card>
))
)}
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void publish()}>
Publish changes
</Button>
<Button
disabled={!policy.canRollback}
onClick={() =>
setPending({
action: 'rollback',
title: 'Roll back one revision?',
body: 'The previous published feature revision is restored on every television.',
label: 'Roll back',
})
}
>
Roll back one revision
</Button>
<Button
onClick={() =>
setPending({
action: 'reset',
title: 'Clear every override?',
body: 'All features return to their safe software defaults.',
label: 'Clear overrides',
})
}
>
Clear all overrides
</Button>
<span className="spacer" />
{safeMode ? (
<Tag tone="warn">safe mode · optional features off</Tag>
) : (
<Tag tone="ok">live · revision r{num(revision)}</Tag>
)}
</div>
</Card>
</>
)}
{pending ? (
<Confirm
title={pending.title}
body={pending.body}
confirmLabel={pending.label}
destructive={pending.action !== 'rollback'}
busy={busy === pending.action}
onConfirm={() => void act(pending.action, pending.action, `${pending.label} done.`)}
onCancel={() => setPending(null)}
/>
) : null}
</>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Empty, Field, Grid, Loading, PageHead } from '../components/ui';
import type { HeroItem, HeroSchedule } from '../api/types';
const MAX_PINS = 4;
export function HeroPage() {
const { status, error, loading, reload } = useGateway();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [pins, setPins] = useState<HeroItem[]>([]);
const [subtitle, setSubtitle] = useState('');
const [dirty, setDirty] = useState(false);
const [queryText, setQueryText] = useState('');
const [results, setResults] = useState<HeroItem[] | null>(null);
const [schedules, setSchedules] = useState<HeroSchedule[]>([]);
const policy = status?.heroPolicy;
useEffect(() => {
// The poll must not take an unsaved arrangement away, which is what `dirty` guards.
if (dirty || !policy) return;
setPins((policy.pinnedItems ?? []).slice(0, MAX_PINS));
setSubtitle(policy.primeSubtitle ?? '');
setSchedules(policy.schedules ?? []);
}, [policy, dirty]);
const search = () =>
run('search', async () => {
const needle = queryText.trim();
if (!needle) return;
const payload = await wrap(() =>
api.get<{ items: HeroItem[] | null }>(`/admin/api/hero/search?q=${encodeURIComponent(needle)}`),
);
if (payload) setResults(payload.items ?? []);
});
const add = (item: HeroItem) => {
if (pins.some((pin) => pin.id === item.id)) return;
if (pins.length >= MAX_PINS) {
show('Remove a pinned title before adding another.', 'bad');
return;
}
setPins((current) => [...current, item]);
setDirty(true);
};
const save = () =>
run('save', async () => {
await wrap(
() =>
api.post('/admin/api/hero-policy', {
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: subtitle.trim(),
schedules,
}),
'Hero saved.',
);
setDirty(false);
await reload();
});
return (
<>
<PageHead
title="Home hero"
intro="Choose films or television shows for the launcher spotlight while recent releases fill the remaining places."
/>
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Card
title="Pinned titles"
intro="Pinned films and series lead the four-card launcher grid in this order. Empty places are filled by Memby's existing mix of recent digital releases, premieres and highly rated library titles. Pinning changes placement only; labels and reasons remain natural."
icon="star"
tone="note"
footer={
<>
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save hero
</Button>
<Button
onClick={() => {
setPins([]);
setDirty(true);
}}
>
Clear pins
</Button>
{dirty ? <span className="hint">Unsaved changes.</span> : null}
</>
}
>
{pins.length === 0 ? (
<Empty>No titles are pinned. The hero is entirely release-aware and automatic.</Empty>
) : (
<div className="chips">
{pins.map((item, index) => (
<Button
key={item.id}
variant="quiet"
size="sm"
icon="close"
onClick={() => {
setPins((current) => current.filter((pin) => pin.id !== item.id));
setDirty(true);
}}
>
{index + 1}. {item.name}
{item.year ? ` (${item.year})` : ''}
</Button>
))}
</div>
)}
<Field
label="Prime-card subtitle"
hint="Optional wording under the large first card. Leave blank to use Memby's natural release or rating reason."
>
<input
type="text"
maxLength={160}
value={subtitle}
placeholder="Leave blank for the automatic reason"
onChange={(event) => {
setSubtitle(event.target.value);
setDirty(true);
}}
/>
</Field>
</Card>
<Card title="Scheduled heroes" intro="Schedules are resolved by the gateway: manual pins still win, then the highest-priority eligible schedule, then Membys automatic hero." icon="clock" tone="info">
{schedules.length === 0 ? <Empty>No scheduled heroes yet.</Empty> : (
<div className="stack">{schedules.map((schedule) => {
const item = [...pins, ...(results ?? [])].find((candidate) => candidate.id === schedule.itemId);
return <div className="row" key={schedule.id}><b>{item?.name ?? schedule.itemId}</b><span className="muted">{new Date(schedule.startAt).toLocaleString()} {new Date(schedule.endAt).toLocaleString()} · priority {schedule.priority}</span><Button size="sm" variant="quiet" onClick={() => { setSchedules((current) => current.filter((entry) => entry.id !== schedule.id)); setDirty(true); }}>Remove</Button></div>;
})}</div>
)}
{pins.length > 0 ? <Button size="sm" icon="plus" onClick={() => {
const first = pins[0]; if (!first) return;
const start = new Date(); const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
setSchedules((current) => [...current, { id: crypto.randomUUID(), itemId: first.id, startAt: start.toISOString(), endAt: end.toISOString(), priority: 0, enabled: true }]); setDirty(true);
}}>Schedule first pinned title for two hours</Button> : <p className="hint">Pin or search for a title first, then add it to a schedule.</p>}
</Card>
<Card
title="Find a title"
intro="Search the imported Emby catalogue. Up to four films or series can be pinned."
icon="search"
tone="info"
>
<div className="field-row">
<Field label="Title" grow>
<input
type="search"
value={queryText}
placeholder="Search films and television shows"
onChange={(event) => setQueryText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void search();
}}
/>
</Field>
<Button busy={busy === 'search'} icon="search" onClick={() => void search()}>
Search
</Button>
</div>
{results === null ? null : results.length === 0 ? (
<Empty>No playable films or series matched that search.</Empty>
) : (
<Grid>
{results.map((item) => (
<Card key={item.id} title={item.name} intro={`${item.type || 'Title'} · ${item.year || 'Year unknown'}`}>
<Button
size="sm"
icon="plus"
disabled={pins.some((pin) => pin.id === item.id)}
onClick={() => add(item)}
>
{pins.some((pin) => pin.id === item.id) ? 'Pinned' : 'Add to hero'}
</Button>
</Card>
))}
</Grid>
)}
</Card>
</>
)}
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useGateway } from '../lib/gateway';
import { num, when } from '../lib/format';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tag } from '../components/ui';
export function ImportsPage() {
const { status, error, loading } = useGateway();
const runs = status?.runs ?? [];
return (
<>
<PageHead title="Imports" intro="Catalogue synchronisation history." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Synchronisation history"
intro="A full import mark-and-sweeps the catalogue; an incremental one asks Emby for what changed, with a minute of overlap so nothing falls between two runs."
icon="sync"
tone="data"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Started</th>
<th>Kind</th>
<th>Trigger</th>
<th>Status</th>
<th className="num">Seen</th>
<th className="num">Written</th>
<th className="num">Removed</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={8}>Nothing has been imported yet.</EmptyRow>
) : (
runs.map((run) => (
<tr key={run.id || run.startedAt}>
<td className="nowrap muted">{when(run.startedAt)}</td>
<td>{run.kind}</td>
<td className="muted">{run.trigger}</td>
<td>
<Tag tone={run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'}>
{run.status}
</Tag>
</td>
<td className="num">{num(run.itemsSeen)}</td>
<td className="num">{num(run.itemsUpserted)}</td>
<td className="num">{num(run.itemsRemoved)}</td>
<td className="muted">{run.error || ''}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
)}
</>
);
}
+298
View File
@@ -0,0 +1,298 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Chip, Empty, Field, Grid, PageHead, Tiles } from '../components/ui';
/* Nothing here changes anything. It re-runs the shared weighted scorer over one person's
prepared pool, after Emby permission and parental-control filtering, and shows every
component and evidence reason behind the order. */
interface Weighted {
weight?: number;
evidence?: number;
}
interface Profile {
genres?: Record<string, Weighted>;
studios?: Record<string, Weighted>;
actors?: Record<string, Weighted>;
directors?: Record<string, Weighted>;
franchises?: Record<string, Weighted>;
runtimeRanges?: Record<string, Weighted>;
ageRatings?: Record<string, Weighted>;
communityRatings?: Record<string, Weighted>;
releasePeriods?: Record<string, Weighted>;
contentTypes?: Record<string, Weighted>;
}
interface InspectorItem {
title: string;
type?: string;
year?: number;
runtimeMinutes?: number;
genres?: string[];
baseRank?: number;
baseScore?: number;
affinityScore?: number;
compatibilityScore?: number;
compatibilityLabel?: string;
preparedReason?: string;
preparedEvidenceTitle?: string;
eligibleRows?: string[];
exposure?: { impressions?: number; focuses?: number; selects?: number };
explanation?: { total?: number; components?: Record<string, number>; reasonCodes?: string[] };
}
interface InspectorResponse {
poolCandidates: number;
permissionEligible: number;
items: InspectorItem[] | null;
profile: Profile | null;
actions: { action: string; title?: string; itemId?: string }[] | null;
profileMeta?: { sourceEvents?: number; algorithmVersion?: string; poolBuiltAt?: string };
}
const DIMENSIONS: [string, keyof Profile][] = [
['Genre', 'genres'],
['Studio', 'studios'],
['Actor', 'actors'],
['Director', 'directors'],
['Franchise', 'franchises'],
['Runtime', 'runtimeRanges'],
['Age rating', 'ageRatings'],
['Community rating', 'communityRatings'],
['Release period', 'releasePeriods'],
['Content type', 'contentTypes'],
];
function affinities(profile: Profile | null) {
if (!profile) return [];
return DIMENSIONS.flatMap(([dimension, key]) =>
Object.entries(profile[key] ?? {}).map(([name, value]) => ({
dimension,
name,
weight: value.weight ?? 0,
evidence: value.evidence ?? 0,
})),
).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
const signed = (value: number) => `${value >= 0 ? '+' : ''}${value.toFixed(3)}`;
export function InspectorPage() {
const { status } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [userId, setUserId] = useState('');
const [context, setContext] = useState('default');
const [minutes, setMinutes] = useState('0');
const [at, setAt] = useState('');
const [result, setResult] = useState<InspectorResponse | null>(null);
const [hint, setHint] = useState('Choose a person to inspect their recommendations.');
const [error, setError] = useState('');
const users = status?.requestUsers ?? [];
const runTest = () =>
run('run', async () => {
if (!userId) {
setError('Choose a person to pressure-test.');
return;
}
setError('');
setHint('Running the permission check and the scorer…');
const params = new URLSearchParams({ userId, context, minutes: minutes || '0', limit: '100' });
if (at) params.set('at', new Date(at).toISOString());
const payload = await wrap(() =>
api.get<InspectorResponse>(`/admin/api/recommendations?${params.toString()}`),
);
if (payload) {
setResult(payload);
setHint(`Scored at ${new Date().toLocaleTimeString()}.`);
} else {
setHint('Pressure test failed.');
}
});
const top = affinities(result?.profile ?? null).slice(0, 24);
const actions = result?.actions ?? [];
const items = result?.items ?? [];
const meta = result?.profileMeta ?? {};
return (
<>
<PageHead
title="Score inspector"
intro="Re-run the ranker for one person and read every component."
/>
<Banner message={error} />
<Card
title="Run a pressure test"
intro="Nothing is changed by running this. It scores the person's prepared pool as the launcher would, in the context you choose."
icon="search"
tone="info"
footer={
<>
<Button variant="primary" busy={busy === 'run'} onClick={() => void runTest()}>
Run pressure test
</Button>
<span className="hint">{hint}</span>
</>
}
>
<div className="fields">
<Field label="Person">
<select value={userId} onChange={(event) => setUserId(event.target.value)}>
<option value="">Choose a person</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</Field>
<Field label="Context">
<select value={context} onChange={(event) => setContext(event.target.value)}>
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select>
</Field>
<Field label="Available minutes">
<input
type="number"
min={0}
max={360}
value={minutes}
onChange={(event) => setMinutes(event.target.value)}
/>
</Field>
<Field label="Evaluate at">
<input type="datetime-local" value={at} onChange={(event) => setAt(event.target.value)} />
</Field>
</div>
</Card>
{result ? (
<>
<Tiles
tiles={[
{ label: 'prepared pool', value: num(result.poolCandidates), icon: 'database', tone: 'data' },
{ label: 'permission eligible', value: num(result.permissionEligible), icon: 'shield', tone: 'ok' },
{ label: 'ranked result', value: num(items.length), icon: 'sparkle', tone: 'note' },
{ label: 'source events', value: num(meta.sourceEvents ?? 0), icon: 'pulse', tone: 'info' },
{ label: 'algorithm', value: meta.algorithmVersion || '—', small: true, icon: 'chip' },
{ label: 'pool built', value: when(meta.poolBuiltAt), small: true, icon: 'clock' },
]}
/>
<Card
title="Profile evidence"
intro="The strongest learned affinities, and every explicit action this person has taken."
icon="sparkle"
tone="note"
>
{top.length === 0 ? (
<Empty>No repeated affinity evidence yet; cold-start priors apply.</Empty>
) : (
<div className="chips">
{top.map((entry) => (
<Chip key={`${entry.dimension}:${entry.name}`} tone={entry.weight < 0 ? 'bad' : undefined}>
{entry.dimension}: {entry.name} {signed(entry.weight)} · n={num(entry.evidence)}
</Chip>
))}
</div>
)}
{actions.length === 0 ? (
<Empty>No explicit recommendation actions.</Empty>
) : (
<div className="chips">
{actions.map((action, index) => (
<Chip key={`${action.action}:${index}`} tone="ok">
{action.action}: {action.title || action.itemId}
</Chip>
))}
</div>
)}
</Card>
{items.length === 0 ? (
<Card>
<Empty>
No candidates survived this context, the explicit exclusions and the permission filter.
</Empty>
</Card>
) : (
<Grid>
{items.map((item, index) => {
const explanation = item.explanation ?? {};
const components = Object.entries(explanation.components ?? {}).sort(
(a, b) => Math.abs(b[1]) - Math.abs(a[1]),
);
const exposure = item.exposure ?? {};
const facts = [
item.type,
item.year,
item.runtimeMinutes ? `${item.runtimeMinutes} min` : null,
...(item.genres ?? []),
]
.filter(Boolean)
.join(' · ');
return (
<Card
key={`${index}:${item.title}`}
title={`#${index + 1} · ${item.title}`}
intro={facts}
actions={<Chip tone="ok">{Number(explanation.total ?? 0).toFixed(3)}</Chip>}
>
<p className="hint">
{item.preparedReason || 'No legacy prepared explanation'}
{item.compatibilityLabel ? ` · ${item.compatibilityLabel}` : ''}
</p>
<div className="chips">
{(explanation.reasonCodes ?? []).map((code) => (
<Chip key={code} tone="ok">
{code}
</Chip>
))}
{components.map(([name, value]) => (
<Chip key={name} tone={value < 0 ? 'bad' : undefined}>
{name}={signed(value)}
</Chip>
))}
</div>
<details>
<summary className="muted">Pool, row and exposure detail</summary>
<p className="hint">
Base rank {num(item.baseRank ?? 0)} · base {Number(item.baseScore ?? 0).toFixed(3)} ·
affinity {Number(item.affinityScore ?? 0).toFixed(3)} · compatibility{' '}
{Number(item.compatibilityScore ?? 0).toFixed(3)} · impressions{' '}
{num(exposure.impressions ?? 0)} · focuses {num(exposure.focuses ?? 0)} · selects{' '}
{num(exposure.selects ?? 0)}
</p>
<div className="chips">
{(item.eligibleRows ?? []).map((row) => (
<Chip key={row} tone="ok">
{row}
</Chip>
))}
</div>
{item.preparedEvidenceTitle ? (
<p className="hint">Prepared evidence: {item.preparedEvidenceTitle}</p>
) : null}
</details>
</Card>
);
})}
</Grid>
)}
</>
) : null}
</>
);
}
+398
View File
@@ -0,0 +1,398 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Confirm,
Empty,
EmptyRow,
Field,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Toggle,
} from '../components/ui';
import type { Integration, IntegrationEventOption, IntegrationsResponse } from '../api/types';
/* Integrations: administrative events going out to somewhere else.
*
* The whole page is written around one property of the backend, and it is worth stating
* because the form would otherwise look careless: the webhook address is never returned.
* It is the credential — anybody holding it can post into the channel — so the gateway
* sends back only whether one is set and the channel id from the middle of it. That is why
* the address field on an existing integration is blank with a placeholder saying so, and
* why saving with it blank leaves the stored one alone. */
interface Draft {
id: string;
name: string;
url: string;
enabled: boolean;
events: string[];
}
const NEW_DRAFT: Draft = { id: '', name: 'Discord', url: '', enabled: true, events: [] };
export function IntegrationsPage() {
const { wrap, show } = useToast();
const { busy, run } = useAction();
const { data, error, loading, reload } = useQuery<IntegrationsResponse>('/admin/api/integrations', {
pollMs: 60_000,
});
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState<Integration | null>(null);
const catalogue = data?.catalogue ?? [];
const integrations = data?.integrations ?? [];
const edit = (integration: Integration) =>
setDraft({
id: integration.id,
name: integration.name,
url: '',
enabled: integration.enabled,
events: integration.events ?? [],
});
const save = () =>
run('save', async () => {
if (!draft) return;
const saved = await wrap(
() => api.post<IntegrationsResponse>('/admin/api/integrations', draft),
draft.id ? 'Integration saved.' : 'Integration added.',
);
if (saved) {
setDraft(null);
await reload();
}
});
const remove = (integration: Integration) =>
run('remove', async () => {
await wrap(
() => api.del(`/admin/api/integrations/${encodeURIComponent(integration.id)}`),
`${integration.name} removed.`,
);
setConfirming(null);
await reload();
});
const test = (integration: Integration) =>
run(`test:${integration.id}`, async () => {
const result = await wrap(() =>
api.post<{ ok: boolean; message: string }>(
`/admin/api/integrations/${encodeURIComponent(integration.id)}/test`,
),
);
// The test route answers 200 whether or not the webhook accepted it, because the
// *request* succeeded — so the verdict is in the body, and reporting it is this
// page's job rather than the transport's.
if (result) show(result.message, result.ok ? 'ok' : 'bad');
await reload();
});
return (
<>
<PageHead
title="Integrations"
intro="Send administrative events to somewhere you already look. Events pass through the gateway's own event layer, so nothing about authentication or scheduled tasks knows Discord exists — and a second kind of destination is a change here rather than everywhere."
actions={
<Button variant="primary" icon="plus" onClick={() => setDraft(NEW_DRAFT)}>
Add a webhook
</Button>
}
/>
<Banner message={error} />
{(data?.dropped ?? 0) > 0 ? (
<Note tone="warn">
{num(data?.dropped ?? 0)} events could not be queued for delivery. The queue is deliberately
lossy a slow endpoint must never hold up a television signing in but a number growing here
means a destination is not keeping up.
</Note>
) : null}
{loading ? (
<Loading />
) : integrations.length === 0 && !draft ? (
<Card title="Nothing configured" icon="plug" tone="note">
<Empty>
No destinations yet. A Discord webhook takes about a minute: in Discord, open a channel's
settings → Integrations → Webhooks → New Webhook, copy its URL, and paste it here.
</Empty>
</Card>
) : (
integrations.map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
catalogue={catalogue}
busy={busy}
onEdit={() => edit(integration)}
onTest={() => void test(integration)}
onRemove={() => setConfirming(integration)}
/>
))
)}
{draft ? (
<DraftCard
draft={draft}
catalogue={catalogue}
busy={busy === 'save'}
onChange={setDraft}
onSave={() => void save()}
onCancel={() => setDraft(null)}
/>
) : null}
{confirming ? (
<Confirm
title={`Remove ${confirming.name}?`}
body="The webhook address and its delivery history go with it. Events already published stay in the activity feed."
confirmLabel="Remove"
destructive
busy={busy === 'remove'}
onConfirm={() => void remove(confirming)}
onCancel={() => setConfirming(null)}
/>
) : null}
</>
);
}
function IntegrationCard({
integration,
catalogue,
busy,
onEdit,
onTest,
onRemove,
}: {
integration: Integration;
catalogue: IntegrationEventOption[];
busy: string | null;
onEdit: () => void;
onTest: () => void;
onRemove: () => void;
}) {
const health = integration.health;
// "Is it working" is answered by the *last* attempt, not by a failure count: a webhook
// that failed once an hour ago and has worked since is healthy.
const healthy =
!health.lastFailure || (health.lastSuccess && health.lastSuccess > health.lastFailure);
const selected = integration.events ?? [];
return (
<Card
title={integration.name}
intro={integration.hint ? `Discord webhook ${integration.hint}` : 'Discord webhook'}
icon="plug"
tone={integration.enabled ? 'ok' : 'warn'}
actions={
<>
{integration.enabled ? <Tag tone="ok">on</Tag> : <Tag tone="warn">off</Tag>}
{health.deliveries > 0 ? (
<Tag tone={healthy ? 'ok' : 'bad'}>{healthy ? 'delivering' : 'failing'}</Tag>
) : (
<Tag>never used</Tag>
)}
<Button size="sm" icon="pulse" busy={busy === `test:${integration.id}`} onClick={onTest}>
Test
</Button>
<Button size="sm" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="danger" icon="trash" onClick={onRemove} title="Remove" />
</>
}
>
<div className="list">
<div className="list-item">
<div className="list-body">
<b>Events sent</b>
<p>
{selected.length === 0
? 'None selected this destination is configured but will never post anything.'
: selected
.map((type) => catalogue.find((entry) => entry.type === type)?.label ?? type)
.join(', ')}
</p>
</div>
</div>
<div className="list-item">
<div className="list-body">
<b>Last delivered</b>
<p>{health.lastSuccess ? when(health.lastSuccess) : 'never'}</p>
</div>
<div className="list-actions">
{health.deliveries > 0 ? (
<span className="quiet">
{num(health.deliveries)} attempts, {num(health.failures)} failed
</span>
) : null}
</div>
</div>
{health.lastFailure ? (
<div className="list-item">
<div className="list-body">
<b>Last failure</b>
<p>
{when(health.lastFailure)}
{health.lastError ? ` — ${health.lastError}` : ''}
</p>
</div>
</div>
) : null}
</div>
{integration.deliveries.length > 0 ? (
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Attempted</th>
<th>Event</th>
<th>Result</th>
<th className="num">Took</th>
</tr>
</thead>
<tbody>
{integration.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td className="nowrap muted" title={when(delivery.attemptedAt)}>
{ago(delivery.attemptedAt)}
</td>
<td className="muted">{delivery.eventType}</td>
<td>
{delivery.success ? (
<Tag tone="ok">{delivery.statusCode || 'ok'}</Tag>
) : (
<Tag tone="bad">{delivery.error || delivery.statusCode || 'failed'}</Tag>
)}
</td>
<td className="num muted">{duration(delivery.durationMs)}</td>
</tr>
))}
</tbody>
</table>
</TableWrap>
) : (
<TableWrap>
<table>
<tbody>
<EmptyRow columns={4}>Nothing has been delivered through this webhook yet.</EmptyRow>
</tbody>
</table>
</TableWrap>
)}
</Card>
);
}
function DraftCard({
draft,
catalogue,
busy,
onChange,
onSave,
onCancel,
}: {
draft: Draft;
catalogue: IntegrationEventOption[];
busy: boolean;
onChange: (next: Draft) => void;
onSave: () => void;
onCancel: () => void;
}) {
const groups = [...new Set(catalogue.map((entry) => entry.group))];
const toggleEvent = (type: string, on: boolean) =>
onChange({
...draft,
events: on ? [...draft.events, type] : draft.events.filter((entry) => entry !== type),
});
return (
<Card
title={draft.id ? `Edit ${draft.name}` : 'New Discord webhook'}
icon="plug"
tone="info"
footer={
<>
<Button variant="primary" busy={busy} onClick={onSave}>
{draft.id ? 'Save' : 'Add'}
</Button>
<Button variant="quiet" onClick={onCancel}>
Cancel
</Button>
<span className="spacer" />
{draft.events.length === 0 ? (
<span className="quiet">Nothing selected — this destination would never post.</span>
) : (
<span className="quiet">{draft.events.length} events selected</span>
)}
</>
}
>
<div className="fields">
<Field label="Name" hint="What this destination is called in the console.">
<input
type="text"
value={draft.name}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</Field>
<Field
label="Webhook address"
hint={
draft.id
? 'Leave blank to keep the address already saved it is a credential and is never sent back to this page.'
: 'Discord channel settings Integrations Webhooks New Webhook Copy Webhook URL.'
}
>
<input
type="url"
value={draft.url}
placeholder={draft.id ? 'unchanged' : 'https://discord.com/api/webhooks/…'}
onChange={(event) => onChange({ ...draft, url: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Enabled"
hint="Off keeps the configuration and stops the posts."
checked={draft.enabled}
onChange={(next) => onChange({ ...draft, enabled: next })}
/>
{groups.map((group) => (
<div key={group}>
<div className="card-head" style={undefined}>
<div className="card-head-text">
<h2>{group}</h2>
</div>
</div>
{catalogue
.filter((entry) => entry.group === group)
.map((entry) => (
<Toggle
key={entry.type}
label={entry.label}
hint={entry.description}
checked={draft.events.includes(entry.type)}
onChange={(on) => toggleEvent(entry.type, on)}
/>
))}
</div>
))}
</Card>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { Link, useParams } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { when } from '../lib/format';
import { Icon } from '../components/Icon';
import { Banner, Card, Loading, PageHead, Tag } from '../components/ui';
interface JourneyEvent {
journeyId: string;
sequence: number;
occurredAt: string;
category: string;
action: string;
screen?: string;
feature?: string;
source?: string;
target?: string;
itemName?: string;
itemType?: string;
outcome?: string;
}
interface JourneyResponse {
users: { userId: string; username: string }[] | null;
events: JourneyEvent[] | null;
}
const label = (value: string | undefined | null) => {
const text = String(value || '—').replaceAll('_', ' ');
return text === 'favorites' ? 'Favourites' : text;
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
const detail = (event: JourneyEvent) => event.itemName
? `${label(event.itemType)} · ${event.itemName}`
: event.source && event.target ? `${label(event.source)}${label(event.target)}` : place(event);
const verb = (event: JourneyEvent) => ({
journey_start: 'Opened Memby', home_open: 'Opened Memby', journey_end: 'Finished session',
screen_view: 'Viewed', select: 'Selected', open: 'Opened', close: 'Closed',
request: event.category === 'playback' ? 'Started watching' : 'Requested',
stop: 'Stopped watching', start: 'Started', complete: 'Completed',
}[event.action] ?? label(event.action));
function outcome(events: JourneyEvent[]) {
const explicit = [...events].reverse().find((event) => event.outcome)?.outcome;
if (explicit === 'success' || explicit === 'completed') return { label: label(explicit), tone: 'ok' as const };
if (explicit === 'failure' || explicit === 'cancelled' || explicit === 'abandoned') return { label: label(explicit), tone: 'note' as const };
if (events.some((event) => event.action === 'stop' && event.category === 'playback')) return { label: 'watched', tone: 'ok' as const };
return { label: 'left before playback ended', tone: 'warn' as const };
}
/* A session is an app-opening UUID. A viewing journey is an intent that reaches playback.
* One session can contain several of them: search → watch a film → search → watch another
* is two journeys, which is the answer an operator needs without pretending it was two app
* launches. Events before each playback request belong to that request; the tail remains a
* non-playback journey so an unsuccessful search is visible rather than silently discarded. */
function splitViewingJourneys(events: JourneyEvent[]) {
const boundaries = events.reduce<number[]>((out, event, index) => {
if (event.category === 'playback' && event.action === 'request') out.push(index);
return out;
}, []);
if (boundaries.length === 0) return [events];
return boundaries.map((boundary, index) => events.slice(index === 0 ? 0 : boundary, (boundaries[index + 1] ?? events.length)));
}
export function JourneyViewerPage() {
const { userId = '' } = useParams();
const path = useMemo(() => `/admin/api/journeys${query({ days: 90, userId })}`, [userId]);
const { data, error, loading } = useQuery<JourneyResponse>(path);
const username = data?.users?.find((user) => user.userId === userId)?.username || userId;
const sessions = useMemo(() => {
const grouped = new Map<string, JourneyEvent[]>();
for (const event of data?.events ?? []) grouped.set(event.journeyId, [...(grouped.get(event.journeyId) ?? []), event]);
return [...grouped.values()]
.map((events) => events.sort((left, right) => left.sequence - right.sequence))
.sort((left, right) => (right[0]?.occurredAt ?? '').localeCompare(left[0]?.occurredAt ?? ''));
}, [data?.events]);
const journeys = sessions.flatMap((session) => splitViewingJourneys(session).map((events, index) => ({ events, key: `${session[0]?.journeyId}:${index}` })));
return <>
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
<Banner message={error} />
{loading ? <Loading /> : <Card title="Viewing journeys" intro={`${sessions.length} app session${sessions.length === 1 ? '' : 's'} · ${journeys.length} viewing journey${journeys.length === 1 ? '' : 's'} in the last 90 days.`} icon="journey" tone="info">
<div className="visits">
{journeys.length === 0 ? <p className="empty">No journeys recorded for this viewer.</p> : journeys.map((journey, index) => {
const events = journey.events;
const entry = events[0];
const selection = [...events].reverse().find((event) => event.itemName || event.action === 'select' || (event.category === 'playback' && event.action === 'request'));
const result = outcome(events);
return <article className="visit" key={journey.key}>
<header><div><b>{when(entry?.occurredAt)}</b><span>Journey {index + 1} · {events.length} recorded steps</span></div><Tag tone={result.tone}>{result.label}</Tag></header>
<div className="journey-answers">
<div className="journey-answer" data-kind="entry"><Icon name="journey" /><span>Entered from</span><b>{place(entry)}</b></div>
<div className="journey-answer" data-kind="selection"><Icon name="play" /><span>Selected</span><b>{selection ? detail(selection) : 'Nothing selected'}</b></div>
<div className="journey-answer" data-kind="outcome"><Icon name={result.tone === 'ok' ? 'check' : 'clock'} /><span>Outcome</span><b>{result.label}</b></div>
</div>
<ol className="journey-timeline">{events.map((event) => <li key={`${event.journeyId}:${event.sequence}`}><span className="timeline-dot" data-action={event.action} /><div><b>{verb(event)}</b><span>{detail(event)}</span></div><time>{when(event.occurredAt)}</time></li>)}</ol>
</article>;
})}
</div>
</Card>}
</>;
}
+299
View File
@@ -0,0 +1,299 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { duration, num, percent, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Grid,
Loading,
Meter,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
/* Memby's major feature catalogue, in the order it is presented.
*
* It is a list here rather than derived from what has been used, and that is the whole
* point of the table it feeds: a feature nobody has touched has no row in the analytics
* and would simply be absent, which is indistinguishable from a feature that does not
* exist. Listing it and showing a zero is what makes "not used" an answer. */
const FEATURE_CATALOGUE = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
];
/** The wire values are ids; this is the render, so it is spelled — the same boundary the
* launcher's own row titles observe. */
function label(value: string | undefined | null): string {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
}
interface JourneysResponse {
days: number;
retentionDays: number;
stats: {
events: number;
journeys: number;
viewers: number;
completed: number;
abandoned: number;
active: number;
averageSteps: number;
averageTimeMs: number;
completionRate: number;
};
users: { userId: string; username: string }[] | null;
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
actions: { category: string; action: string; events: number; journeys: number }[] | null;
paths: { from: string; to: string; count: number }[] | null;
}
export function JourneysPage() {
const [days, setDays] = useState(30);
const [userId, setUserId] = useState('');
const navigate = useNavigate();
const path = useMemo(() => `/admin/api/journeys${query({ days, userId })}`, [days, userId]);
const { data, error, loading } = useQuery<JourneysResponse>(path);
const stats = data?.stats;
const users = data?.users ?? [];
const actions = data?.actions ?? [];
const paths = data?.paths ?? [];
const topPath = paths[0];
const features = useMemo(() => {
const used = new Map((data?.features ?? []).map((feature) => [feature.feature, feature]));
const position = new Map(FEATURE_CATALOGUE.map((name, index) => [name, index]));
return [...new Set([...FEATURE_CATALOGUE, ...used.keys()])]
.map((name) => ({ name, stat: used.get(name) }))
.sort((left, right) => {
const byUse = (right.stat?.uses ?? 0) - (left.stat?.uses ?? 0);
if (byUse) return byUse;
return (
(position.get(left.name) ?? Number.MAX_SAFE_INTEGER) -
(position.get(right.name) ?? Number.MAX_SAFE_INTEGER)
);
});
}, [data?.features]);
// Individual visits are shown only for one person: across a household they are a wall
// of cards with nothing to compare against, and the question they answer is always
// "what did *they* do".
return (
<>
<PageHead
title="User journeys"
intro="How viewers move through Memby, use features and complete flows."
/>
<Banner message={error} />
<Card
title="Journey health"
intro="Server-derived foreground visits, completion and interruption. Search text, content titles and setting values are never stored."
icon="people"
tone="info"
actions={
<>
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={90}>90 days</option>
</select>
</Field>
<Field label="User">
<select
value={userId}
onChange={(event) => {
const next = event.target.value;
setUserId(next);
if (next) navigate(`/admin/journeys/${encodeURIComponent(next)}`);
}}
>
<option value="">All users</option>
{users.map((user) => (
<option key={user.userId} value={user.userId}>
{user.username || user.userId}
</option>
))}
</select>
</Field>
</>
}
>
{loading ? (
<Loading rows={1} />
) : (
<>
<Tiles
tiles={[
{ label: 'journeys', value: num(stats?.journeys), icon: 'list', tone: 'data' },
{ label: 'viewers', value: num(stats?.viewers), icon: 'people', tone: 'info' },
{ label: 'completion', value: percent(stats?.completionRate), icon: 'check', tone: 'ok' },
{ label: 'abandoned', value: num(stats?.abandoned), icon: 'alert', tone: 'note' },
{ label: 'active now', value: num(stats?.active), icon: 'pulse', tone: 'info' },
{ label: 'average steps', value: (stats?.averageSteps ?? 0).toFixed(1), icon: 'chart' },
{ label: 'average visit', value: duration(stats?.averageTimeMs), small: true, icon: 'clock' },
{ label: 'history kept', value: `${data?.retentionDays ?? 90} days`, small: true, icon: 'clock' },
]}
/>
<div className="summary-grid">
<div className="summary">
<div className="summary-head">
<b>Visit completion</b>
<strong>{percent(stats?.completionRate)}</strong>
</div>
<Meter value={stats?.completed ?? 0} total={stats?.journeys ?? 0} />
<p>
{num(stats?.completed)} completed · {num(stats?.abandoned)} abandoned ·{' '}
{num(stats?.active)} active
</p>
</div>
<div className="summary">
<div className="summary-head">
<b>Most common route</b>
</div>
<strong style={undefined}>
{topPath ? `${label(topPath.from)}${label(topPath.to)}` : 'Not enough data'}
</strong>
<p>
{topPath
? `${num(topPath.count)} times in this window`
: 'Journeys will appear here as viewers move through Memby.'}
</p>
</div>
</div>
</>
)}
</Card>
<Grid cols="2">
<Card
title="What people do"
intro="Actions show total use and how many separate visits included them."
icon="chart"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Action</th>
<th className="num">Uses</th>
<th className="num">Visits</th>
</tr>
</thead>
<tbody>
{actions.length === 0 ? (
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
) : (
actions.map((action) => (
<tr key={`${action.category}:${action.action}`}>
<td>
<b>{label(action.action)}</b>
<span className="table-sub">{label(action.category)}</span>
</td>
<td className="num">{num(action.events)}</td>
<td className="num">{num(action.journeys)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Where people go"
intro="The most common steps between screens, including where quiet visits ended."
icon="list"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Route</th>
<th className="num">Times</th>
</tr>
</thead>
<tbody>
{paths.length === 0 ? (
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
) : (
paths.map((entry, index) => (
<tr key={`${entry.from}:${entry.to}:${index}`}>
<td>
{label(entry.from)} <span className="route-arrow"></span> {label(entry.to)}
</td>
<td className="num">{num(entry.count)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</Grid>
<Card
title="Feature use"
intro="Rare and unused features are shown against Memby's major feature catalogue."
icon="pulse"
tone="data"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Feature</th>
<th className="num">Uses</th>
<th>Last used</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{features.map(({ name, stat }) => {
const uses = stat?.uses ?? 0;
return (
<tr key={name}>
<td>{label(name)}</td>
<td className="num">{num(uses)}</td>
<td className="muted nowrap">{stat ? when(stat.lastUsedAt) : '—'}</td>
<td>
{uses === 0 ? (
<Tag tone="warn">not used</Tag>
) : uses < 3 ? (
<Tag tone="note">rare</Tag>
) : (
<Tag tone="ok">used</Tag>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</TableWrap>
</Card>
{userId ? null : (
<Card title="Inspect a viewer" intro="Choose a person above to open their dedicated session and viewing-journey timeline." icon="journey" tone="info">
<p className="empty">A viewing journey follows one intent through to playback, so two films watched in a single app session appear as two separate journeys.</p>
</Card>
)}
</>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Confirm, Loading, PageHead, Tiles } from '../components/ui';
export function LibraryPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [confirming, setConfirming] = useState(false);
const byType = status?.library.byType ?? {};
const running = Boolean(status?.syncRunning);
const sync = (kind: 'incremental' | 'full') =>
run(kind, async () => {
await wrap(
() => api.post('/admin/api/sync', { kind }),
kind === 'full' ? 'Full re-import started.' : 'Import started.',
);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="Library" intro="Import and inspect the catalogue Memby ranks." />
<Banner message={error} />
{loading || !status ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'items', value: num(status.library.total), icon: 'library', tone: 'data' },
...Object.keys(byType)
.sort()
.map((type) => ({
label: type,
value: num(byType[type]),
icon: 'list' as const,
})),
{ label: 'last import', value: when(status.library.lastSynced), small: true, icon: 'clock' as const },
]}
/>
<Card
title="Import the catalogue"
intro="Emby's catalogue is copied here so search and the recommendation candidate pool can be answered from one indexed table. Watched, favourite and resume state is deliberately not stored — that is per person and still comes from Emby live."
icon="library"
tone="data"
footer={
<span className="hint">
{running
? 'Import running…'
: `An incremental import runs automatically every ${status.syncEvery}.`}
</span>
}
>
<div className="row">
<Button
variant="primary"
icon="sync"
disabled={running}
busy={busy === 'incremental'}
onClick={() => void sync('incremental')}
>
Sync new items
</Button>
<Button icon="database" disabled={running} onClick={() => setConfirming(true)}>
Full re-import
</Button>
</div>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Re-import the entire library?"
body="A full pass mark-and-sweeps the catalogue and can take several minutes on a large library. Televisions keep reading the current table throughout."
confirmLabel="Re-import"
busy={busy === 'full'}
onConfirm={() => void sync('full')}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+419
View File
@@ -0,0 +1,419 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { daysAgo, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
EmptyRow,
Field,
Grid,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { Bars } from '../components/ui';
import type { LoginDevicesResponse, LoginsResponse } from '../api/types';
/* The sign-in history.
*
* This is the page the whole login-analytics feature exists for, and its shape follows
* from one observation: an operator arrives here with a *question*, not a browsing
* intention — "did the bedroom TV connect this morning", "who is that address", "why does
* this account keep failing" — so the filter bar is above the table and always visible,
* not behind a disclosure, and every control in it maps to one server-side filter.
*
* Two tables of the same rows, deliberately, the stance the searches page takes: the
* devices summary answers "which televisions are connecting", which is what you read when
* you do not yet know where to look, and the log is uncollapsed and newest-first, which is
* what you read when something has just happened. */
type View = 'log' | 'devices';
interface Filters {
user: string;
q: string;
ip: string;
outcome: '' | 'success' | 'failure';
from: string;
to: string;
}
const EMPTY: Filters = { user: '', q: '', ip: '', outcome: '', from: '', to: '' };
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
{ value: 0, label: 'All' },
] as const;
export function LoginsPage() {
const [view, setView] = useState<View>('log');
const [days, setDays] = useState<number>(7);
const [filters, setFilters] = useState<Filters>(EMPTY);
const [page, setPage] = useState(0);
const limit = 100;
// The window and the explicit date range are the same filter expressed two ways, and an
// explicit `from` wins: an operator who typed a date meant it, and silently narrowing it
// to the last seven days would answer a question they did not ask.
const params = useMemo(
() =>
query({
...filters,
days: filters.from ? undefined : days || undefined,
limit,
offset: page * limit,
}),
[filters, days, page],
);
const log = useQuery<LoginsResponse>(`/admin/api/logins${params}`, { enabled: view === 'log' });
const devices = useQuery<LoginDevicesResponse>(`/admin/api/logins/devices${params}`, {
enabled: view === 'devices',
});
const users = log.data?.users ?? devices.data?.users ?? [];
const totals = log.data?.totals ?? devices.data?.totals;
const retention = log.data?.retentionDays ?? devices.data?.retentionDays ?? 90;
const loading = view === 'log' ? log.loading : devices.loading;
const error = view === 'log' ? log.error : devices.error;
const update = (patch: Partial<Filters>) => {
setFilters((current) => ({ ...current, ...patch }));
setPage(0);
};
const active =
Object.entries(filters).some(([, value]) => value !== '') || Boolean(filters.from);
return (
<>
<PageHead
title="Sign-in history"
intro="Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected."
actions={
<Segments
value={view}
options={[
{ value: 'log', label: 'Log' },
{ value: 'devices', label: 'By device' },
]}
onChange={setView}
/>
}
/>
<Banner message={error} />
{totals ? (
<Tiles
tiles={[
{ label: 'Successful sign-ins', value: num(totals.logins), icon: 'key', tone: 'ok' },
{
label: 'Refused',
value: num(totals.failures),
icon: 'shield',
tone: totals.failures > 0 ? 'warn' : undefined,
},
{ label: 'Televisions', value: num(totals.devices), icon: 'tv', tone: 'info' },
{ label: 'People', value: num(totals.users), icon: 'people', tone: 'note' },
{ label: 'Addresses', value: num(totals.addresses), icon: 'globe', tone: 'data' },
{
label: 'History kept',
value: `${retention} days`,
small: true,
icon: 'clock',
},
]}
/>
) : null}
{/* Fast filtering is most of what makes this table useful, so the controls sit above
it rather than behind a "filters" disclosure nobody opens. */}
<div className="filters">
<Field label="Window">
<Segments
value={filters.from ? -1 : days}
options={WINDOWS.map((entry) => ({ value: entry.value as number, label: entry.label }))}
onChange={(next) => {
setDays(next);
update({ from: '', to: '' });
}}
/>
</Field>
<Field label="Person">
<select value={filters.user} onChange={(event) => update({ user: event.target.value })}>
<option value="">Anyone</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username || user.id}
</option>
))}
</select>
</Field>
<Field label="Outcome">
<select
value={filters.outcome}
onChange={(event) => update({ outcome: event.target.value as Filters['outcome'] })}
>
<option value="">Both</option>
<option value="success">Got in</option>
<option value="failure">Refused</option>
</select>
</Field>
<Field label="Address">
<input
type="text"
value={filters.ip}
placeholder="10.0.0.4"
onChange={(event) => update({ ip: event.target.value })}
/>
</Field>
<Field label="From">
<input type="date" value={filters.from} onChange={(event) => update({ from: event.target.value })} />
</Field>
<Field label="To">
<input type="date" value={filters.to} onChange={(event) => update({ to: event.target.value })} />
</Field>
<Field label="Search" grow>
<input
type="search"
value={filters.q}
placeholder="Name, device or address"
onChange={(event) => update({ q: event.target.value })}
/>
</Field>
<div className="filter-actions">
{active ? (
<Button
variant="quiet"
size="sm"
onClick={() => {
setFilters(EMPTY);
setPage(0);
}}
>
Clear
</Button>
) : null}
</div>
</div>
{loading ? (
<Loading />
) : view === 'log' ? (
<LogView data={log.data} page={page} limit={limit} onPage={setPage} />
) : (
<DeviceView data={devices.data} />
)}
</>
);
}
function LogView({
data,
page,
limit,
onPage,
}: {
data: LoginsResponse | undefined;
page: number;
limit: number;
onPage: (next: number) => void;
}) {
if (!data) return null;
const shown = data.events.length;
const from = data.total === 0 ? 0 : page * limit + 1;
return (
<>
<Grid cols="wide">
<Card
title="Attempts per day"
intro="Grouped in the household's own timezone, so an evening sign-in stays on the day it happened."
icon="chart"
tone="info"
>
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => row.day}
valueOf={(row: (typeof data.days)[number]) => row.logins + row.failures}
toneOf={(row: (typeof data.days)[number]) => (row.failures > row.logins ? 'bad' : undefined)}
title={(row: (typeof data.days)[number]) =>
`${row.day}: ${row.logins} in, ${row.failures} refused, ${row.devices} televisions`
}
/>
</Card>
<Card title="Where from" icon="globe" tone="data">
{data.addresses.length === 0 ? (
<Empty>No addresses in this window.</Empty>
) : (
<div className="list">
{data.addresses.slice(0, 8).map((address) => (
<div className="list-item" key={address.ipAddress}>
<div className="list-body">
<b className="mono">{address.ipAddress}</b>
<p>
{num(address.logins)} in
{address.failures > 0 ? ` · ${num(address.failures)} refused` : ''}
</p>
</div>
{address.failures > 0 && address.logins === 0 ? <Tag tone="bad">only refused</Tag> : null}
</div>
))}
</div>
)}
</Card>
</Grid>
<Card
title="Attempts"
intro="Uncollapsed and newest first: this is what to read when somebody says a television will not sign in."
icon="key"
tone="ok"
actions={
<span className="filter-summary">
{data.total === 0
? 'nothing matches'
: `${num(from)}${num(from + shown - 1)} of ${num(data.total)}`}
</span>
}
footer={
data.total > limit ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => onPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= data.total}
onClick={() => onPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Person</th>
<th>Television</th>
<th className="nowrap">Address</th>
<th>Build</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{data.events.length === 0 ? (
<EmptyRow columns={6}>No sign-in attempts match these filters.</EmptyRow>
) : (
data.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted">{when(event.occurredAt)}</td>
<td>{event.username || <span className="quiet">unknown</span>}</td>
<td>
{event.deviceId ? (
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(event.deviceId)}`}>
{event.deviceName || event.deviceId}
</Link>
) : (
<span className="quiet"></span>
)}
</td>
<td className="mono nowrap">{event.ipAddress || '—'}</td>
<td className="mono">{event.clientVersion || '—'}</td>
<td className="nowrap">
{event.success ? (
event.newDevice ? (
<Tag tone="info">first sign-in</Tag>
) : (
<Tag tone="ok">got in</Tag>
)
) : (
<Tag tone="bad">{event.failureReason || 'refused'}</Tag>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
);
}
function DeviceView({ data }: { data: LoginDevicesResponse | undefined }) {
if (!data) return null;
return (
<Card
title="Televisions"
intro="Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it."
icon="tv"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Television</th>
<th>Person</th>
<th className="num">Today</th>
<th className="num">Sign-ins</th>
<th className="num">Refused</th>
<th className="num">Addresses</th>
<th className="nowrap">Last address</th>
<th className="nowrap">Last sign-in</th>
<th>Build</th>
</tr>
</thead>
<tbody>
{data.devices.length === 0 ? (
<EmptyRow columns={9}>No television has connected in this window.</EmptyRow>
) : (
data.devices.map((device) => (
<tr key={device.deviceId}>
<td>
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(device.deviceId)}`}>
{device.deviceName || device.deviceId}
</Link>
</td>
<td className="muted">{device.username || '—'}</td>
<td className="num">{device.loginsToday > 0 ? num(device.loginsToday) : '—'}</td>
<td className="num">{num(device.logins)}</td>
<td className="num">
{device.failures > 0 ? <span className="mono">{num(device.failures)}</span> : '—'}
</td>
<td className="num">{num(device.distinctIps)}</td>
<td className="mono nowrap">{device.lastIp || '—'}</td>
{/* A device with failures and no successes has no last sign-in, which is
a real answer rather than a zero one. */}
<td className="nowrap muted">{device.lastLogin ? when(device.lastLogin) : '—'}</td>
<td className="mono">{device.clientVersion || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
);
}
/** Exported for the device page, which offers the same seven-day default. */
export const defaultWindowFrom = () => daysAgo(7);
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Field, PageHead } from '../components/ui';
import type { LogEvent, LogResponse } from '../api/types';
/* The live server log.
*
* The ring buffer is drained in pages until it is caught up, so a console opened *after*
* an incident sees what happened rather than only what happens next. Everything the page
* has drained is held for filtering and export; only the visible tail is drawn, because
* rendering twenty thousand lines during an incident is how a browser tab stops
* responding at exactly the wrong moment. */
const RANKS: Record<string, number> = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
const RETAIN = 20_000;
const DRAW = 2_500;
const POLL_MS = 5_000;
/* The same order the server's own console lines use — who and where first, the reason for
the line last — so a log read here and a log read over SSH look alike. `version` is
dropped: it is the same on every line and is reported once in the top bar instead. */
const FIELD_ORDER = ['component', 'user', 'device', 'client', 'protocol', 'method', 'path', 'status', 'duration'];
function orderedFields(attributes: Record<string, unknown>): [string, unknown][] {
const rank = (key: string) => {
const at = FIELD_ORDER.indexOf(key);
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
}
const haystack = (event: LogEvent) =>
[event.message, ...Object.entries(event.attributes ?? {}).flat()].join(' ').toLowerCase();
export function LogsPage() {
const [records, setRecords] = useState<LogEvent[]>([]);
const [dropped, setDropped] = useState(0);
const [paused, setPaused] = useState(false);
const [level, setLevel] = useState('INFO');
const [search, setSearch] = useState('');
const [error, setError] = useState('');
const cursor = useRef(0);
const fetching = useRef(false);
const view = useRef<HTMLDivElement>(null);
// Whether the reader is at the bottom is decided *before* the new lines are drawn: after
// they are, the measurement always says "not at the bottom" and the log would never
// follow. Hence the layout effect below rather than a check inside the fetch.
const pinned = useRef(true);
const drain = useCallback(async () => {
if (paused || fetching.current) return;
fetching.current = true;
try {
let pages = 0;
let page: LogResponse;
do {
page = await api.get<LogResponse>(`/admin/api/events?after=${cursor.current}&limit=1000`);
cursor.current = page.next || cursor.current;
if (page.dropped) setDropped((current) => current + page.dropped);
const events = page.events ?? [];
if (events.length > 0) {
setRecords((current) => {
const next = [...current, ...events];
return next.length > RETAIN ? next.slice(next.length - RETAIN) : next;
});
}
pages += 1;
} while (page.hasMore && pages < 20);
setError('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
fetching.current = false;
}
}, [paused]);
useEffect(() => {
void drain();
if (paused) return;
const timer = window.setInterval(() => void drain(), POLL_MS);
return () => window.clearInterval(timer);
}, [drain, paused]);
const filtered = useMemo(() => {
const minimum = RANKS[level] ?? 20;
const needle = search.trim().toLowerCase();
return records.filter(
(event) => (RANKS[event.level] ?? 0) >= minimum && (!needle || haystack(event).includes(needle)),
);
}, [records, level, search]);
const visible = filtered.slice(-DRAW);
useLayoutEffect(() => {
const node = view.current;
if (node && pinned.current) node.scrollTop = node.scrollHeight;
}, [visible.length]);
const onScroll = () => {
const node = view.current;
if (node) pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 50;
};
const exportJson = () => {
const blob = new Blob([JSON.stringify(records, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `memby-events-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
};
return (
<>
<PageHead title="Server logs" intro="Structured gateway events as they happen." />
<Banner message={error} />
<Card>
<div className="filters">
<Field label="Level">
<select value={level} onChange={(event) => setLevel(event.target.value)}>
<option value="DEBUG">Debug and above</option>
<option value="INFO">Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select>
</Field>
<Field label="Filter" grow>
<input
type="search"
value={search}
placeholder="Person, television, title, component, path…"
onChange={(event) => setSearch(event.target.value)}
/>
</Field>
<div className="filter-actions">
<Button onClick={() => setPaused((current) => !current)} icon={paused ? 'play' : 'clock'}>
{paused ? 'Resume' : 'Pause'}
</Button>
<Button
onClick={() => {
setRecords([]);
setDropped(0);
}}
>
Clear view
</Button>
<Button onClick={exportJson} icon="download">
Export JSON
</Button>
</div>
</div>
<div className="logview" ref={view} onScroll={onScroll} role="log" aria-live="polite">
{visible.length === 0 ? (
<p className="empty">{records.length === 0 ? 'Waiting for server events…' : 'No events match this filter.'}</p>
) : (
visible.map((event, index) => (
<div className="logline" key={`${event.occurredAt}:${index}`} data-level={event.level}>
<time>{when(event.occurredAt)}</time>
{/* The level is a class on the line as well as its own column: scrolling a
log is looking for the one line that is not INFO, and a coloured word
four columns in is easy to scroll past. */}
<span className="lvl">{event.level}</span>
<span className="msg">{event.message}</span>
<span className="attrs">
{orderedFields(event.attributes ?? {}).map(([key, value]) => (
<span key={key}>
{' '}
<b>{key}=</b>
{String(value)}
</span>
))}
</span>
</div>
))
)}
</div>
<p className="hint">
{num(records.length)} retained · {num(filtered.length)} matching
{visible.length < filtered.length ? ` · showing the latest ${num(visible.length)}` : ''}
{dropped ? ` · ${num(dropped)} overwritten before delivery` : ''}
{paused ? ' · paused' : ''}
</p>
</Card>
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag } from '../components/ui';
export function MaintenancePage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [message, setMessage] = useState('');
const [confirming, setConfirming] = useState(false);
const [touched, setTouched] = useState(false);
const enabled = Boolean(status?.maintenance?.enabled);
// The poll must never take a half-typed message away mid-edit, which was the one rule
// the previous console's `Admin.fill` existed to enforce. React holds the field's value
// itself, so the equivalent here is simply not to overwrite it once the operator has
// touched it.
useEffect(() => {
if (!touched && status) setMessage(status.maintenance?.message ?? '');
}, [status, touched]);
const set = (next: boolean) =>
run(next ? 'on' : 'off', async () => {
await wrap(
() => api.post('/admin/api/maintenance', { enabled: next, message }),
next ? 'Memby is offline for every television.' : 'Memby is back online.',
);
setConfirming(false);
setTouched(false);
await reload();
});
return (
<>
<PageHead title="Maintenance" intro="Take Memby offline for every television." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Gateway availability"
intro="Takes Memby offline for every television, independently of Emby. Sign-in and all content calls answer 503 with the message below, and the television shows it in place of the launcher rows. This console keeps working."
icon="power"
tone={enabled ? 'bad' : 'warn'}
actions={enabled ? <Tag tone="bad">offline</Tag> : <Tag tone="ok">online</Tag>}
footer={
<>
<Button variant="danger" disabled={enabled} onClick={() => setConfirming(true)}>
Go offline
</Button>
<Button disabled={!enabled} busy={busy === 'off'} onClick={() => void set(false)}>
Bring back online
</Button>
</>
}
>
<Field
label="Message shown on the television"
hint="Say what is happening and when it will be back. It is the only thing the viewer is told."
>
<input
type="text"
value={message}
placeholder="Back shortly — upgrading the server"
onChange={(event) => {
setMessage(event.target.value);
setTouched(true);
}}
/>
</Field>
</Card>
)}
{confirming ? (
<Confirm
title="Take Memby offline?"
body="Every television will stop working immediately and show your message in place of the launcher. This console keeps working."
confirmLabel="Go offline"
destructive
busy={busy === 'on'}
onConfirm={() => void set(true)}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+268
View File
@@ -0,0 +1,268 @@
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { bytes, num, recent, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Grid,
KeyValue,
Loading,
PageHead,
PlainTiles,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import type { RuntimeStatus, ViewsReport } from '../api/types';
/* The page an operator lands on. It answers one question — is anything wrong — and hands
off to the page that can do something about it. Nothing here is editable on purpose:
somewhere that both summarises and changes state is where an accidental click lives. */
export function OverviewPage() {
const { status, error, loading } = useGateway();
// Process statistics are their own endpoint and their own tick: they are the one thing
// here that says nothing about the household and everything about the container.
const runtime = useQuery<RuntimeStatus>('/admin/api/runtime', { pollMs: 30_000 });
const views = useQuery<ViewsReport>('/admin/api/views', { pollMs: 60_000 });
if (loading || !status) {
return (
<>
<PageHead title="Overview" intro="What the gateway is doing right now." />
<Banner message={error} />
<Loading />
</>
);
}
const features = status.features ?? { features: [], revision: 0, safeMode: false };
const featureList = features.features ?? [];
const clients = status.clients ?? [];
const online = clients.filter((client) => recent(client.lastSeen)).length;
const policy = status.updatePolicy ?? ({} as typeof status.updatePolicy);
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const playback = status.playbackPolicy;
const mdblist = status.mdblist;
const forYou = status.forYou;
const runs = (status.runs ?? []).slice(0, 5);
const memory = runtime.data;
return (
<>
<PageHead title="Overview" intro="What the gateway is doing right now." />
<Banner message={error} />
{/* The marks are the areas these numbers belong to, in the tones the rest of the
console uses for them: the library is teal wherever it is counted, a person is
violet, a television is blue. They are the same on the pages these link to. */}
<Tiles
tiles={[
{ label: 'items in the library', value: num(status.library.total), icon: 'library', tone: 'data' },
{
label: 'people signed in',
value: num((status.requestUsers ?? []).length),
icon: 'people',
tone: 'note',
},
{
label: `devices · ${online} active now`,
value: num(clients.length),
icon: 'tv',
tone: 'info',
},
{
label: `visits today · ${views.data?.lastWeek.visits ?? 0} this time last week`,
value: num(views.data?.today.visits),
icon: 'overview',
tone: 'data',
},
{
label: `viewers today · ${views.data?.lastWeek.viewers ?? 0} this time last week`,
value: num(views.data?.today.viewers),
icon: 'people',
tone: 'note',
},
{
label: 'optional features on',
value: `${featureList.filter((feature) => feature.enabled).length} / ${featureList.length}`,
icon: 'sliders',
tone: 'ok',
},
{ label: 'last import', value: when(status.library.lastSynced), small: true, icon: 'clock' },
]}
/>
<Grid cols="2">
<Card
title="What televisions are being told"
intro="The answers the gateway is giving every set right now."
icon="tv"
tone="info"
>
<KeyValue
rows={[
{
label: 'Availability',
value: status.maintenance?.enabled ? (
<Tag tone="bad">offline for maintenance</Tag>
) : (
<Tag tone="ok">online</Tag>
),
},
{
label: 'Feature control plane',
value: features.safeMode ? (
<Tag tone="warn">safe mode · optional features off</Tag>
) : (
<Tag tone="ok">revision r{num(features.revision)}</Tag>
),
},
{
label: 'App update prompt',
value: !policy.enabled ? (
<Tag>off</Tag>
) : (
<Tag tone={required ? 'warn' : 'ok'}>
{required ? 'required · ' : 'optional · '}
{policy.latestVersion}
</Tag>
),
},
{
label: 'Catalogue import',
value: status.syncRunning ? (
<Tag tone="warn">running</Tag>
) : (
<Tag>every {status.syncEvery}</Tag>
),
},
{
label: 'Playback preroll',
value: playback?.prerollEnabled === false ? (
<Tag>off</Tag>
) : (
<Tag tone="ok">{(playback?.prerollDurationMs ?? 6500) / 1000}s</Tag>
),
},
]}
/>
</Card>
<Card
title="Services"
intro="The services this gateway leans on, and whether they answered."
icon="wrench"
tone="note"
>
<KeyValue
rows={[
{
label: 'Movies (Radarr)',
value: status.radarrReady ? <Tag tone="ok">ready</Tag> : <Tag>not configured</Tag>,
},
{
label: 'Series (Sonarr)',
value: status.sonarrReady ? <Tag tone="ok">ready</Tag> : <Tag>not configured</Tag>,
},
{
label: 'MDBList ratings',
value: mdblist?.enabled ? (
<Tag tone="ok">{num(mdblist.cachedTitles)} titles stored</Tag>
) : (
<Tag>{mdblist?.apiKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
),
},
{
label: 'For You pools',
value: status.forYouRunning ? (
<Tag tone="warn">rebuilding</Tag>
) : (
<Tag>{num(forYou?.candidates ?? 0)} ranked candidates</Tag>
),
},
{
label: 'Recommendation profiles',
value: <span className="mono">{num(forYou?.profiles ?? 0)}</span>,
},
]}
/>
</Card>
</Grid>
<Grid cols="2">
<Card
title="Latest imports"
intro="The last few catalogue synchronisations."
icon="sync"
tone="data"
actions={<Link to="/admin/imports">All imports</Link>}
>
<TableWrap>
<table>
<thead>
<tr>
<th>Started</th>
<th>Kind</th>
<th>Status</th>
<th className="num">Written</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<EmptyRow columns={4}>No imports have run yet.</EmptyRow>
) : (
runs.map((run) => (
<tr key={run.id || run.startedAt}>
<td className="nowrap muted">{when(run.startedAt)}</td>
<td>{run.kind}</td>
<td>
<Tag
tone={run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'}
>
{run.status}
</Tag>
</td>
<td className="num">{num(run.itemsUpserted)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="Process"
intro="The container the gateway is served from."
icon="chip"
tone="info"
>
{memory ? (
<>
<PlainTiles
tiles={[
{ label: 'goroutines', value: num(memory.goroutines) },
{ label: 'heap in use', value: bytes(memory.heapInuse) },
{ label: 'reserved', value: bytes(memory.sys) },
{ label: 'collections', value: num(memory.numGc) },
]}
/>
<p className="hint">
Next collection at {bytes(memory.nextGc)} · memory limit{' '}
{memory.memoryLimit > 0 && memory.memoryLimit < Number.MAX_SAFE_INTEGER
? `${bytes(memory.memoryLimit)}${memory.configuredLimit ? ' (GOMEMLIMIT)' : ''}`
: 'no limit set'}{' '}
· {memory.gomaxprocs} processors available.
</p>
</>
) : (
<p className="hint">Reading process statistics</p>
)}
</Card>
</Grid>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
export function PlaybackPage() {
const { status, error, loading, reload } = useGateway();
const { wrap, show } = useToast();
const { busy, run } = useAction();
const [enabled, setEnabled] = useState(true);
const [seconds, setSeconds] = useState('6.5');
const [touched, setTouched] = useState(false);
useEffect(() => {
if (touched || !status) return;
setEnabled(status.playbackPolicy?.prerollEnabled !== false);
setSeconds(String((status.playbackPolicy?.prerollDurationMs ?? 6500) / 1000));
}, [status, touched]);
const save = () =>
run('save', async () => {
const value = Number(seconds);
// Checked here as well as on the server because the server clamps rather than
// refusing, and a value silently corrected to 30 would look like the field not
// having saved.
if (!Number.isFinite(value) || value < 1 || value > 30) {
show('The preroll duration must be between 1 and 30 seconds.', 'bad');
return;
}
await wrap(
() =>
api.post('/admin/api/playback-policy', {
prerollEnabled: enabled,
prerollDurationMs: Math.round(value * 1000),
}),
'Playback policy saved.',
);
setTouched(false);
await reload();
});
return (
<>
<PageHead title="Playback" intro="Presentation policy sent with every playback launch." />
<Banner message={error} />
{loading ? (
<Loading rows={1} />
) : (
<Card
title="Upcoming-show preroll"
intro="Sent with every playback launch. A change applies to the next title opened on every gateway-connected television; no app release is required."
icon="play"
tone="info"
actions={enabled ? <Tag tone="ok">on · {seconds}s</Tag> : <Tag>off</Tag>}
footer={
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save playback policy
</Button>
}
>
<Toggle
label="Show the preroll before a title starts"
checked={enabled}
onChange={(next) => {
setEnabled(next);
setTouched(true);
}}
/>
<div className="fields">
<Field label="Duration" hint="Between 1 and 30 seconds. The stream is already playing behind it.">
<input
type="number"
min={1}
max={30}
step={0.5}
value={seconds}
onChange={(event) => {
setSeconds(event.target.value);
setTouched(true);
}}
/>
</Field>
</div>
</Card>
)}
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num } from '../lib/format';
import {
Banner,
Button,
Card,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
/* Display names for the providers MDBList answers with. An unknown key is shown as itself
rather than hidden — a source the server offers and the console cannot name is still a
source the operator may want on. */
const sourceNames: Record<string, string> = {
imdb: 'IMDb',
tomatoes: 'Rotten Tomatoes',
audience: 'Rotten Tomatoes Audience',
metacritic: 'Metacritic',
letterboxd: 'Letterboxd',
rogerebert: 'Roger Ebert',
tmdb: 'TMDb',
trakt: 'Trakt',
mal: 'MyAnimeList',
anilist: 'AniList',
anidb: 'AniDB',
kitsu: 'Kitsu',
score: 'MDBList Score',
score_average: 'MDBList Average',
};
export function RatingsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [enabled, setEnabled] = useState(false);
const [apiKey, setApiKey] = useState('');
const [clearKey, setClearKey] = useState(false);
const [sources, setSources] = useState<string[]>([]);
const [touched, setTouched] = useState(false);
const mdblist = status?.mdblist;
useEffect(() => {
if (touched || !mdblist) return;
setEnabled(mdblist.enabled);
setSources(mdblist.sources ?? []);
}, [mdblist, touched]);
const save = () =>
run('save', async () => {
await wrap(
() =>
api.post('/admin/api/mdblist-settings', {
enabled,
apiKey: apiKey.trim(),
clearApiKey: clearKey,
sources,
}),
'Ratings settings saved.',
);
// The key field is emptied after a save whether or not one was typed: it is a
// credential, and leaving it on screen is the one thing a shoulder can read.
setApiKey('');
setClearKey(false);
setTouched(false);
await reload();
});
const cached = mdblist?.cachedTitles ?? 0;
return (
<>
<PageHead title="Movie ratings" intro="Optional MDBList scores on films and shows." />
<Banner message={error} />
{loading || !mdblist ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'titles stored', value: num(cached), icon: 'database', tone: 'data' },
{ label: 'due to be re-checked', value: num(mdblist.staleTitles), icon: 'sync', tone: 'warn' },
{ label: 'sources shown', value: num((mdblist.sources ?? []).length), icon: 'star', tone: 'note' },
{
label: 'API key',
value: mdblist.apiKeyConfigured ? 'saved' : 'not set',
small: true,
icon: 'key',
tone: mdblist.apiKeyConfigured ? 'ok' : undefined,
},
]}
/>
<Grid cols="2">
<Card
title="MDBList connection"
intro="The key stays on this server and a failure never blocks a television. Every rating fetched is stored here permanently and re-checked about once a month, so browsing the library costs nothing after the first look at a title."
icon="star"
tone="note"
actions={
enabled ? (
<Tag tone="ok">on · {sources.length} sources</Tag>
) : (
<Tag>{mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
)
}
>
<Toggle
label="Show external ratings on televisions"
hint="Off leaves the stored ratings in place."
checked={enabled}
onChange={(next) => {
setEnabled(next);
setTouched(true);
}}
/>
<Field label="API key" hint="Leave blank to keep the key that is already saved.">
<input
type="password"
autoComplete="new-password"
value={apiKey}
placeholder={mdblist.apiKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key'}
onChange={(event) => setApiKey(event.target.value)}
/>
</Field>
<Toggle label="Remove the saved key" checked={clearKey} onChange={setClearKey} />
</Card>
<Card
title="Sources shown on televisions"
intro="A title with none of these has no ratings strip at all, which is the honest answer — nothing stands in for a score that was never fetched."
icon="list"
tone="data"
>
{(mdblist.availableSources ?? []).length === 0 ? (
<Empty>No rating sources are available.</Empty>
) : (
<div className="checks columns">
{(mdblist.availableSources ?? []).map((source) => (
<Toggle
key={source}
label={sourceNames[source] ?? source}
checked={sources.includes(source)}
onChange={(on) => {
setTouched(true);
setSources((current) =>
on ? [...current, source] : current.filter((entry) => entry !== source),
);
}}
/>
))}
</div>
)}
</Card>
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save ratings settings
</Button>
<span className="hint">
{cached
? 'Ratings are fetched as televisions browse, never on the request path.'
: 'No ratings stored yet. They are saved as televisions browse the library.'}
</span>
</div>
</Card>
</>
)}
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import { Banner, Button, Card, Confirm, Loading, PageHead, Tiles } from '../components/ui';
export function RecommendationsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [confirming, setConfirming] = useState(false);
const forYou = status?.forYou;
const running = Boolean(status?.forYouRunning);
const act = (action: string, key: string, message: string) =>
run(key, async () => {
await wrap(() => api.post('/admin/api/for-you', { action }), message);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="For You" intro="The prepared pools personalised rows are drawn from." />
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Tiles
tiles={[
{ label: 'Tracearr sessions', value: num(forYou?.tracearrSessions ?? 0), icon: 'play', tone: 'info' },
{ label: 'user profiles', value: num(forYou?.profiles ?? 0), icon: 'people', tone: 'note' },
{ label: 'ranked candidates', value: num(forYou?.candidates ?? 0), icon: 'sparkle', tone: 'note' },
{ label: 'last full import', value: when(forYou?.lastFullImport), small: true, icon: 'clock' },
]}
/>
<Card
title="Pool maintenance"
intro="Prepared pools refresh in the background; these are the manual versions of the same work. A rebuild is safe at any time — televisions read the last finished pool until a new one lands."
icon="sparkle"
tone="note"
footer={
<span className="hint">
{running ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.'}
</span>
}
>
<div className="row">
<Button
variant="primary"
icon="download"
disabled={running}
busy={busy === 'import'}
onClick={() => void act('incremental-import', 'import', 'Import started.')}
>
Import recent sessions
</Button>
<Button icon="database" disabled={running} onClick={() => setConfirming(true)}>
Full Tracearr backfill
</Button>
<Button
icon="sync"
disabled={running}
busy={busy === 'rebuild'}
onClick={() => void act('rebuild-all', 'rebuild', 'Rebuild started.')}
>
Rebuild all pools
</Button>
</div>
</Card>
<Card
title="Reading a person's scores"
intro="The inspector re-runs the shared weighted scorer over one person's prepared pool, after Emby permission and parental-control filtering, and shows every component and evidence reason behind the order."
icon="search"
tone="info"
actions={<Link to="/admin/inspector">Open the inspector</Link>}
>
<></>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Backfill all Tracearr history?"
body="Every session is re-read and every active user's pool is rebuilt. It is safe at any time — televisions keep reading the last finished pool — but on a long history it takes a while."
confirmLabel="Backfill"
busy={busy === 'full'}
onConfirm={() => void act('full-import', 'full', 'Backfill started.')}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { when } from '../lib/format';
import { Banner, Button, Card, Empty, Loading, PageHead, TableWrap, Tag } from '../components/ui';
export function RequestsPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const users = status?.requestUsers ?? [];
const allowed = status?.requestPolicy?.allowedUserIds ?? [];
const usage = useMemo(() => new Map((status?.requestUsage ?? []).map((entry) => [entry.userId, entry])), [status?.requestUsage]);
const toggle = (id: string) => run(`access-${id}`, async () => {
const next = allowed.includes(id) ? allowed.filter((entry) => entry !== id) : [...allowed, id];
await wrap(
() => api.post('/admin/api/request-policy', { allowedUserIds: next }),
next.includes(id) ? 'Request access granted.' : 'Request access removed.',
);
await reload();
});
return (
<>
<PageHead title="Media requests" intro="Who can ask for something the library does not have." />
<Banner message={error} />
{loading ? (
<Loading rows={2} />
) : (
<>
<Card
title="Where a request goes"
intro="A movie or series is monitored and searched for immediately. The configured download service handles it from there."
icon="inbox"
tone="info"
actions={
<>
<Tag tone={status?.radarrReady ? 'ok' : 'bad'}>
Movies {status?.radarrReady ? 'ready' : 'not configured'}
</Tag>
<Tag tone={status?.sonarrReady ? 'ok' : 'bad'}>
Series {status?.sonarrReady ? 'ready' : 'not configured'}
</Tag>
</>
}
>
{!status?.radarrReady && !status?.sonarrReady ? (
<Empty>
Neither Radarr nor Sonarr is configured, so a request would have nowhere to go. The
button stays hidden on every television until one of them is.
</Empty>
) : null}
</Card>
<Card
title="Request access and activity"
intro="One button grants or removes access. A recorded request has already been sent to Radarr or Sonarr; Memby does not duplicate their download state."
icon="people"
tone="note"
>
{users.length === 0 ? (
<Empty>No one has signed in yet.</Empty>
) : (
<TableWrap><table><thead><tr><th>User</th><th>Last seen</th><th className="num">Sent to services</th><th>Last request</th><th>Access</th></tr></thead><tbody>
{users.map((user) => { const userUsage = usage.get(user.id); const granted = allowed.includes(user.id); return <tr key={user.id}><td><b>{user.username}</b></td><td className="muted nowrap">{when(user.lastSeen)}</td><td className="num">{userUsage?.requests ?? 0}</td><td className="muted nowrap">{userUsage?.lastRequest ? when(userUsage.lastRequest) : '—'}</td><td><Button size="sm" variant={granted ? 'quiet' : 'primary'} busy={busy === `access-${user.id}`} onClick={() => void toggle(user.id)}>{granted ? 'Remove access' : 'Give access'}</Button></td></tr>; })}
</tbody></table></TableWrap>
)}
</Card>
</>
)}
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useQuery } from '../lib/hooks';
import { num, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Field,
Loading,
PageHead,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
interface SearchTerm {
query: string;
searches: number;
viewers: number;
lastAt: string;
}
interface SearchEvent {
occurredAt: string;
userId: string;
username: string;
query: string;
}
interface SearchesResponse {
days: number;
retentionDays: number;
totals: { searches: number; queries: number; viewers: number };
terms: SearchTerm[] | null;
recent: SearchEvent[] | null;
}
/* Two tables of the same rows on purpose: the summary groups by query and answers "what
does this house look for", which is what a library is organised against; the log is
uncollapsed and newest-first and answers "what happened just now", which is the one to
read when somebody reports that search is not finding something. */
export function SearchesPage() {
const [days, setDays] = useState(7);
const { data, error, loading } = useQuery<SearchesResponse>(`/admin/api/searches?days=${days}`);
const terms = data?.terms ?? [];
const recent = data?.recent ?? [];
const totals = data?.totals;
return (
<>
<PageHead
title="Searches"
intro="What the household has been looking for, and what it searched just now."
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'searches', value: num(totals?.searches ?? 0), icon: 'search', tone: 'info' },
{ label: 'distinct queries', value: num(totals?.queries ?? 0), icon: 'list', tone: 'data' },
{ label: 'viewers searching', value: num(totals?.viewers ?? 0), icon: 'people', tone: 'note' },
// Stated rather than assumed: every figure on this page is bounded by how long
// the table keeps a row, and an operator reading a quiet week has no other way to
// tell a household that stopped searching from one whose history has aged out.
{
label: 'history kept',
value: `${data?.retentionDays ?? 30} days`,
small: true,
icon: 'clock',
},
]}
/>
{loading ? (
<Loading />
) : (
<>
<Card
title="What the house looks for"
intro="Queries the search tab ran, grouped without regard to case and labelled with the most recent spelling. Instant search asks from the second character, so a title typed slowly leaves its prefixes here too."
icon="search"
tone="info"
actions={
<Field label="Window">
<select value={days} onChange={(event) => setDays(Number(event.target.value))}>
<option value={1}>24 hours</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
</select>
</Field>
}
>
<TableWrap>
<table>
<thead>
<tr>
<th>Query</th>
<th className="num">Searches</th>
<th className="num">Viewers</th>
<th>Last searched</th>
</tr>
</thead>
<tbody>
{terms.length === 0 ? (
<EmptyRow columns={4}>Nothing searched in this window.</EmptyRow>
) : (
terms.map((term) => (
<tr key={term.query}>
<td>{term.query}</td>
<td className="num">{num(term.searches)}</td>
<td className="num">{num(term.viewers)}</td>
<td className="muted nowrap">{when(term.lastAt)}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
<Card
title="As it happened"
intro="The log, newest first — the query exactly as it was typed, and who typed it. This is the one to read when somebody says search is not finding something."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th>Viewer</th>
<th>Query</th>
</tr>
</thead>
<tbody>
{recent.length === 0 ? (
<EmptyRow columns={3}>No searches in this window.</EmptyRow>
) : (
recent.map((event, index) => (
<tr key={`${event.occurredAt}:${index}`}>
<td className="muted nowrap">{when(event.occurredAt)}</td>
{/* An unattributed search keeps its row and shows the id: the query
is the point, and a viewer whose sessions have all expired is
still one searcher rather than nobody. */}
<td>
{event.username || <Tag tone="warn">{event.userId || 'unknown'}</Tag>}
</td>
<td>{event.query}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+319
View File
@@ -0,0 +1,319 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Chip,
Confirm,
Empty,
EmptyRow,
Loading,
PageHead,
TableWrap,
Tag,
} from '../components/ui';
/* One person's settings, over time.
*
* The two questions this answers are different and both need the whole page. "What did I
* change and can I undo it" is the table; "why is that television still wrong" is the
* device list above it, because a revision the server wrote is not a revision a set has
* taken. */
interface PreferenceDefinition {
key: string;
name: string;
kind: 'toggle' | 'choice' | 'number' | 'multi' | 'list';
options?: { value: string; label: string }[];
unit?: string;
}
interface Change {
name: string;
before: string;
after: string;
}
interface Revision {
revision: number;
createdAt: string;
author: string;
source: string;
current?: boolean;
initial?: boolean;
restoredFrom?: number;
changes: Change[] | null;
acks: { deviceId: string; deviceName: string }[] | null;
preferences?: Record<string, unknown>;
}
interface HistoryDevice {
name: string;
deviceId: string;
revision: number;
behind: number;
never: boolean;
signedOut: boolean;
ackedAt: string;
lastSeen: string;
clientVersion: string;
}
interface HistoryResponse {
username: string;
saved: boolean;
currentRevision: number;
currentSource: string;
devices: HistoryDevice[] | null;
revisions: Revision[] | null;
catalogue: PreferenceDefinition[] | null;
}
/** The same wording the server puts in a change line, so a document and a diff never
* describe one value two ways. */
function describe(definition: PreferenceDefinition, value: unknown): string {
if (definition.kind === 'toggle') return value ? 'On' : 'Off';
if (definition.kind === 'choice') {
const match = (definition.options ?? []).find((option) => option.value === value);
return match ? match.label : String(value ?? '');
}
if (definition.kind === 'number') {
if (Number(value) === 0 && definition.unit) return 'No limit';
return definition.unit ? `${value} ${definition.unit}` : String(value ?? '');
}
const entries = Array.isArray(value) ? (value as string[]) : [];
if (entries.length === 0) return 'None';
return entries
.map((entry) => (definition.options ?? []).find((option) => option.value === entry)?.label ?? entry)
.join(', ');
}
export function SettingsHistoryPage() {
const { userId = '' } = useParams();
const { wrap } = useToast();
const { busy, run } = useAction();
const base = `/admin/api/accounts/${encodeURIComponent(userId)}`;
const { data, error, loading, reload } = useQuery<HistoryResponse>(`${base}/preferences/history`, {
pollMs: 30_000,
});
// Which revisions have their full document open. Kept in state rather than in the DOM so
// the poll can redraw the table without closing what the operator opened.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [restoring, setRestoring] = useState<number | null>(null);
const name = data?.username || 'this account';
const devices = data?.devices ?? [];
const revisions = data?.revisions ?? [];
const catalogue = data?.catalogue ?? [];
const toggle = (revision: number) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(revision)) next.delete(revision);
else next.add(revision);
return next;
});
const restore = (revision: number) =>
run('restore', async () => {
await wrap(
() => api.post(`${base}/preferences/revisions/${revision}/restore`),
`Restored r${revision}.`,
);
setRestoring(null);
await reload();
});
return (
<>
<PageHead
title="Settings history"
intro={`Every change to ${name}'s synced settings, and which of their televisions has taken it.`}
crumbs={<Link to={`/admin/accounts/${encodeURIComponent(userId)}`}> {name}</Link>}
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Card
title="Where each device has got to"
intro="A set takes a change by fetching it, which it does within a few seconds of being told — so anything still behind is switched off, mid-film, or cannot reach the gateway."
icon="tv"
tone="info"
actions={
data?.saved ? (
<Tag tone={data.currentSource === 'admin' ? 'warn' : 'ok'}>
now on r{num(data.currentRevision)} · {data.currentSource || 'device'}
</Tag>
) : (
<Tag>defaults · never synced</Tag>
)
}
>
{devices.length === 0 ? (
<Empty>No television has been signed in to this account.</Empty>
) : (
<div className="list">
{devices.map((device) => {
const tone = device.never ? undefined : device.behind ? 'bad' : 'ok';
return (
<div className="list-item" key={device.deviceId || device.name}>
<div className="list-body">
<b>
<span className="dot-state" data-tone={tone} />{' '}
{device.name || 'Memby TV'}{' '}
{device.signedOut ? <Tag>signed out</Tag> : null}
</b>
<p>
{device.never
? 'Has not fetched these settings yet'
: `Holding r${num(device.revision)} · taken ${when(device.ackedAt)}`}
{device.clientVersion ? ` · Memby ${device.clientVersion}` : ''}
{device.signedOut ? '' : ` · last seen ${when(device.lastSeen)}`}
</p>
</div>
<div className="list-actions">
{device.never ? (
<Tag>never taken one</Tag>
) : device.behind ? (
<Tag tone="bad">{num(device.behind)} behind</Tag>
) : (
<Tag tone="ok">up to date</Tag>
)}
</div>
</div>
);
})}
</div>
)}
</Card>
<Card
title="Change history"
intro="Restoring puts an earlier version back as a new change, so the televisions notice it and the version it replaced stays here to return to."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th>When</th>
<th className="num">Rev</th>
<th>Changed by</th>
<th>What changed</th>
<th className="num">Taken by</th>
<th />
</tr>
</thead>
<tbody>
{revisions.length === 0 ? (
<EmptyRow columns={6}>Nothing has been changed on this account yet.</EmptyRow>
) : (
revisions.flatMap((revision) => {
const open = expanded.has(revision.revision);
const acks = revision.acks ?? [];
const changes = revision.changes ?? [];
const rows = [
<tr key={revision.revision}>
<td className="nowrap muted">{when(revision.createdAt)}</td>
<td className="num nowrap">
r{num(revision.revision)} {revision.current ? <Tag tone="ok">current</Tag> : null}
</td>
<td className="nowrap">
<Tag tone={revision.source === 'admin' ? 'warn' : 'ok'}>{revision.author}</Tag>
{revision.restoredFrom ? (
<span className="muted"> restored r{num(revision.restoredFrom)}</span>
) : null}
</td>
<td className="muted">
{revision.initial ? (
<span className="muted">First recorded settings</span>
) : changes.length === 0 ? (
// A write that changed nothing an operator can see: a
// television pushing the document it already held, usually.
// Saying so is more useful than an empty cell.
<span className="muted">No visible change</span>
) : (
<div className="chips">
{changes.map((change, index) => (
<Chip key={`${change.name}:${index}`}>
{change.name}: {change.before} {change.after}
</Chip>
))}
</div>
)}
</td>
<td className="num">
{acks.length === 0 ? (
<span className="muted"></span>
) : (
<span title={acks.map((ack) => ack.deviceName || ack.deviceId).join(', ')}>
{num(acks.length)}
</span>
)}
</td>
<td className="num nowrap">
<span className="list-actions">
<Button size="sm" onClick={() => toggle(revision.revision)}>
{open ? 'Hide' : 'Show'}
</Button>
{revision.current ? null : (
<Button size="sm" onClick={() => setRestoring(revision.revision)}>
Restore
</Button>
)}
</span>
</td>
</tr>,
];
if (open) {
// The whole document at one revision, in the catalogue's own order
// and wording. This is what makes a restore a decision rather than
// a guess.
rows.push(
<tr key={`${revision.revision}:detail`}>
<td colSpan={6} className="muted">
<div className="chips">
{catalogue.map((definition) => (
<Chip key={definition.key}>
{definition.name}:{' '}
{describe(definition, revision.preferences?.[definition.key])}
</Chip>
))}
</div>
</td>
</tr>,
);
}
return rows;
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
{restoring !== null ? (
<Confirm
title={`Restore revision ${restoring}?`}
body="It goes out as a new change, so every one of their televisions will pick it up — and the current version stays in this history to return to."
confirmLabel="Restore"
busy={busy === 'restore'}
onConfirm={() => void restore(restoring)}
onCancel={() => setRestoring(null)}
/>
) : null}
</>
);
}
+316
View File
@@ -0,0 +1,316 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { bytes, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
Confirm,
Empty,
Field,
Grid,
Loading,
PageHead,
Tag,
Tiles,
Toggle,
} from '../components/ui';
interface TestResult {
provider: string;
ok: boolean;
message: string;
}
interface Draft {
bazarr: boolean;
openSubtitles: boolean;
key: string;
clearKey: boolean;
username: string;
password: string;
clearLogin: boolean;
}
export function SubtitlesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
const [results, setResults] = useState<TestResult[] | null>(null);
const [confirming, setConfirming] = useState(false);
const subtitles = status?.subtitles;
const stored = subtitles?.stored as { count?: number; bytes?: number; latest?: string } | undefined;
useEffect(() => {
if (draft || !subtitles) return;
setDraft({
bazarr: subtitles.bazarrEnabled,
openSubtitles: subtitles.openSubtitlesEnabled,
key: '',
clearKey: false,
username: subtitles.openSubtitlesUsername ?? '',
password: '',
clearLogin: false,
});
}, [subtitles, draft]);
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
const save = () =>
run('save', async () => {
if (!draft) return;
await wrap(
() =>
api.post('/admin/api/subtitle-settings', {
bazarrEnabled: draft.bazarr,
openSubtitlesEnabled: draft.openSubtitles,
openSubtitlesApiKey: draft.key.trim(),
clearOpenSubtitlesApiKey: draft.clearKey,
openSubtitlesUsername: draft.username.trim(),
openSubtitlesPassword: draft.password,
clearOpenSubtitlesLogin: draft.clearLogin,
}),
'Subtitle settings saved.',
);
// The credential fields are emptied on the way out, so a saved page never has a
// secret sitting in a form somebody could walk past.
setDraft(null);
await reload();
});
const test = () =>
run('test', async () => {
setResults(null);
const answer = await wrap(() =>
api.post<{ results: TestResult[] | null }>('/admin/api/subtitle-test'),
);
setResults(answer?.results ?? []);
});
const clearStored = () =>
run('clear', async () => {
await wrap(
() => api.post('/admin/api/subtitle-settings', { action: 'clear-stored' }),
'Stored subtitles deleted.',
);
setConfirming(false);
await reload();
});
return (
<>
<PageHead title="Subtitles" intro="Which providers a viewer may fetch a missing subtitle from." />
<Banner message={error} />
{loading || !subtitles || !draft ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{
label: 'offered on televisions',
value: subtitles.available ? 'yes' : 'no',
small: true,
icon: 'captions',
tone: subtitles.available ? 'ok' : undefined,
},
{
label: 'providers on',
value: num(
(subtitles.bazarrEnabled && subtitles.bazarrConfigured ? 1 : 0) +
(subtitles.openSubtitlesEnabled ? 1 : 0),
),
icon: 'list',
tone: 'note',
},
{ label: 'subtitles held', value: num(stored?.count ?? 0), icon: 'database', tone: 'data' },
{ label: 'last fetched', value: when(stored?.latest), small: true, icon: 'clock' },
]}
/>
<Grid cols="2">
<Card
title="Bazarr"
intro="Bazarr writes the subtitle file beside the media file, so Emby finds it and the track behaves like one that was always there. Its address is deployment configuration; this switch only decides whether viewers may use it."
icon="wrench"
tone="data"
actions={
!subtitles.bazarrConfigured ? (
<Tag>not configured</Tag>
) : draft.bazarr ? (
<Tag tone="ok">on</Tag>
) : (
<Tag>off</Tag>
)
}
footer={
// The address is worth printing: it is the one thing on this page an
// operator cannot change here, so seeing which Bazarr is meant is how they
// find out it is the wrong one.
<span className="hint">
{subtitles.bazarrConfigured
? `Configured at ${subtitles.bazarrUrl}`
: 'Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr.'}
</span>
}
>
<Toggle
label="Offer Bazarr in the player"
hint="Off leaves every subtitle it has already written in place."
checked={draft.bazarr}
disabled={!subtitles.bazarrConfigured}
onChange={(next) => patch({ bazarr: next })}
/>
</Card>
<Card
title="OpenSubtitles"
intro="OpenSubtitles hands back a file rather than writing one, so Memby keeps what it fetches and serves it to the television itself. Titles are matched on their IMDb or TMDb id, which is exact — there is no guessing at a name."
icon="captions"
tone="note"
actions={
subtitles.openSubtitlesEnabled ? (
<Tag tone={subtitles.openSubtitlesAccount ? 'ok' : 'warn'}>
{subtitles.openSubtitlesAccount ? 'on · signed in' : 'on · anonymous'}
</Tag>
) : (
<Tag>{subtitles.openSubtitlesKeyConfigured ? 'off · key saved' : 'off · no key'}</Tag>
)
}
>
<Toggle
label="Offer OpenSubtitles in the player"
hint="Needs an API key. It cannot be switched on without one."
checked={draft.openSubtitles}
onChange={(next) => patch({ openSubtitles: next })}
/>
<Field
label="API key"
hint="From your consumer at opensubtitles.com. Leave blank to keep the saved key."
>
<input
type="password"
autoComplete="new-password"
value={draft.key}
placeholder={
subtitles.openSubtitlesKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key'
}
onChange={(event) => patch({ key: event.target.value })}
/>
</Field>
<Toggle
label="Remove the saved key"
checked={draft.clearKey}
onChange={(next) => patch({ clearKey: next })}
/>
<div className="fields">
<Field
label="Account username"
hint="Optional, and the difference between a working feature and one that stops after a few files: without an account, downloads come out of the small anonymous allowance."
>
<input
type="text"
autoComplete="off"
value={draft.username}
placeholder="Not signed in"
onChange={(event) => patch({ username: event.target.value })}
/>
</Field>
<Field label="Account password" hint="Leave blank to keep the saved one.">
<input
type="password"
autoComplete="new-password"
value={draft.password}
onChange={(event) => patch({ password: event.target.value })}
/>
</Field>
</div>
<Toggle
label="Sign out and forget the account"
checked={draft.clearLogin}
onChange={(next) => patch({ clearLogin: next })}
/>
</Card>
</Grid>
<Card>
<div className="row">
<Button variant="primary" busy={busy === 'save'} onClick={() => void save()}>
Save subtitle settings
</Button>
<Button busy={busy === 'test'} icon="pulse" onClick={() => void test()}>
Test the providers
</Button>
{/* The feature flag overrides both switches, so a page that stayed silent
about it would be showing two controls that visibly do nothing. */}
<span className="hint">
{subtitles.featureEnabled
? 'A change applies to the next title opened; no app release is required.'
: 'Downloading subtitles is switched off on the Features page, so nothing here is offered.'}
</span>
</div>
{results === null ? null : results.length === 0 ? (
<Empty>No provider is switched on, so there was nothing to ask.</Empty>
) : (
<div className="list">
{results.map((result) => (
<div className="list-item" key={result.provider}>
<div className="list-body">
<b>{result.provider}</b>
<p>{result.message}</p>
</div>
<div className="list-actions">
<Tag tone={result.ok ? 'ok' : 'bad'}>{result.ok ? 'reachable' : 'not reachable'}</Tag>
</div>
</div>
))}
</div>
)}
</Card>
<Card
title="Subtitles Memby is holding"
intro="Only files fetched from a provider that cannot write beside the media file are kept here; they are served to televisions as ordinary tracks on every later playback. Emptying this is safe — each one can be fetched again, at the cost of the download allowance that fetched it."
icon="database"
tone="data"
actions={
stored?.count ? (
<Tag tone="data">
{num(stored.count)} files · {bytes(stored.bytes)}
</Tag>
) : (
<Tag>nothing held</Tag>
)
}
footer={
<Button variant="danger" disabled={!stored?.count} onClick={() => setConfirming(true)}>
Delete every stored subtitle
</Button>
}
>
<></>
</Card>
</>
)}
{confirming ? (
<Confirm
title="Delete every stored subtitle?"
body="Each one can be fetched again, at the cost of the download allowance that fetched it. Subtitles Bazarr wrote beside the media are untouched — those belong to Emby."
confirmLabel="Delete"
destructive
busy={busy === 'clear'}
onConfirm={() => void clearStored()}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useState } from 'react';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, duration, interval, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Loading,
Note,
PageHead,
TableWrap,
Tag,
Tiles,
Toggle,
} from '../components/ui';
import type { ScheduledTask, TaskRun, TasksResponse } from '../api/types';
import type { Tone } from '../lib/format';
/* Scheduled tasks: what the gateway does when nobody is watching.
*
* Polled faster than the console's own heartbeat while a task is running, because the one
* thing an operator does here is press Run now and then watch for the outcome — and a
* thirty-second poll makes a two-second job look like one that did nothing. */
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
function statusTone(status: TaskRun['status']): Tone {
if (status === 'failed') return 'bad';
if (status === 'running') return 'info';
if (status === 'skipped') return 'warn';
return 'ok';
}
export function TasksPage() {
const { wrap } = useToast();
const { busy, run } = useAction();
const [fast, setFast] = useState(false);
const { data, error, loading, reload } = useQuery<TasksResponse>('/admin/api/tasks?limit=60', {
pollMs: fast ? BUSY_POLL_MS : IDLE_POLL_MS,
});
const tasks = data?.tasks ?? [];
const anyRunning = tasks.some((task) => task.running);
if (anyRunning !== fast) setFast(anyRunning);
const runNow = (task: ScheduledTask) =>
run(task.id, async () => {
await wrap(
() => api.post(`/admin/api/tasks/${encodeURIComponent(task.id)}/run`),
`${task.name} started.`,
);
await reload();
});
const setEnabled = (task: ScheduledTask, enabled: boolean) =>
run(`${task.id}:enabled`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { enabled }),
enabled ? `${task.name} switched on.` : `${task.name} switched off.`,
);
await reload();
});
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
const disabled = tasks.filter((task) => !task.enabled).length;
const groups = data?.groups ?? [];
const ungrouped = tasks.filter((task) => !task.group);
return (
<>
<PageHead
title="Scheduled tasks"
intro="The gateway's background work: what it does, when it last ran, how long it took and whether it worked. Every one of these can be started by hand."
/>
<Banner message={error} />
{loading ? (
<Loading />
) : (
<>
<Tiles
tiles={[
{ label: 'Tasks', value: num(tasks.length), icon: 'clock', tone: 'info' },
{
label: 'Running now',
value: num(tasks.filter((task) => task.running).length),
icon: 'pulse',
tone: anyRunning ? 'ok' : undefined,
},
{
label: 'Last run failed',
value: num(failures),
icon: 'alert',
tone: failures > 0 ? 'bad' : undefined,
},
{
label: 'Switched off',
value: num(disabled),
icon: 'power',
tone: disabled > 0 ? 'warn' : undefined,
},
]}
/>
{failures > 0 ? (
<Note tone="bad">
A failed task publishes an administrative event, so the failure is in the activity feed and
wherever your integrations send it you did not have to be looking at this page.
</Note>
) : null}
{[...groups, ...(ungrouped.length > 0 ? [''] : [])].map((group) => {
const inGroup = tasks.filter((task) => task.group === group);
if (inGroup.length === 0) return null;
return (
<Card
key={group || 'other'}
title={group || 'Other'}
icon={group === 'System' ? 'chip' : group === 'Analytics' ? 'chart' : 'wrench'}
tone={group === 'System' ? 'info' : group === 'Analytics' ? 'data' : 'note'}
>
<div className="list">
{inGroup.map((task) => (
<div className="list-item" key={task.id}>
<div className="list-body">
<b>
{task.name}{' '}
{task.running ? <Tag tone="info">running</Tag> : null}
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
</b>
<p>{task.description}</p>
<p className="quiet">
{interval(task.intervalSeconds)}
{task.enabled && task.nextRun ? ` · next ${ago(task.nextRun).replace(' ago', '')}` : ''}
{task.lastRun ? (
<>
{' · last '}
<span title={when(task.lastRun.startedAt)}>{ago(task.lastRun.startedAt)}</span>
{` in ${duration(task.lastRun.durationMs)}`}
{task.lastRun.detail ? `${task.lastRun.detail}` : ''}
</>
) : (
' · never run'
)}
</p>
{task.lastRun?.error ? (
<p className="mono" style={undefined}>
<Tag tone="bad">{task.lastRun.error}</Tag>
</p>
) : null}
</div>
<div className="list-actions">
{task.lastRun ? (
<Tag tone={statusTone(task.lastRun.status)}>{task.lastRun.status}</Tag>
) : (
<Tag>never run</Tag>
)}
<Toggle
label=""
checked={task.enabled}
disabled={busy === `${task.id}:enabled`}
onChange={(next) => void setEnabled(task, next)}
/>
<Button
size="sm"
icon="play"
busy={busy === task.id}
disabled={task.running}
onClick={() => void runNow(task)}
>
Run now
</Button>
</div>
</div>
))}
</div>
</Card>
);
})}
<Card
title="Recent runs"
intro="Every task together and in order, which is what shows two jobs interfering with each other."
icon="history"
tone="note"
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">Started</th>
<th>Task</th>
<th>Trigger</th>
<th>Result</th>
<th className="num">Took</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{(data?.runs.length ?? 0) === 0 ? (
<EmptyRow columns={6}>No task has run yet.</EmptyRow>
) : (
data?.runs.map((entry) => (
<tr key={entry.id}>
<td className="nowrap muted" title={when(entry.startedAt)}>
{ago(entry.startedAt)}
</td>
<td>{tasks.find((task) => task.id === entry.taskId)?.name ?? entry.taskId}</td>
<td className="muted">{entry.trigger}</td>
<td>
<Tag tone={statusTone(entry.status)}>{entry.status}</Tag>
</td>
<td className="num muted">{duration(entry.durationMs)}</td>
<td className="muted">{entry.error || entry.detail || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { useAction } from '../lib/hooks';
import { useGateway } from '../lib/gateway';
import { useToast } from '../lib/toast';
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
interface Draft {
version: string;
url: string;
notes: string;
retireBelow: string;
required: boolean;
destructive: boolean;
}
export function UpdatesPage() {
const { status, error, loading, reload } = useGateway();
const { wrap } = useToast();
const { busy, run } = useAction();
const [draft, setDraft] = useState<Draft | null>(null);
const [confirming, setConfirming] = useState(false);
const policy = status?.updatePolicy;
useEffect(() => {
if (draft || !policy) return;
// "Required" is not a field of its own: it is the minimum and the latest being the
// same version, which is what the client compares against. Same for the destructive
// floor, which is the retire-below version having caught up with the latest.
setDraft({
version: policy.latestVersion ?? '',
url: policy.downloadUrl ?? '',
notes: policy.notes ?? '',
retireBelow: policy.retireBelowVersion ?? '',
required: Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion,
destructive:
Boolean(policy.retireBelowVersion) && policy.retireBelowVersion === policy.latestVersion,
});
}, [policy, draft]);
const save = (enabled: boolean) =>
run(enabled ? 'save' : 'off', async () => {
if (!draft) return;
await wrap(
() =>
api.post('/admin/api/update-policy', {
enabled,
latestVersion: draft.version.trim(),
downloadUrl: draft.url.trim(),
notes: draft.notes.trim(),
required: draft.required,
destructive: draft.destructive,
retireBelowVersion: draft.retireBelow.trim(),
}),
enabled ? 'Update policy saved.' : 'Update prompts turned off.',
);
setConfirming(false);
setDraft(null);
await reload();
});
const required = Boolean(policy?.minimumVersion) && policy?.minimumVersion === policy?.latestVersion;
const destructive =
Boolean(policy?.retireBelowVersion) && policy?.retireBelowVersion === policy?.latestVersion;
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
return (
<>
<PageHead title="App updates" intro="Publish an optional or a required client update." />
<Banner message={error} />
{loading || !draft ? (
<Loading rows={1} />
) : (
<Card
title="Update policy"
intro="Televisions check on every launch. An optional update is a prompt the viewer can dismiss; a required one covers the home screen until they update, so it needs a download URL that actually works."
icon="download"
tone="info"
actions={
!policy?.enabled ? (
<Tag>off</Tag>
) : (
<Tag tone={required ? 'warn' : 'ok'}>
{destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · '}
{policy.latestVersion}
</Tag>
)
}
footer={
<>
<Button
variant="primary"
busy={busy === 'save'}
onClick={() => (draft.required ? setConfirming(true) : void save(true))}
>
Save policy
</Button>
<Button busy={busy === 'off'} onClick={() => void save(false)}>
Turn prompts off
</Button>
</>
}
>
<div className="fields">
<Field label="Latest version">
<input
type="text"
value={draft.version}
placeholder="0.2.63"
onChange={(event) => patch({ version: event.target.value })}
/>
</Field>
<Field label="APK URL">
<input
type="text"
value={draft.url}
placeholder="https://nas/memby/memby-0.2.63.apk"
onChange={(event) => patch({ url: event.target.value })}
/>
</Field>
</div>
<Field label="What's new" hint="Shown on the television above the update button.">
<input
type="text"
value={draft.notes}
placeholder="One line the viewer reads"
onChange={(event) => patch({ notes: event.target.value })}
/>
</Field>
<Field
label="Sign out builds below"
hint="The destructive compatibility floor. Leave blank to keep every supported viewer signed in."
>
<input
type="text"
value={draft.retireBelow}
placeholder="0.2.44"
onChange={(event) => patch({ retireBelow: event.target.value })}
/>
</Field>
<Toggle
label="Require this update"
hint="Blocks the home screen on every television below this version."
checked={draft.required}
onChange={(next) => patch({ required: next })}
/>
<Toggle
label="Set the destructive floor to this update"
hint="Deletes sessions on every older television when it next uses Memby, then shows the required update screen."
checked={draft.destructive}
onChange={(next) =>
// Turning it on implies requiring the update and sets the floor to it; turning
// it off clears the floor only if it was this version, so a floor typed by
// hand is not thrown away by an unrelated toggle.
patch(
next
? { destructive: true, required: true, retireBelow: draft.version.trim() }
: {
destructive: false,
retireBelow:
draft.retireBelow.trim() === draft.version.trim() ? '' : draft.retireBelow,
},
)
}
/>
</Card>
)}
{confirming && draft ? (
<Confirm
title={draft.destructive ? 'Sign every older television out?' : 'Require this update?'}
body={
draft.destructive
? 'This deletes sessions on every older television and forces viewers to sign in again after updating.'
: 'Required updates block the home screen on every television below this version until they update.'
}
confirmLabel="Publish"
destructive={draft.destructive}
busy={busy === 'save'}
onConfirm={() => void save(true)}
onCancel={() => setConfirming(false)}
/>
) : null}
</>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useQuery } from '../lib/hooks';
import { num } from '../lib/format';
import { Banner, Card, EmptyRow, Loading, PageHead, TableWrap, Tiles } from '../components/ui';
import type { ViewsReport } from '../api/types';
function change(today: number, previous: number): string {
if (previous === 0) return today > 0 ? 'new this week' : 'no change';
const amount = Math.round(((today - previous) / previous) * 100);
return `${amount > 0 ? '+' : ''}${amount}% vs last week`;
}
export function ViewsPage() {
const { data, error, loading } = useQuery<ViewsReport>('/admin/api/views', { pollMs: 60_000 });
const daily = data?.daily ?? [];
const hourly = data?.hourly ?? [];
return (
<>
<PageHead title="Views" intro="How often people reach Membys home screen. This measures app use, not playback streams." />
<Banner message={error} />
{loading ? <Loading /> : (
<>
<Tiles tiles={[
{ label: change(data?.today.visits ?? 0, data?.lastWeek.visits ?? 0), value: num(data?.today.visits), icon: 'overview', tone: 'data' },
{ label: change(data?.today.viewers ?? 0, data?.lastWeek.viewers ?? 0), value: num(data?.today.viewers), icon: 'people', tone: 'note' },
{ label: 'busiest time today', value: data?.busiestHour || '—', small: true, icon: 'clock', tone: 'info' },
]} />
<Card title="Visits by day" intro="One visit is a signed-in home-screen opening. Viewers are distinct household profiles." icon="chart" tone="data">
<TableWrap><table><thead><tr><th>Day</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{daily.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet.</EmptyRow> : daily.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap>
</Card>
<Card title="Today by hour" intro="Local New Zealand time. Use this to see when the household is opening Memby." icon="clock" tone="info">
<TableWrap><table><thead><tr><th>Hour</th><th className="num">Visits</th><th className="num">Viewers</th></tr></thead><tbody>
{hourly.length === 0 ? <EmptyRow columns={3}>No home-screen visits yet today.</EmptyRow> : hourly.map((row) => <tr key={row.label}><td>{row.label}</td><td className="num">{num(row.visits)}</td><td className="num">{num(row.viewers)}</td></tr>)}
</tbody></table></TableWrap>
</Card>
</>
)}
</>
);
}
File diff suppressed because it is too large Load Diff