Files
pastilhasandClaude Opus 5 66383aa9a4 the lists re-read when the agent changes something
an agent deleted two proxy hosts and both stayed on screen. the person had the
answer in the transcript and a stale list six inches above it — which is the
one thing a chat beside a list has to get right, and the reason it is beside
the list at all.

so the chat panel bumps the refresh channel when a turn ran tools, and every
section re-reads. gated on hadToolCalls: a turn that only talked changed
nothing, and re-reading after every sentence would put six requests behind
every reply.

the channel is renamed with it. it was proxy-manager:connection-changed, named
after its only publisher; it has two now and the second is the bigger one, so
it says what it MEANS — something at NPM changed, read it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 19:03:35 +00:00

144 lines
5.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { readableError } from './errors';
import { useNpmChanged } 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] = useNpmChanged();
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'}`;
},
};