This commit is contained in:
ponzischeme89
2026-08-24 22:56:46 +12:00
parent 4f95767e2f
commit 396d35e2f5
48 changed files with 1541 additions and 672 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,9 +13,9 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-D2yWw-VA.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BNwLRlCd.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-BIMcejkS.css">
<link rel="stylesheet" crossorigin href="/admin/assets/index-CtoC2zbP.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -4,6 +4,7 @@ import { GatewayProvider } from './lib/gateway';
import { NotificationProvider } from './lib/notifications';
import { ToastProvider } from './lib/toast';
import { PageHead } from './components/ui';
import { ReconnectOverlay } from './components/ReconnectOverlay';
import { OverviewPage } from './pages/Overview';
import { ActivityPage } from './pages/Activity';
@@ -125,6 +126,7 @@ export function App() {
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
<ReconnectOverlay />
</ToastProvider>
</NotificationProvider>
</GatewayProvider>
+34 -8
View File
@@ -7,17 +7,28 @@
* by reloading into the gateway's own sign-in form rather than by anything here.
*/
import { reportTemporaryAvailabilityFailure } from '../lib/availability';
/** 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,
readonly temporaryAvailability = false,
) {
super(message);
this.name = 'ApiError';
}
}
function isTemporaryStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
export function isTemporaryAvailabilityError(error: unknown): boolean {
return error instanceof ApiError && error.temporaryAvailability;
}
/* 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;
@@ -48,14 +59,25 @@ function reauthenticate(): boolean {
}
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 ?? {}),
},
});
let response: Response;
try {
response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(operatorPresent() ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers ?? {}),
},
});
} catch (error) {
// Fetch reports a refused connection, DNS failure, or broken proxy as TypeError.
// Keep that implementation detail out of the UI and begin the one global recovery loop.
if (error instanceof TypeError) {
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', 0, true);
}
throw error;
}
if (response.status === 401) {
throw new ApiError(
reauthenticate()
@@ -65,6 +87,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
);
}
if (!response.ok) {
if (isTemporaryStatus(response.status)) {
reportTemporaryAvailabilityFailure();
throw new ApiError('The server is temporarily unavailable.', response.status, true);
}
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new ApiError(body.error ?? `Request failed (${response.status})`, response.status);
}
@@ -0,0 +1,17 @@
import { useAvailability } from '../lib/availability';
export function ReconnectOverlay() {
const { reconnecting, attempt } = useAvailability();
if (!reconnecting) return null;
return (
<div className="reconnect-overlay" role="status" aria-live="polite" aria-label="Server updating">
<div className="reconnect-panel">
<span className="reconnect-spinner" aria-hidden="true" />
<h1>Server Updating...</h1>
<p>The backend is restarting or temporarily unavailable. Your Admin UI will reconnect automatically.</p>
<small>Attempting connection... Attempt {attempt}</small>
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
export interface AvailabilityState {
reconnecting: boolean;
attempt: number;
}
type StateListener = (state: AvailabilityState) => void;
type RecoveryListener = () => void;
const RETRY_MS = 2500;
const HEALTH_TIMEOUT_MS = 2000;
const listeners = new Set<StateListener>();
const recoveryListeners = new Set<RecoveryListener>();
let state: AvailabilityState = { reconnecting: false, attempt: 0 };
let loopRunning = false;
let retryTimer: number | undefined;
let healthRequest: AbortController | undefined;
function publish(next: AvailabilityState) {
state = next;
listeners.forEach((listener) => listener(state));
}
function waitForRetry(): Promise<void> {
return new Promise((resolve) => {
retryTimer = window.setTimeout(() => {
retryTimer = undefined;
resolve();
}, RETRY_MS);
});
}
async function reconnect() {
if (loopRunning) return;
loopRunning = true;
try {
while (state.reconnecting) {
const attempt = state.attempt + 1;
publish({ reconnecting: true, attempt });
healthRequest = new AbortController();
const timeout = window.setTimeout(() => healthRequest?.abort(), HEALTH_TIMEOUT_MS);
try {
const response = await fetch('/healthz', {
cache: 'no-store',
credentials: 'same-origin',
signal: healthRequest.signal,
});
if (response.ok) {
publish({ reconnecting: false, attempt: 0 });
recoveryListeners.forEach((listener) => listener());
break;
}
} catch {
// The next attempt is the recovery path. Health failures are deliberately silent.
} finally {
window.clearTimeout(timeout);
healthRequest = undefined;
}
if (state.reconnecting) await waitForRetry();
}
} finally {
loopRunning = false;
window.clearTimeout(retryTimer);
retryTimer = undefined;
healthRequest = undefined;
}
}
export function reportTemporaryAvailabilityFailure(): void {
if (!state.reconnecting) publish({ reconnecting: true, attempt: 0 });
void reconnect();
}
export function subscribeAvailability(listener: StateListener): () => void {
listeners.add(listener);
listener(state);
return () => listeners.delete(listener);
}
export function subscribeToRecovery(listener: RecoveryListener): () => void {
recoveryListeners.add(listener);
return () => recoveryListeners.delete(listener);
}
export function useAvailability(): AvailabilityState {
const [current, setCurrent] = useState(state);
useEffect(() => subscribeAvailability(setCurrent), []);
return current;
}
+3
View File
@@ -10,6 +10,7 @@ import {
} from 'react';
import { api } from '../api/client';
import type { AdminStatus } from '../api/types';
import { subscribeToRecovery } from './availability';
/* /admin/api/status is the console's shared heartbeat.
*
@@ -101,6 +102,8 @@ export function GatewayProvider({ children }: { children: ReactNode }) {
};
}, [reload]);
useEffect(() => subscribeToRecovery(() => void reload()), [reload]);
const value = useMemo<GatewayState>(
() => ({
status,
+3
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import { subscribeToRecovery } from './availability';
/* The two hooks every page is built from.
*
@@ -77,6 +78,8 @@ export function useQuery<T>(path: string, options: QueryOptions = {}): Loadable<
};
}, [run]);
useEffect(() => subscribeToRecovery(() => void run()), [run]);
useEffect(() => {
if (!pollMs || !enabled) return;
let timer: number | undefined;
+3
View File
@@ -11,6 +11,7 @@ import {
import { api } from '../api/client';
import type { Tone } from './format';
import type { IconName } from '../components/Icon';
import { subscribeToRecovery } from './availability';
/* The administrative feed, shared by the bell in the top bar and the activity page.
*
@@ -117,6 +118,8 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
void reload();
}, [reload]);
useEffect(() => subscribeToRecovery(() => void reload()), [reload]);
useEffect(() => {
let source: EventSource | null = null;
let fallback: number | undefined;
+2
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { Icon } from '../components/Icon';
import type { Tone } from './format';
import { isTemporaryAvailabilityError } from '../api/client';
/* Toasts say what a mutation did.
*
@@ -51,6 +52,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
if (success) show(success, 'ok');
return result;
} catch (err) {
if (isTemporaryAvailabilityError(err)) return undefined;
show(err instanceof Error ? err.message : String(err), 'bad');
return undefined;
}
+63 -200
View File
@@ -1,216 +1,79 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '../lib/hooks';
import { api } from '../api/client';
import { useAction, useQuery } from '../lib/hooks';
import { useToast } from '../lib/toast';
import { ago, initials, num, presence, recent, watchTime, when } from '../lib/format';
import {
Banner,
Card,
EmptyRow,
Loading,
PageHead,
TableWrap,
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. */
/* Watch time comes from Tracearr, and `matched` is the field that matters: a household
running no Tracearr, and a person Tracearr has never seen, both arrive as zeroes. Drawing
those as "0 min this week" would have an operator asking why somebody stopped watching
when the real answer is that nothing was asked. */
interface WatchTime {
matched: boolean;
tracearrUsername?: string;
weekMs: number;
monthMs: number;
totalMs: number;
weekSessions: number;
monthSessions: number;
lastWatchedAt?: string;
}
import { Banner, Button, Card, Confirm, EmptyRow, Loading, PageHead, TableWrap, Tag, Tiles, Toggle } from '../components/ui';
interface Device { id: string; name: string; version: string; lastSeen: string; lastIp?: string; }
interface WatchTime { matched: boolean; weekMs: number; monthMs: number; }
interface Account {
id: string;
username: string;
initials: string;
shortName: string;
lastSeen: string;
devices: KnownClient[] | null;
recommendations?: { prompted?: boolean; completed?: boolean };
watchTime?: WatchTime;
id: string; username: string; initials: string; shortName: string; lastSeen: string; devices: Device[] | null;
enabled: boolean; lastIp?: string; notifications: { enabled: boolean; [key: string]: unknown }; watchTime?: WatchTime;
}
interface AccountsResponse { accounts: Account[] | null; }
interface StatusResponse { updatePolicy?: { latestVersion?: string }; }
interface AccountsResponse {
accounts: Account[] | null;
}
/** seenAt is a timestamp as a number, with anything unreadable sorting last rather than
* first an invalid date yields NaN, and NaN comparisons would scatter those rows. */
function seenAt(value: string | undefined): number {
const at = value ? new Date(value).getTime() : 0;
return Number.isFinite(at) ? at : 0;
}
/** A dash, and it says why on hover. Watch time comes from Tracearr; a household running
* none and a person it has never matched are both "not measured", never "none". */
function NotMeasured() {
return (
<span className="muted" title="No Tracearr sessions matched to this person">
</span>
);
}
function seenAt(value: string | undefined): number { const at = value ? new Date(value).getTime() : 0; return Number.isFinite(at) ? at : 0; }
function NotMeasured() { return <span className="muted" title="No Tracearr sessions matched to this person"></span>; }
export function AccountsPage() {
const { data, error, loading } = useQuery<AccountsResponse>('/admin/api/accounts', {
pollMs: 60_000,
});
const { data, error, loading, reload } = useQuery<AccountsResponse>('/admin/api/accounts', { pollMs: 60_000 });
const { data: status } = useQuery<StatusResponse>('/admin/api/status', { pollMs: 60_000 });
const { busy, run } = useAction();
const { wrap } = useToast();
const [expanded, setExpanded] = useState<string | null>(null);
const [confirm, setConfirm] = useState<Account | null>(null);
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;
/* Summed from the same rows the list draws, so the tile and the column underneath it can
never disagree a separate total query is how those two come apart. */
const latest = status?.updatePolicy?.latestVersion ?? '';
const current = (version: string) => Boolean(version && latest && version === latest);
const needsUpdate = devices.filter((device) => device.version && latest && device.version !== latest).length;
const tracked = accounts.filter((account) => account.watchTime?.matched);
const weekMs = tracked.reduce((total, account) => total + (account.watchTime?.weekMs ?? 0), 0);
const rows = [...accounts].sort((a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen));
const path = (id: string, suffix: string) => `/admin/api/accounts/${encodeURIComponent(id)}${suffix}`;
/* Most recently seen first, so the table means something without being sorted. Whoever
is using Memby right now is the row an operator opening this page is looking for, and a
person who has never signed in from a device sorts to the bottom rather than the top. */
const rows = [...accounts].sort(
(a, b) => seenAt(b.lastSeen) - seenAt(a.lastSeen),
);
const saveNotifications = async (account: Account, enabled: boolean) => run(`notifications-${account.id}`, () => wrap(() => api.put(path(account.id, '/notifications'), { ...account.notifications, enabled }), `Notifications ${enabled ? 'enabled' : 'disabled'} for ${account.username}`).then(() => reload()));
const saveEnabled = async (account: Account) => { const enabled = !account.enabled; await run(`enabled-${account.id}`, () => wrap(() => api.put(path(account.id, '/enabled'), { enabled }), `${account.username} ${enabled ? 'enabled' : 'disabled'}`).then(() => { setConfirm(null); return reload(); })); };
const forceUpdate = async (account: Account) => run(`update-${account.id}`, () => wrap(() => api.post(path(account.id, '/force-update')), `Update request queued for ${account.username}`));
return (
<>
<PageHead title="Users" intro="Who uses Memby, and which devices they are signed in to." />
<Banner message={error} />
{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 15 mins',
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' },
...(tracked.length
? [
{
label: 'watch time this week',
value: watchTime(weekMs),
icon: 'pulse' as const,
tone: 'data' as const,
},
]
: []),
]}
/>
<Card title="Users" icon="people" tone="note">
<TableWrap>
<table>
<thead>
<tr>
<th>User</th>
<th>Short name</th>
<th className="num">Devices</th>
<th className="num">This week</th>
<th className="num">This month</th>
<th>Recommendations</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<EmptyRow columns={7}>
No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.
</EmptyRow>
) : (
rows.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);
const watched = account.watchTime;
return (
<tr key={account.id}>
<td>
<span className="row tight">
<span className="dot-state" data-tone={seen.tone} title={seen.label} />
<span className="avatar">{account.initials || initials(account.username)}</span>
<Link
className="table-row-link"
to={`/admin/accounts/${encodeURIComponent(account.id)}`}
>
{account.username || 'Unnamed user'}
</Link>
</span>
</td>
{/* Blank is the ordinary state and not a gap: the launcher greets
somebody by their account name unless an operator has given
Memby a friendlier one, and saying so beats a bare dash. */}
<td>
{account.shortName || (
<span className="muted" title="Memby greets them by their account name">
account name
</span>
)}
</td>
<td className="num">
{num(list.length)}
{/* Only where there is something to say. A sub-line under every
row reading "0 active now" is a column of noise. */}
{active ? <span className="table-sub">{num(active)} active now</span> : null}
</td>
{/* The month sits beside the week because a quiet week only means
something next to the month around it. Both are a dash rather
than a zero for somebody Tracearr has never seen: "0 min" would
have an operator investigating a person when the real answer is
that nothing was ever asked. */}
<td className="num">
{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}
</td>
<td className="num muted">
{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}
</td>
<td>
<Tag tone={state.tone}>{state.label}</Tag>
</td>
<td className="nowrap muted" title={when(account.lastSeen)}>
{ago(account.lastSeen)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
)}
</>
);
return <>
<PageHead title="Users" intro="Manage Memby users and signed-in devices at a glance." />
<Banner message={error} />
{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 15 mins', value: num(devices.filter((device) => recent(device.lastSeen)).length), icon: 'pulse', tone: 'ok' },
{ label: 'devices up to date', value: num(devices.filter((device) => current(device.version)).length), icon: 'check', tone: 'ok' },
{ label: 'devices requiring update', value: num(needsUpdate), icon: 'alert', tone: needsUpdate ? 'warn' : 'note' },
...(tracked.length ? [{ label: 'watch time this week', value: watchTime(weekMs), icon: 'pulse' as const, tone: 'data' as const }] : []),
]} />
<Card title="Users" icon="people" tone="note"><TableWrap><table><thead><tr>
<th>User</th><th className="num">Devices</th><th>Version</th><th>Remote IP</th><th className="num">This week</th><th className="num">This month</th><th>Last seen</th><th>Notifications</th><th>Enabled</th><th>Actions</th>
</tr></thead><tbody>
{rows.length === 0 ? <EmptyRow columns={10}>No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.</EmptyRow> : rows.map((account) => {
const list = account.devices ?? []; const seen = presence(account.lastSeen); const watched = account.watchTime; const open = expanded === account.id; const first = list[0];
return <>
<tr key={account.id} className={!account.enabled ? 'is-disabled' : undefined}>
<td><span className="row tight"><span className="dot-state" data-tone={seen.tone} title={seen.label} /><span className="avatar">{account.initials || initials(account.username)}</span><Link className="table-row-link" to={`/admin/accounts/${encodeURIComponent(account.id)}`}>{account.username || 'Unnamed user'}</Link></span></td>
<td className="num"><button type="button" className="link-button" onClick={() => setExpanded(open ? null : account.id)}>{num(list.length)}</button></td>
<td><span title={first?.version ? `Latest recorded version on ${first.name}` : 'Version not reported'}>{first?.version || <span className="muted">Unknown</span>}</span>{first?.version && current(first.version) ? <Tag tone="ok">current</Tag> : first?.version && latest ? <Tag tone="warn">update</Tag> : null}{list.length > 1 ? <span className="table-sub">{list.length - 1} more device{list.length === 2 ? '' : 's'}</span> : null}</td>
<td className="mono" title="Last recorded IP address">{first?.lastIp || account.lastIp || <span className="muted"></span>}</td>
<td className="num">{watched?.matched ? watchTime(watched.weekMs) : <NotMeasured />}</td><td className="num muted">{watched?.matched ? watchTime(watched.monthMs) : <NotMeasured />}</td>
<td className="nowrap muted" title={when(account.lastSeen)}>{ago(account.lastSeen)}</td>
<td><Toggle label={account.notifications?.enabled ? 'ON' : 'OFF'} checked={Boolean(account.notifications?.enabled)} disabled={busy === `notifications-${account.id}`} onChange={(next) => void saveNotifications(account, next)} /></td>
<td><Toggle label={account.enabled ? 'Enabled' : 'Disabled'} checked={account.enabled} disabled={busy === `enabled-${account.id}`} onChange={() => setConfirm(account)} /></td>
<td><Button size="sm" variant="quiet" busy={busy === `update-${account.id}`} onClick={() => void forceUpdate(account)}>Force update</Button></td>
</tr>
{open ? <tr key={`${account.id}-devices`} className="account-device-detail"><td colSpan={10}><div className="device-breakdown">{list.map((device) => <div className="device-breakdown-row" key={device.id}><b>{device.name || 'Unnamed device'}</b><span>{device.version || 'Version unknown'}</span><span className="mono" title="Last recorded IP address">{device.lastIp || 'IP not recorded'}</span><span className="muted">last seen {ago(device.lastSeen)}</span></div>)}</div></td></tr> : null}
</>;
})}
</tbody></table></TableWrap></Card>
</>}
{confirm ? <Confirm title={`${confirm.enabled ? 'Disable' : 'Enable'} ${confirm.username}?`} body={confirm.enabled ? 'This will stop their Memby sessions from accessing the gateway. Their Emby account is not changed.' : 'This will restore their access to Memby.'} confirmLabel={confirm.enabled ? 'Disable user' : 'Enable user'} destructive={confirm.enabled} busy={busy === `enabled-${confirm.id}`} onCancel={() => setConfirm(null)} onConfirm={() => void saveEnabled(confirm)} /> : null}
</>;
}
/** The account page reads the same list to find the person it is about. */
export type { Account, AccountsResponse };
+19 -3
View File
@@ -2,9 +2,9 @@ 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 { num, when } from '../lib/format';
import { Icon } from '../components/Icon';
import { Banner, Card, Loading, PageHead, Tag } from '../components/ui';
import { Banner, Card, Loading, PageHead, TableWrap, Tag } from '../components/ui';
interface JourneyEvent {
journeyId: string;
@@ -25,12 +25,20 @@ interface JourneyEvent {
interface JourneyResponse {
users: { userId: string; username: string }[] | null;
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
events: JourneyEvent[] | null;
}
const label = (value: string | undefined | null) => {
const text = String(value || '—').replaceAll('_', ' ');
return text === 'favorites' ? 'Favourites' : text;
const labels: Record<string, string> = {
favorites: 'Favourites', continue_watching: 'Continue Watching', recommendation: 'Recommendations',
for_you: 'Recommendations', screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected',
request: 'Requested', start: 'Started', stop: 'Stopped', complete: 'Completed', text: 'Text Search',
voice: 'Voice Search', journey_start: 'Started Session', journey_end: 'Ended Session',
};
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
};
const place = (event: JourneyEvent | undefined) => label(event?.target || event?.screen || event?.source || event?.feature);
@@ -104,10 +112,18 @@ export function JourneyViewerPage() {
.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}` })));
const featureUsage = data?.featureUsage ?? [];
const featureBreakdown = data?.featureBreakdown ?? [];
return <>
<PageHead title={`${username}'s journeys`} intro="Each app session is shown as the viewing journeys it contains: entry, selection, playback outcome." icon="journey" crumbs={<Link className="crumb" to="/admin/journeys">Journeys</Link>} />
<Banner message={error} />
{!loading && <Card title="Feature usage summary" intro="This viewers feature use before the detailed journeys below." icon="chart" tone="data">
<TableWrap><table><thead><tr><th>Feature</th><th className="num">Uses</th><th className="num">Visits</th><th>Last used</th></tr></thead><tbody>
{featureUsage.length === 0 ? <tr><td colSpan={4} className="empty">No feature usage recorded for this viewer.</td></tr> : featureUsage.map((feature) => <tr key={feature.feature}><td><b>{label(feature.feature)}</b><span className="table-sub">{num(feature.users)} viewer</span></td><td className="num">{num(feature.uses)}</td><td className="num">{num(feature.journeys)}</td><td>{when(feature.lastUsedAt)}</td></tr>)}
</tbody></table></TableWrap>
{featureBreakdown.length > 0 && <p className="table-sub">Breakdown: {featureBreakdown.slice(0, 8).map((entry) => `${label(entry.feature)} · ${label(entry.subFeature)} · ${label(entry.action)} (${num(entry.uses)})`).join(' · ')}</p>}
</Card>}
{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) => {
+52 -83
View File
@@ -2,7 +2,7 @@ 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 { duration, num, percent } from '../lib/format';
import {
Banner,
Card,
@@ -13,30 +13,24 @@ import {
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', 'magic_movie', '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;
const labels: Record<string, string> = {
favorites: 'Favourites', continue: 'Continue Watching', continue_watching: 'Continue Watching',
recent_searches: 'Recent Searches', genre_browse: 'Genre Browse', for_you: 'Recommendations',
recommendation: 'Recommendations', for_you_time: 'Recommendation Timing', my_shows: 'My Shows',
magic_movie: 'Magic Movie', detail_page: 'Details', home_hero: 'Home Hero',
screen_view: 'Viewed', open: 'Opened', close: 'Closed', select: 'Selected', request: 'Requested',
start: 'Started', stop: 'Stopped', complete: 'Completed', change: 'Changed', toggle: 'Toggled',
submit: 'Submitted', journey_start: 'Started Session', journey_end: 'Ended Session',
abandoned: 'Abandoned / interrupted', text: 'Text Search', voice: 'Voice Search',
};
return labels[String(value || '')] ?? text.replace(/\b\w/g, (character) => character.toUpperCase());
}
interface JourneysResponse {
@@ -55,6 +49,8 @@ interface JourneysResponse {
};
users: { userId: string; username: string }[] | null;
features: { feature: string; uses: number; lastUsedAt: string }[] | null;
featureUsage: { feature: string; users: number; uses: number; journeys: number; activeUserRate: number; lastUsedAt: string }[] | null;
featureBreakdown: { feature: string; subFeature: string; action: string; users: number; uses: number; journeys: number; lastUsedAt: string }[] | null;
actions: { category: string; action: string; events: number; journeys: number }[] | null;
paths: { from: string; to: string; count: number }[] | null;
}
@@ -68,25 +64,11 @@ export function JourneysPage() {
const stats = data?.stats;
const users = data?.users ?? [];
const actions = data?.actions ?? [];
const featureUsage = data?.featureUsage ?? [];
const featureBreakdown = data?.featureBreakdown ?? [];
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".
@@ -182,8 +164,8 @@ export function JourneysPage() {
<Grid cols="2">
<Card
title="What people do"
intro="Actions show total use and how many separate visits included them."
title="Feature usage"
intro="Distinct viewers, total uses and visits containing each feature."
icon="chart"
tone="info"
>
@@ -191,23 +173,24 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Action</th>
<th>Feature</th>
<th className="num">Users</th>
<th className="num">Uses</th>
<th className="num">Visits</th>
<th className="num">Active users</th>
</tr>
</thead>
<tbody>
{actions.length === 0 ? (
<EmptyRow columns={3}>No significant actions in this window.</EmptyRow>
{featureUsage.length === 0 ? (
<EmptyRow columns={5}>No feature usage 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>
featureUsage.map((feature) => (
<tr key={feature.feature}>
<td><b>{label(feature.feature)}</b></td>
<td className="num">{num(feature.users)}</td>
<td className="num">{num(feature.uses)}</td>
<td className="num">{num(feature.journeys)}</td>
<td className="num">{percent(feature.activeUserRate)}</td>
</tr>
))
)}
@@ -217,8 +200,8 @@ export function JourneysPage() {
</Card>
<Card
title="Where people go"
intro="The most common steps between screens, including where quiet visits ended."
title="Feature and action detail"
intro="Structured sub-features show which parts of a feature were used."
icon="list"
tone="note"
>
@@ -226,20 +209,24 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Route</th>
<th>Feature / sub-feature</th>
<th>Action</th>
<th className="num">Users</th>
<th className="num">Times</th>
<th className="num">Visits</th>
</tr>
</thead>
<tbody>
{paths.length === 0 ? (
<EmptyRow columns={2}>No repeated paths in this window.</EmptyRow>
{featureBreakdown.length === 0 ? (
<EmptyRow columns={5}>No feature details 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>
featureBreakdown.slice(0, 30).map((entry, index) => (
<tr key={`${entry.feature}:${entry.subFeature}:${entry.action}:${index}`}>
<td><b>{label(entry.feature)}</b><span className="table-sub">{label(entry.subFeature)}</span></td>
<td>{label(entry.action)}</td>
<td className="num">{num(entry.users)}</td>
<td className="num">{num(entry.uses)}</td>
<td className="num">{num(entry.journeys)}</td>
</tr>
))
)}
@@ -250,8 +237,8 @@ export function JourneysPage() {
</Grid>
<Card
title="Feature use"
intro="Rare and unused features are shown against Memby's major feature catalogue."
title="Most common routes"
intro="The most common steps between screens, including where quiet visits ended."
icon="pulse"
tone="data"
>
@@ -259,32 +246,14 @@ export function JourneysPage() {
<table>
<thead>
<tr>
<th>Feature</th>
<th className="num">Uses</th>
<th>Last used</th>
<th>Status</th>
<th>Route</th>
<th className="num">Times</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>
);
})}
{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>
+69
View File
@@ -882,6 +882,59 @@ a {
font: inherit;
}
/* A gateway restart is a console-wide state, not a page error. Keep the page mounted so
filters and navigation survive the short outage, while making it impossible for a proxy
response or a collection of stale banners to become the operator's view. */
.reconnect-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: rgba(3, 6, 10, .86);
backdrop-filter: blur(5px);
}
.reconnect-panel {
display: grid;
justify-items: center;
width: min(440px, 100%);
padding: 34px 32px 30px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(145deg, var(--surface-lift), var(--surface));
box-shadow: 0 24px 80px rgba(0, 0, 0, .4);
text-align: center;
}
.reconnect-spinner {
width: 30px;
height: 30px;
margin-bottom: 18px;
border: 3px solid var(--line);
border-top-color: var(--accent-ink);
border-radius: 50%;
animation: reconnect-spin .9s linear infinite;
}
.reconnect-panel h1 {
margin: 0;
font-size: 21px;
letter-spacing: -.02em;
}
.reconnect-panel p {
max-width: 35ch;
margin: 10px 0 18px;
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.reconnect-panel small {
color: var(--quiet);
font: 11.5px/1.4 var(--mono);
}
@keyframes reconnect-spin {
to { transform: rotate(360deg); }
}
/* ---------- cards ---------- */
.card {
@@ -1384,6 +1437,22 @@ td.mono {
font: 12px/1.5 var(--mono);
color: var(--muted);
}
.link-button {
border: 0;
padding: 0;
background: none;
color: var(--accent-ink);
font: inherit;
text-decoration: underline;
text-decoration-color: var(--line);
text-underline-offset: 3px;
cursor: pointer;
}
.is-disabled td { opacity: .62; }
.account-device-detail > td { padding: 0 0 12px; background: var(--surface-sunken, transparent); }
.device-breakdown { display: grid; gap: 6px; padding: 10px 12px 10px 46px; border-top: 1px solid var(--line-soft); }
.device-breakdown-row { display: grid; grid-template-columns: minmax(150px, 1.4fr) minmax(90px, .7fr) minmax(130px, 1fr) minmax(100px, 1fr); gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--line-soft); border-radius: var(--radius-sm); background: var(--surface-lift); font-size: 12px; }
@media (max-width: 1100px) { .device-breakdown-row { grid-template-columns: 1fr 1fr; } }
td.nowrap,
th.nowrap {
white-space: nowrap;