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
144 lines
5.4 KiB
TypeScript
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'}`;
|
|
},
|
|
};
|