0.1.38 gateway
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 user’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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, ' ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 Memby’s 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 Memby’s 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
Reference in New Issue
Block a user