import { useCallback, useEffect, useRef, useState } from "react"; import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, Settings, ShieldCheck, Users } from "lucide-react"; import { useClient } from "hooks/useClient"; import { useSearchParams } from "react-router"; import { ConnectionPanel } from "./ConnectionPanel"; 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=`), 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(null); const [failure, setFailure] = useState(null); const [busy, setBusy] = useState(null); const [params, setParams] = useSearchParams(); const selected = params.get("host"); // `?config=1` is the settings page; an unconfigured plugin shows it whether or not it was asked for. // In the URL rather than in state, so it survives a refresh and can be linked to — `docs/navigation-audit.md`. const configuring = params.get("config") === "1"; // 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("/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); } }; // Order matters: an explicit `?config=1` wins over everything, then "there is nothing to connect to" // wins over the generic failure screen. Otherwise the first thing a new install shows is an error. if (configuring || failure?.unconfigured) return ; if (failure) return void load()} />; if (!hosts) return Loading hosts…; return (
{hosts.length} host{hosts.length === 1 ? "" : "s"} {/* Not a Link: this panel lives inside a workspace and the target is a parameter on the page it is already on. A button that sets the parameter is the same navigation without a full route. */}
{hosts.length === 0 ? ( Nginx Proxy Manager has no proxy hosts. ) : null}
    {hosts.map((host) => { const offline = host.meta?.nginx_online === false; return (
  • ); })}
{selected ? (
void toggle(host)} />
) : null}
); }; 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 ( ); }; /** * 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 }) => (
{failure.unconfigured ? ( <>

No Nginx Proxy Manager connection is set.

The owner sets it in this plugin's settings.

) : failure.likelyAuth ? ( <>

Nginx Proxy Manager refused the credential.

Its API answers 404 when unauthenticated, so this is almost always a wrong identity or secret rather than a missing endpoint.

) : failure.unreachable ? ( <>

Nginx Proxy Manager is not answering.

{failure.error}

) : (

{failure.error}

)}
); const Centred = ({ children }: { children: React.ReactNode }) => (
{children}
);