diff --git a/dashboard/README.md b/dashboard/README.md index c67e0ab..86d5327 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -80,10 +80,20 @@ Every variable has a working default — the app must start and be useful agains which handles the loading, unavailable and empty cases for you. The `hint` is the part that matters: it should name the one concrete step that -fixes the problem. See `hintFor()` in `sources/transmission.ts` — an auth +fixes the problem. See `hintFor()` in `server/src/sources/transmission.ts` — an auth failure and an unreachable host need different advice, and a generic hint sends people looking in the wrong place. +## The header + +Three controls, all reading data the app already polls: + +- **Search** (`components/CommandSearch.tsx`) filters the service catalog and opens the one you pick. Focus it with `/` or `Ctrl`/`Cmd`+`K`. Only services with a published port are offered as results, since opening one is the only thing a result does — services without a web UI are named in a footer line instead of appearing as rows that do nothing on Enter. +- **Request** deep-links to Seerr rather than posting a request. The dashboard is read-only and ships no auth, so a request endpoint here would let anyone who can reach the page add to the library under Seerr's credentials with no record of who did it. Seerr already has accounts and approval rules. +- **Alerts** (`components/Notifications.tsx`) is derived in the browser by `alerts.ts` from `/api/health` plus `/api/integrations` — both already on screen, so a dedicated endpoint would re-poll the same sources to produce something the client can assemble for free. + +Two states deliberately don't raise an alert: `absent` services (a user who trimmed services out of their compose file shouldn't get permanent alerts for things they chose not to run) and `waiting` integrations (the normal state on a clean install — alerting would mean a first boot opens with a full inbox that clears itself). When the socket proxy is unreachable, every service reads `absent`, so that case short-circuits to a single alert naming the proxy rather than reporting nothing at all. + ## Conventions - **Nothing may throw on missing configuration.** An unset key or an unreachable upstream degrades that one panel; it never blanks the page. diff --git a/dashboard/web/src/alerts.ts b/dashboard/web/src/alerts.ts new file mode 100644 index 0000000..72bb455 --- /dev/null +++ b/dashboard/web/src/alerts.ts @@ -0,0 +1,98 @@ +/** + * Derives the "what needs attention" list from data the app already polls. + * + * This is computed in the browser rather than served from the API because both + * inputs are already on screen — /api/health drives the sidebar and the health + * tile, /api/integrations drives Setup. A dedicated endpoint would poll the + * same two sources again to produce something the client can assemble for free. + */ + +import type { HealthReport, Integration, ServiceStatus } from './types'; + +export type AlertKind = 'unreachable' | 'attn' | 'down' | 'blocked'; + +export interface Alert { + id: string; + kind: AlertKind; + title: string; + /** One line naming what is actually wrong, or the step that fixes it. */ + detail: string; + /** Set when the alert is about a service with a web UI, so it can be opened. */ + port?: number; +} + +/** + * Rendering order. A stack that can't be inspected at all outranks a single + * unhealthy container, which outranks a stopped one (often deliberate), which + * outranks an integration that only degrades one panel. + */ +const ORDER: Record = { + unreachable: 0, + attn: 1, + down: 2, + blocked: 3, +}; + +export function deriveAlerts( + health: HealthReport | null, + integrations: Integration[], +): Alert[] { + const alerts: Alert[] = []; + + // Without the socket proxy every service reports `absent`, which would + // otherwise produce no alerts at all — the quietest possible rendering of the + // loudest possible problem. Report the cause and stop: the per-service states + // behind it aren't trustworthy. + if (health && !health.reachable) { + return [ + { + id: 'docker-unreachable', + kind: 'unreachable', + title: 'Container state unavailable', + detail: + 'The dashboard cannot reach docker-socket-proxy, so service status may be stale. Check that the autoplexx-socket-proxy container is running.', + }, + ]; + } + + for (const service of health?.services ?? []) { + // `absent` is not a fault. A user who trimmed services out of their compose + // file would otherwise get a permanent list of alerts for things they chose + // not to run. + if (service.state === 'attn') { + alerts.push(serviceAlert(service, 'attn', 'needs attention')); + } else if (service.state === 'down') { + alerts.push(serviceAlert(service, 'down', 'is stopped')); + } + } + + for (const integration of integrations) { + // Only `blocked` — `waiting` is the normal state on a clean install, where + // a service simply hasn't written its config file yet. Alerting on it would + // mean a first boot opens with a full inbox that clears itself. + if (integration.state !== 'blocked') continue; + + const service = health?.services.find((candidate) => candidate.id === integration.source); + alerts.push({ + id: `integration:${integration.source}`, + kind: 'blocked', + title: `${service?.name ?? integration.source} is not connected`, + detail: integration.hint ?? 'The dashboard could not read an API key for this service.', + ...(service?.port != null ? { port: service.port } : {}), + }); + } + + return alerts.sort((a, b) => ORDER[a.kind] - ORDER[b.kind]); +} + +function serviceAlert(service: ServiceStatus, kind: AlertKind, summary: string): Alert { + return { + id: `service:${service.id}`, + kind, + title: `${service.name} ${summary}`, + // Docker's own status line is the most specific thing available here — + // "Restarting (1) 4 seconds ago" says far more than "needs attention". + detail: service.status ?? service.blurb, + ...(service.port != null ? { port: service.port } : {}), + }; +} diff --git a/dashboard/web/src/app/App.tsx b/dashboard/web/src/app/App.tsx index 3c678c1..4f56719 100644 --- a/dashboard/web/src/app/App.tsx +++ b/dashboard/web/src/app/App.tsx @@ -10,7 +10,8 @@ import { StackHealth } from '../components/StackHealth'; import { Gauges } from '../components/Gauges'; import { usePolled } from '../hooks/usePolled'; import { useTheme } from '../hooks/useTheme'; -import type { Gauge, HealthReport, Result, ServiceGroup, VpnStatus } from '../types'; +import { deriveAlerts } from '../alerts'; +import type { Gauge, HealthReport, Integration, Result, ServiceGroup, VpnStatus } from '../types'; interface ServicesResponse { groups: readonly { id: ServiceGroup; label: string }[]; @@ -20,6 +21,8 @@ interface ServicesResponse { type View = 'command' | 'launcher' | 'setup'; const HEALTH_POLL_MS = 10_000; +/** Matches the server's discovery TTL, so a newly written key surfaces promptly. */ +const INTEGRATION_POLL_MS = 30_000; export function App() { const [theme, toggleTheme] = useTheme(); @@ -31,10 +34,21 @@ export function App() { const health = usePolled('/api/health', HEALTH_POLL_MS); const metrics = usePolled>('/api/metrics', 15_000); const vpn = usePolled>('/api/vpn', 30_000); + // Polled here rather than inside Setup because the alert bell needs the same + // data — one poll feeds both, whichever view is on screen. + const integrations = usePolled<{ integrations: Integration[] }>( + '/api/integrations', + INTEGRATION_POLL_MS, + ); const groups = catalog.data?.groups ?? []; const services = health.data?.services ?? catalog.data?.services ?? []; + const alerts = useMemo( + () => deriveAlerts(health.data, integrations.data?.integrations ?? []), + [health.data, integrations.data], + ); + const subtitle = useMemo(() => { const today = new Date().toLocaleDateString(undefined, { weekday: 'long', @@ -64,7 +78,16 @@ export function App() {
-
+
setView('setup')} + />
@@ -146,7 +169,9 @@ export function App() { ) : ( ))} - {view === 'setup' && } + {view === 'setup' && ( + + )}
diff --git a/dashboard/web/src/app/Header.tsx b/dashboard/web/src/app/Header.tsx index aea37fa..6653e67 100644 --- a/dashboard/web/src/app/Header.tsx +++ b/dashboard/web/src/app/Header.tsx @@ -1,5 +1,9 @@ -import { Moon, Sun } from '@phosphor-icons/react'; +import { ArrowUpRight, Moon, Plus, Sun } from '@phosphor-icons/react'; +import { CommandSearch } from '../components/CommandSearch'; +import { Notifications } from '../components/Notifications'; +import { serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; +import type { Alert } from '../alerts'; import type { Theme } from '../hooks/useTheme'; interface Props { @@ -7,9 +11,22 @@ interface Props { subtitle: string; theme: Theme; onToggleTheme: () => void; + services: ServiceStatus[]; + groups: readonly { id: ServiceGroup; label: string }[]; + alerts: Alert[]; + onOpenSetup: () => void; } -export function Header({ title, subtitle, theme, onToggleTheme }: Props) { +export function Header({ + title, + subtitle, + theme, + onToggleTheme, + services, + groups, + alerts, + onOpenSetup, +}: Props) { return (
-
+ {/* + The title yields space before the controls do. Without this the search + box is the thing that collapses on a narrow window, and a search field + squeezed down to its own icon is worse than a truncated date. + */} +

{title}

-
+
{subtitle}
-
+
+ + + + + + ); + } + + return ( + + + Request + + + ); +} diff --git a/dashboard/web/src/components/CommandSearch.tsx b/dashboard/web/src/components/CommandSearch.tsx new file mode 100644 index 0000000..fe3e03f --- /dev/null +++ b/dashboard/web/src/components/CommandSearch.tsx @@ -0,0 +1,250 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { MagnifyingGlass } from '@phosphor-icons/react'; + +import { ServiceIcon } from './ServiceIcon'; +import { StatusDot } from './StatusDot'; +import { useDismissable } from '../hooks/useDismissable'; +import { serviceUrl, type ServiceGroup, type ServiceStatus } from '../types'; + +interface Props { + services: ServiceStatus[]; + groups: readonly { id: ServiceGroup; label: string }[]; +} + +const MAX_RESULTS = 7; + +/** A service is openable when it publishes a web UI. */ +interface Openable extends ServiceStatus { + port: number; +} + +export interface Matches { + /** Services that can actually be opened — the navigable results. */ + openable: Openable[]; + /** Matched services with no web UI, named but not offered as results. */ + uiless: ServiceStatus[]; +} + +/** + * Ranks a service against a query. Lower is better; `null` means no match. + * + * Name matches beat blurb matches, and a name that starts with the query beats + * one that merely contains it — typing "so" should reach Sonarr before + * Flaresolverr. + */ +export function score(service: ServiceStatus, query: string): number | null { + const name = service.name.toLowerCase(); + if (name.startsWith(query)) return 0; + if (name.includes(query)) return 1; + if (service.id.includes(query)) return 2; + if (service.blurb.toLowerCase().includes(query)) return 3; + return null; +} + +/** + * Filters the catalog for the search menu. + * + * Only services in a visible group are searchable, matching what the sidebar + * and Launcher show — `system` services like the socket proxy have no UI and + * aren't things a user navigates to. + */ +export function match( + services: ServiceStatus[], + groups: readonly { id: ServiceGroup }[], + rawQuery: string, +): Matches { + const query = rawQuery.trim().toLowerCase(); + if (query === '') return { openable: [], uiless: [] }; + + const visible = new Set(groups.map((group) => group.id)); + const ranked = services + .filter((service) => visible.has(service.group)) + .flatMap((service) => { + const rank = score(service, query); + return rank === null ? [] : [{ service, rank }]; + }) + .sort((a, b) => a.rank - b.rank || a.service.name.localeCompare(b.service.name)); + + return { + openable: ranked + .flatMap(({ service }) => (service.port === null ? [] : [{ ...service, port: service.port }])) + .slice(0, MAX_RESULTS), + uiless: ranked.flatMap(({ service }) => (service.port === null ? [service] : [])), + }; +} + +/** + * The header's service search. + * + * The one action a result can take is "open this service", so services without + * a web UI aren't offered as results — they'd be rows that do nothing on Enter. + * They're still named underneath when they match, because a user searching + * "watchtower" deserves better than an empty menu. + */ +export function CommandSearch({ services, groups }: Props) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const listId = useId(); + + const close = () => setOpen(false); + const wrapRef = useDismissable(open, close); + + const { openable, uiless } = useMemo(() => match(services, groups, query), [services, groups, query]); + + // Clamp rather than reset, so results narrowing under the cursor doesn't + // leave the highlight pointing past the end of the list. + const activeIndex = Math.min(active, Math.max(openable.length - 1, 0)); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const typing = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable === true; + + // "/" is the convention for search-focus, but only when it isn't being + // typed into something. + const slash = event.key === '/' && !typing; + const commandK = event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey); + if (!slash && !commandK) return; + + event.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + const openService = (service: Openable) => { + window.open(serviceUrl(service.port), '_blank', 'noopener,noreferrer'); + setQuery(''); + close(); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + // First Escape closes the menu, a second clears the box — so an + // accidental open doesn't cost the query. + if (open) close(); + else setQuery(''); + return; + } + if (event.key === 'Tab') { + close(); + return; + } + if (openable.length === 0) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setOpen(true); + setActive((index) => (Math.min(index, openable.length - 1) + 1) % openable.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setOpen(true); + setActive((index) => (Math.min(index, openable.length - 1) + openable.length - 1) % openable.length); + } else if (event.key === 'Enter') { + const service = openable[activeIndex]; + if (service) openService(service); + } + }; + + const expanded = open && query.trim() !== ''; + + return ( +
+
+ ); +} diff --git a/dashboard/web/src/components/Notifications.tsx b/dashboard/web/src/components/Notifications.tsx new file mode 100644 index 0000000..e51750e --- /dev/null +++ b/dashboard/web/src/components/Notifications.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { ArrowUpRight, Bell, CheckCircle, PlugsConnected, Warning, WarningOctagon } from '@phosphor-icons/react'; + +import { useDismissable } from '../hooks/useDismissable'; +import { serviceUrl } from '../types'; +import type { Alert, AlertKind } from '../alerts'; + +interface Props { + alerts: Alert[]; + /** Jumps to the Setup view, where a blocked integration is explained in full. */ + onOpenSetup: () => void; +} + +const KIND = { + unreachable: { Icon: WarningOctagon, hue: 'var(--ap-red)' }, + attn: { Icon: Warning, hue: 'var(--ap-amber)' }, + down: { Icon: WarningOctagon, hue: 'var(--ap-red)' }, + blocked: { Icon: PlugsConnected, hue: 'var(--ap-amber)' }, +} as const satisfies Record; + +/** + * The header's alert bell. + * + * Everything here is derived from data already on screen, so it never + * contradicts the sidebar — and it deliberately says nothing when nothing is + * wrong, rather than inventing an activity feed. The Command Center's Activity + * panel is where "things that happened" belongs; this is only "things that need + * you". + */ +export function Notifications({ alerts, onOpenSetup }: Props) { + const [open, setOpen] = useState(false); + const ref = useDismissable(open, () => setOpen(false)); + + const count = alerts.length; + const label = count === 0 ? 'Alerts: nothing needs attention' : `Alerts: ${count} need attention`; + + return ( +
+ + + {open && ( +
+
+ Needs attention +
+ + {count === 0 ? ( +
+ + Every service is running and connected. +
+ ) : ( +
    + {alerts.map((alert) => ( + { + setOpen(false); + onOpenSetup(); + }} + /> + ))} +
+ )} +
+ )} +
+ ); +} + +function AlertRow({ alert, onOpenSetup }: { alert: Alert; onOpenSetup: () => void }) { + const { Icon, hue } = KIND[alert.kind]; + + return ( +
  • + +
    +
    {alert.title}
    +
    + {alert.detail} +
    +
    + {/* + A stopped container's UI won't answer, so "Open" is only offered for + problems where the service is still up — a blocked integration is + exactly that case. + */} + {alert.kind === 'blocked' && ( + <> + + {alert.port != null && ( + + Open + + + )} + + )} +
    +
    +
  • + ); +} diff --git a/dashboard/web/src/hooks/useDismissable.ts b/dashboard/web/src/hooks/useDismissable.ts new file mode 100644 index 0000000..30cfd88 --- /dev/null +++ b/dashboard/web/src/hooks/useDismissable.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef } from 'react'; + +/** + * Closes an open popover on Escape or on a pointer press outside it, and + * returns the ref to put on the popover's container. + * + * Uses `pointerdown` rather than `click` so a press that starts outside closes + * immediately, and listens in the capture phase so a press on a control that + * stops propagation still dismisses. + */ +export function useDismissable(open: boolean, onDismiss: () => void) { + const ref = useRef(null); + // Kept in a ref so a caller passing an inline arrow doesn't re-subscribe on + // every render. + const dismiss = useRef(onDismiss); + dismiss.current = onDismiss; + + useEffect(() => { + if (!open) return; + + const onPointerDown = (event: PointerEvent) => { + const node = ref.current; + if (node && event.target instanceof Node && !node.contains(event.target)) { + dismiss.current(); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') dismiss.current(); + }; + + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + return ref; +} diff --git a/dashboard/web/src/styles/autoplexx.css b/dashboard/web/src/styles/autoplexx.css index 0f06705..c6600da 100644 --- a/dashboard/web/src/styles/autoplexx.css +++ b/dashboard/web/src/styles/autoplexx.css @@ -18,6 +18,14 @@ --ap-cyan-dim: oklch(48% 0.09 220); --ap-violet: var(--color-accent-400); --ap-violet-dim: var(--color-accent-700); + + /* + * The alert badge is the one place amber sits behind text rather than being + * used as a dot, border or bar, so it needs its own value: the light theme's + * --ap-amber carries only 3.4:1 against --color-bg, under the 4.5:1 AA asks + * for at 10px. Dark theme already clears it at 9.9:1 and reuses amber as-is. + */ + --ap-badge: var(--ap-amber); } [data-theme='light'] { @@ -29,6 +37,8 @@ --ap-amber: oklch(62% 0.15 78); --ap-red: oklch(56% 0.19 22); --ap-cyan: oklch(58% 0.13 230); + /* 5.1:1 against --color-bg, where plain --ap-amber manages only 3.4:1. */ + --ap-badge: oklch(52% 0.15 78); } @keyframes ap-fade { @@ -67,6 +77,72 @@ box-shadow: var(--shadow-md); } +/* + * Header popovers — the search menu and the alert list. + * + * z-index sits above the sticky header's own stacking context (20) so a menu + * opened from the header isn't clipped by the content below it. + */ +.ap-pop { + position: absolute; + z-index: 30; + background: var(--color-surface); + border: 1px solid var(--color-divider); + border-radius: var(--radius-md); + overflow: hidden; +} + +.ap-menu { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} + +.ap-menu-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + cursor: pointer; +} + +/* + * Keyboard and pointer highlight are the same state: the pointer sets the + * active index rather than styling itself, so the two can never disagree about + * which row Enter will open. + */ +.ap-menu-item[aria-selected='true'] { + background: color-mix(in srgb, var(--color-accent) 14%, transparent); +} + +/* + * The unread count on the alert bell. + * + * nocturne's spacing scale is a layout scale — 2.8 / 5.6 / 8.4 / 11.2px — and + * none of its steps reach the 16px an icon-corner badge needs. So the size is + * stated once here and the offset, radius and line-height derive from it, + * rather than being four independent numbers that have to stay in agreement. + */ +.ap-badge { + --badge-size: 16px; + + position: absolute; + top: calc(var(--badge-size) / -4); + right: calc(var(--badge-size) / -4); + min-width: var(--badge-size); + height: var(--badge-size); + padding: 0 var(--space-1); + border-radius: calc(var(--badge-size) / 2); + background: var(--ap-badge); + color: var(--color-bg); + font-size: 10px; + font-weight: 600; + line-height: var(--badge-size); + text-align: center; +} + /* Tinted tags and dots driven by a `--h` hue custom property. */ .ap-tag { display: inline-flex; diff --git a/dashboard/web/src/views/Setup.tsx b/dashboard/web/src/views/Setup.tsx index fd7f875..0c03696 100644 --- a/dashboard/web/src/views/Setup.tsx +++ b/dashboard/web/src/views/Setup.tsx @@ -1,8 +1,13 @@ import { CheckCircle, Clock, Warning } from '@phosphor-icons/react'; -import { usePolled } from '../hooks/usePolled'; import type { Integration } from '../types'; +interface Props { + integrations: Integration[]; + /** True only until the first response — a refresh never blanks the list. */ + loading: boolean; +} + const LABEL: Record = { sonarr: 'Sonarr', radarr: 'Radarr', @@ -25,13 +30,11 @@ const STATE = { * this panel exists to make that legible: on a first boot the user can watch * integrations connect themselves, and anything genuinely stuck names the one * step that fixes it. Keys are never sent to the browser — only their state. + * + * The data is polled by App and passed in, because the header's alert bell + * reads the same endpoint and one poll should serve both. */ -export function Setup() { - // Polled on the same cadence as server-side discovery, so a service that has - // just written its config shows up here without a reload. - const { data, loading } = usePolled<{ integrations: Integration[] }>('/api/integrations', 30_000); - - const integrations = data?.integrations ?? []; +export function Setup({ integrations, loading }: Props) { const live = integrations.filter((i) => i.state === 'live').length; return ( @@ -50,7 +53,7 @@ export function Setup() { )}
    - {loading && !data ? ( + {loading && integrations.length === 0 ? ( Loading…