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
229 lines
8.8 KiB
TypeScript
229 lines
8.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, ShieldCheck, Users } from "lucide-react";
|
|
import { useClient } from "hooks/useClient";
|
|
import { useSearchParams } from "react-router";
|
|
import { readableError } from "./errors";
|
|
import { useConnectionChanged } from "./channel";
|
|
|
|
/**
|
|
* The proxy hosts, which is the thing anybody opens this plugin to see.
|
|
*
|
|
* Selection lives in the URL (`?host=<id>`), not in a channel — `docs/navigation-audit.md` is the
|
|
* authority and this is the case it names: the detail panel reads the same parameter rather than being
|
|
* handed the id through a bus.
|
|
*/
|
|
|
|
type ProxyHost = {
|
|
id: number;
|
|
domain_names: string[];
|
|
forward_scheme: string;
|
|
forward_host: string;
|
|
forward_port: number;
|
|
enabled: number;
|
|
ssl_forced: number;
|
|
certificate_id: number;
|
|
access_list_id: number;
|
|
meta?: { nginx_online?: boolean; nginx_err?: string | null };
|
|
};
|
|
|
|
type Failure = { error: string; likelyAuth?: boolean; unreachable?: boolean; unconfigured?: boolean };
|
|
|
|
/**
|
|
* Turn a thrown `ApiError` into the shape this panel branches on.
|
|
*
|
|
* **This reads the STATUS, not the body, and that is not a shortcut.** `useClient` throws
|
|
* `ApiError(status, message)` — there is no `.body` on it, and there never was. This panel used to read
|
|
* `(error as { body?: Failure }).body`, which is always `undefined`, so `unconfigured` and `likelyAuth`
|
|
* were never once true and every failure fell through to the generic message. The careful explanation of
|
|
* NPM's 404 quirk was written, shipped, and unreachable.
|
|
*
|
|
* `api/router.ts` already encodes the distinction in the status it chooses, so it survives the trip:
|
|
* 409 is "no connection stored", 503 is "NPM did not answer", 502 is "NPM answered and refused". The
|
|
* message carries the detail, including the sentence about a 404 really meaning the credential.
|
|
*/
|
|
const asFailure = (error: unknown): Failure => {
|
|
const status = (error as { status?: number })?.status;
|
|
const message = readableError(error, "Could not load hosts.");
|
|
return {
|
|
error: message,
|
|
unconfigured: status === 409,
|
|
unreachable: status === 503,
|
|
likelyAuth: status === 502,
|
|
};
|
|
};
|
|
|
|
export const HostsPanel = () => {
|
|
const client = useClient();
|
|
// useClient() rebuilds its verbs every render, so it must not sit in a dependency list.
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [hosts, setHosts] = useState<ProxyHost[] | null>(null);
|
|
const [failure, setFailure] = useState<Failure | null>(null);
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
const [params, setParams] = useSearchParams();
|
|
const selected = params.get("host");
|
|
// Re-read when the connection is saved or removed. Named as an effect dependency below — that IS the
|
|
// subscription; there is nothing to unsubscribe from.
|
|
const [connectionSignal] = useConnectionChanged();
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setHosts(await clientRef.current.get<ProxyHost[]>("/proxy-manager/hosts"));
|
|
setFailure(null);
|
|
} catch (error) {
|
|
setFailure(asFailure(error));
|
|
setHosts(null);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load, connectionSignal]);
|
|
|
|
const toggle = async (host: ProxyHost) => {
|
|
setBusy(host.id);
|
|
try {
|
|
await clientRef.current.post(`/proxy-manager/hosts/${host.id}/${host.enabled ? "disable" : "enable"}`, {});
|
|
await load();
|
|
} catch {
|
|
setFailure({ error: `Could not ${host.enabled ? "disable" : "enable"} that host.` });
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
// No `unconfigured` branch here any more: `DetailPanel` asks `/status` once and renders the connection
|
|
// form instead of any section, so all seven say the same thing rather than six of them saying it badly.
|
|
if (failure) return <Failed failure={failure} onRetry={() => void load()} />;
|
|
if (!hosts) return <Centred>Loading hosts…</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">
|
|
{hosts.length} host{hosts.length === 1 ? "" : "s"}
|
|
</span>
|
|
<button type="button" onClick={() => void load()} className="text-muted-foreground hover:text-foreground">
|
|
<RefreshCw className="size-4" />
|
|
</button>
|
|
</header>
|
|
|
|
{hosts.length === 0 ? (
|
|
<Centred>Nginx Proxy Manager has no proxy hosts.</Centred>
|
|
) : null}
|
|
|
|
<ul className="flex-1 overflow-auto">
|
|
{hosts.map((host) => {
|
|
const offline = host.meta?.nginx_online === false;
|
|
return (
|
|
<li key={host.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setParams((p) => { p.set("host", String(host.id)); return p; }, { replace: true })}
|
|
className={`flex w-full items-center gap-2 border-b px-3 py-2 text-left hover:bg-muted/50 ${
|
|
selected === String(host.id) ? "bg-muted" : ""
|
|
} ${host.enabled ? "" : "opacity-50"}`}
|
|
>
|
|
<Globe className="size-4 shrink-0 text-muted-foreground" />
|
|
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{host.domain_names.join(", ")}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
→ {host.forward_scheme}://{host.forward_host}:{host.forward_port}
|
|
</span>
|
|
</span>
|
|
|
|
{/* Certificate state, not certificate identity — "is it https" is the question here. */}
|
|
{host.certificate_id ? (
|
|
<Lock className={`size-3.5 shrink-0 ${host.ssl_forced ? "text-emerald-500" : "text-muted-foreground"}`} />
|
|
) : (
|
|
<LockOpen className="size-3.5 shrink-0 text-amber-500" />
|
|
)}
|
|
{host.access_list_id ? <Users className="size-3.5 shrink-0 text-sky-500" /> : null}
|
|
{offline ? <AlertTriangle className="size-3.5 shrink-0 text-destructive" /> : null}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
|
|
{selected ? (
|
|
<footer className="border-t p-2">
|
|
<ToggleButton
|
|
hosts={hosts}
|
|
selected={selected}
|
|
busy={busy}
|
|
onToggle={(host) => void toggle(host)}
|
|
/>
|
|
</footer>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ToggleButton = ({
|
|
hosts,
|
|
selected,
|
|
busy,
|
|
onToggle,
|
|
}: {
|
|
hosts: ProxyHost[];
|
|
selected: string;
|
|
busy: number | null;
|
|
onToggle: (host: ProxyHost) => void;
|
|
}) => {
|
|
const host = hosts.find((h) => String(h.id) === selected);
|
|
if (!host) return null;
|
|
return (
|
|
<button
|
|
type="button"
|
|
disabled={busy === host.id}
|
|
onClick={() => onToggle(host)}
|
|
className="flex w-full items-center justify-center gap-2 rounded-md border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
|
|
>
|
|
<Power className="size-4" />
|
|
{busy === host.id ? "Working…" : host.enabled ? "Take out of service" : "Put back in service"}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* NPM answers 404 when unauthenticated, so a naive client shows "not found" for a wrong password. The
|
|
* server marks that case `likelyAuth`; this turns it into a sentence that points somewhere useful.
|
|
*/
|
|
const Failed = ({ failure, onRetry }: { failure: Failure; onRetry: () => void }) => (
|
|
<div className="flex size-full flex-col items-center justify-center gap-3 p-6 text-center">
|
|
<ShieldCheck className="size-8 text-muted-foreground" />
|
|
{failure.unconfigured ? (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">No Nginx Proxy Manager connection is set.</p>
|
|
<p className="max-w-xs text-xs text-muted-foreground">The owner sets it in this plugin's settings.</p>
|
|
</>
|
|
) : failure.likelyAuth ? (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">Nginx Proxy Manager refused the credential.</p>
|
|
<p className="max-w-xs text-xs text-muted-foreground">
|
|
Its API answers 404 when unauthenticated, so this is almost always a wrong identity or secret rather than a
|
|
missing endpoint.
|
|
</p>
|
|
</>
|
|
) : failure.unreachable ? (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">Nginx Proxy Manager is not answering.</p>
|
|
<p className="max-w-xs text-xs text-muted-foreground">{failure.error}</p>
|
|
</>
|
|
) : (
|
|
<p className="max-w-xs text-sm text-muted-foreground">{failure.error}</p>
|
|
)}
|
|
<button type="button" onClick={onRetry} className="flex items-center gap-1 text-xs hover:text-foreground">
|
|
<RefreshCw className="size-3" /> try again
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex size-full items-center justify-center p-6 text-sm text-muted-foreground">{children}</div>
|
|
);
|