Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
98 changes: 98 additions & 0 deletions dashboard/web/src/alerts.ts
Original file line number Diff line number Diff line change
@@ -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<AlertKind, number> = {
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 } : {}),
};
}
31 changes: 28 additions & 3 deletions dashboard/web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
Expand All @@ -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();
Expand All @@ -31,10 +34,21 @@ export function App() {
const health = usePolled<HealthReport>('/api/health', HEALTH_POLL_MS);
const metrics = usePolled<Result<{ gauges: Gauge[] }>>('/api/metrics', 15_000);
const vpn = usePolled<Result<VpnStatus>>('/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',
Expand Down Expand Up @@ -64,7 +78,16 @@ export function App() {
<Sidebar services={services} groups={groups} vpn={vpn.data} vpnLoading={vpn.loading} />

<main style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Header title="Dashboard" subtitle={subtitle} theme={theme} onToggleTheme={toggleTheme} />
<Header
title="Dashboard"
subtitle={subtitle}
theme={theme}
onToggleTheme={toggleTheme}
services={services}
groups={groups}
alerts={alerts}
onOpenSetup={() => setView('setup')}
/>

<div style={{ padding: 'var(--space-8)', flex: 1 }} className="ap-view">
<div className="seg" style={{ marginBottom: 'var(--space-6)' }}>
Expand Down Expand Up @@ -146,7 +169,9 @@ export function App() {
) : (
<Launcher services={services} groups={groups} />
))}
{view === 'setup' && <Setup />}
{view === 'setup' && (
<Setup integrations={integrations.data?.integrations ?? []} loading={integrations.loading} />
)}
</div>
</main>
</div>
Expand Down
87 changes: 82 additions & 5 deletions dashboard/web/src/app/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
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 {
title: string;
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 (
<header
style={{
Expand All @@ -24,13 +41,32 @@ export function Header({ title, subtitle, theme, onToggleTheme }: Props) {
background: 'var(--color-bg)',
}}
>
<div style={{ minWidth: 0 }}>
{/*
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.
*/}
<div style={{ minWidth: 0, flex: '0 1 auto', overflow: 'hidden' }}>
<h4 style={{ margin: 0, fontSize: 21 }}>{title}</h4>
<div className="text-muted" style={{ fontSize: 12, marginTop: 2 }}>
<div
className="text-muted"
style={{
fontSize: 12,
marginTop: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{subtitle}
</div>
</div>
<div style={{ flex: 1 }} />
<div style={{ flex: '1 0 var(--space-4)' }} />

<CommandSearch services={services} groups={groups} />
<RequestButton services={services} />
<Notifications alerts={alerts} onOpenSetup={onOpenSetup} />

<button
type="button"
className="btn btn-secondary btn-icon"
Expand All @@ -42,3 +78,44 @@ export function Header({ title, subtitle, theme, onToggleTheme }: Props) {
</header>
);
}

/**
* Hands off to Seerr rather than requesting from here.
*
* The design mocked a Request control in the header, and this is that control —
* but it deep-links instead of posting. The dashboard is read-only and ships no
* auth, so a request endpoint here would let anyone who can reach the LAN page
* add to the library under Seerr's credentials, with no record of who did it.
* Seerr already has accounts, approval rules and its own search; what the
* header adds is the shortcut, not a second front door.
*/
function RequestButton({ services }: { services: ServiceStatus[] }) {
const seerr = services.find((service) => service.id === 'seerr');

// Not in the catalog, absent from the host, or published without a UI port —
// in each case there is nothing to link to, so no control is shown.
if (!seerr || seerr.state === 'absent' || seerr.port === null) return null;

if (seerr.state === 'down') {
return (
<button type="button" className="btn btn-secondary" disabled title="Seerr is not running">
<Plus size={15} weight="regular" />
Request
</button>
);
}

return (
<a
className="btn btn-primary"
href={serviceUrl(seerr.port)}
target="_blank"
rel="noreferrer"
title="Request a movie or series in Seerr"
>
<Plus size={15} weight="regular" />
Request
<ArrowUpRight size={12} weight="regular" />
</a>
);
}
Loading
Loading