a nav down the left, whatever it selects beside it

two fixed panels could show two of NPM's seven sections and had nowhere to put
the other five. now the left column is the section list — proxy hosts,
redirections, streams, 404 hosts, access lists, certificates, connection — and
the right renders whichever is chosen.

NPM's own vocabulary, in NPM's own order. somebody who has used it should find
the same words, and somebody who has not will end up in NPM's UI eventually
anyway; better names would just mean learning both. "404 hosts" reads oddly and
is theirs, so the hint under it says what it means.

the two panels never speak. the nav writes ?section= and the detail pane reads
it — navigation-audit's rule, and it is what makes a section linkable and
survive a reload. changing section clears ?host=, which belonged to the list
that was open.

three things folded up while doing it. the unconfigured check moved to the
detail pane and happens once, so all seven sections say "no connection yet" the
same way instead of six saying it badly. the settings gear is gone: connection
is a nav row now. and the four list-only sections are one component, differing
only in what a row says — proxy hosts and certificates stay separate because
they have real behaviour, and folding them in would be a props bag that is a
worse version of both.

the counts in the nav are one request each. affordable against a local NPM, and
they answer "is this worth opening" without a click. a count that fails is
absent rather than zero — zero is a fact about NPM, a failure is a fact about
the request, and the detail pane reports that properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 19:00:24 +00:00
co-authored by Claude Opus 5
parent 65c98a49dd
commit 417e091dfe
7 changed files with 433 additions and 42 deletions
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { readableError } from './errors';
import { useConnectionChanged } from './channel';
/**
* The four sections that are a list and nothing more — redirections, streams, 404 hosts, access lists.
*
* One component rather than four near-identical ones. Each differs only in what a row SAYS, so that is the
* one thing passed in; everything around it — loading, failure, empty, refresh — is the same problem four
* times over and was not worth writing four times.
*
* Proxy hosts and certificates are deliberately NOT here. They have real behaviour of their own: a
* selection with an enable/disable action, and expiry arithmetic that decides the colour of a row. Folding
* them in would mean a props bag that is a worse version of both.
*/
type Row = { id: number } & Record<string, unknown>;
export type ListSectionProps = {
/** Plugin route, e.g. `/proxy-manager/streams`. */
path: string;
/** What this is, shown when there are none. */
emptyText: string;
/** The row's heading — usually a domain or a port. */
primary: (row: Row) => string;
/** The line under it — where it goes, or what it holds. */
secondary: (row: Row) => string;
/** Dimmed when false. NPM's `enabled` is 0/1, and some of these have no such field at all. */
isEnabled?: (row: Row) => boolean;
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
{children}
</div>
);
export const ListSection = ({ path, emptyText, primary, secondary, isEnabled }: ListSectionProps) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionSignal] = useConnectionChanged();
const load = useCallback(async () => {
try {
const next = await clientRef.current.get<Row[]>(path);
setRows(Array.isArray(next) ? next : []);
setError(null);
} catch (caught) {
setError(readableError(caught, 'Could not load this.'));
setRows(null);
}
}, [path]);
useEffect(() => {
void load();
}, [load, connectionSignal]);
if (error) return <Centred>{error}</Centred>;
if (!rows) return <Centred>Loading…</Centred>;
return (
<div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">
{rows.length} {rows.length === 1 ? 'entry' : 'entries'}
</span>
<button type="button" onClick={() => void load()} className="text-muted-foreground hover:text-foreground">
<RefreshCw className="size-4" />
</button>
</header>
{rows.length === 0 ? (
<Centred>{emptyText}</Centred>
) : (
<ul className="flex-1 overflow-auto">
{rows.map((row) => (
<li
key={row.id}
className={`border-b px-3 py-2 ${isEnabled && !isEnabled(row) ? 'opacity-50' : ''}`}
>
<span className="block truncate text-sm">{primary(row)}</span>
<span className="block truncate text-xs text-muted-foreground">{secondary(row)}</span>
</li>
))}
</ul>
)}
</div>
);
};
/** NPM's `enabled` is an integer. Absent on the kinds that cannot be switched off. */
const enabled = (row: Row) => row.enabled !== 0;
const domains = (row: Row) => (Array.isArray(row.domain_names) ? (row.domain_names as string[]).join(', ') : '—');
/**
* The four, with only the parts that differ.
*
* Field names are NPM's, straight off the wire — `docs/npm-api.md` has the shapes. Nothing is renamed on
* the way through, so a value that looks wrong here is a value that looks the same in NPM's own UI.
*/
export const REDIRECTIONS: ListSectionProps = {
path: '/proxy-manager/redirections',
emptyText: 'No redirections. These send a domain somewhere else rather than serving it.',
primary: domains,
secondary: (r) => `→ ${String(r.forward_scheme ?? 'auto')}://${String(r.forward_domain_name ?? '?')}`,
isEnabled: enabled,
};
export const STREAMS: ListSectionProps = {
path: '/proxy-manager/streams',
emptyText: 'No streams. These forward a raw TCP or UDP port rather than HTTP.',
primary: (r) => `port ${String(r.incoming_port ?? '?')}`,
secondary: (r) =>
`→ ${String(r.forwarding_host ?? '?')}:${String(r.forwarding_port ?? '?')}` +
`${r.tcp_forwarding ? ' · TCP' : ''}${r.udp_forwarding ? ' · UDP' : ''}`,
isEnabled: enabled,
};
export const DEAD_HOSTS: ListSectionProps = {
path: '/proxy-manager/dead-hosts',
emptyText: 'No 404 hosts. These serve a domain deliberately as nothing.',
primary: domains,
secondary: (r) => (r.certificate_id ? 'HTTPS' : 'HTTP only'),
isEnabled: enabled,
};
export const ACCESS_LISTS: ListSectionProps = {
path: '/proxy-manager/access-lists',
emptyText: 'No access lists. These decide who may reach a host.',
primary: (r) => String(r.name ?? '(unnamed)'),
secondary: (r) => {
const users = Array.isArray(r.items) ? r.items.length : 0;
const clients = Array.isArray(r.clients) ? r.clients.length : 0;
return `${users} user${users === 1 ? '' : 's'} · ${clients} client rule${clients === 1 ? '' : 's'}`;
},
};