0.3.21
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 viewer’s 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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user