diff --git a/web/CertificatesPanel.tsx b/web/CertificatesPanel.tsx index f3df7ec..066b91b 100644 --- a/web/CertificatesPanel.tsx +++ b/web/CertificatesPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react"; import { useClient } from "hooks/useClient"; +import { readableError } from "./errors"; /** * Certificates, ordered by how soon they expire. @@ -38,7 +39,7 @@ export const CertificatesPanel = () => { } catch (caught) { // Not `.body` — `useClient` throws `ApiError(status, message)` and has no body. See `asFailure` in // HostsPanel.tsx for the full account; this panel had the same dead read. - setError(caught instanceof Error ? caught.message : "Could not load certificates."); + setError(readableError(caught, "Could not load certificates.")); setCertificates(null); } }, []); diff --git a/web/ConnectionPanel.tsx b/web/ConnectionPanel.tsx index 4c65983..2362940 100644 --- a/web/ConnectionPanel.tsx +++ b/web/ConnectionPanel.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, Loader2, Plug, ShieldAlert, Trash2, TriangleAlert } from import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { readableError } from './errors'; /** * The connection to Nginx Proxy Manager — both the setup wizard and the permanent settings page. @@ -94,9 +95,10 @@ const isForbidden = (error: unknown): boolean => (error as { status?: number })? * `useClient` throws `ApiError(status, message)` and nothing else — there is no `.body` to read, which is * the bug `HostsPanel`'s `asFailure` documents at length. The message is already the server's own text: * since 2026-08-25 the platform answers `{ error }` and the client unwraps it before throwing. + * + * `readableError` guards the case where that text is not Officer's at all — see `errors.ts`. */ -const errorText = (error: unknown): string => - error instanceof Error ? error.message : 'Something went wrong.'; +const errorText = (error: unknown): string => readableError(error); export const ConnectionPanel = () => { const client = useClient(); diff --git a/web/HostsPanel.tsx b/web/HostsPanel.tsx index eef42c6..cf26a46 100644 --- a/web/HostsPanel.tsx +++ b/web/HostsPanel.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Globe, Lock, LockOpen, Power, RefreshCw, Settings, Shiel import { useClient } from "hooks/useClient"; import { useSearchParams } from "react-router"; import { ConnectionPanel } from "./ConnectionPanel"; +import { readableError } from "./errors"; /** * The proxy hosts, which is the thing anybody opens this plugin to see. @@ -42,7 +43,7 @@ type Failure = { error: string; likelyAuth?: boolean; unreachable?: boolean; unc */ const asFailure = (error: unknown): Failure => { const status = (error as { status?: number })?.status; - const message = error instanceof Error ? error.message : "Could not load hosts."; + const message = readableError(error, "Could not load hosts."); return { error: message, unconfigured: status === 409, diff --git a/web/errors.ts b/web/errors.ts new file mode 100644 index 0000000..5093fb1 --- /dev/null +++ b/web/errors.ts @@ -0,0 +1,48 @@ +/** + * Turning a thrown request error into a sentence a person can act on. + * + * ── The one this exists for ── + * + * `useClient` throws `ApiError(status, message)` where `message` is the response body as text. That is + * right when the body came from Officer. It is badly wrong when the body came from something BETWEEN the + * browser and Officer — because then the message is a whole HTML error page, and the panel renders it + * verbatim, tags and all. + * + * Seen on 2026-08-25, on this plugin's first real test: installing it restarts `officer`, and a request + * made during that window reached Nginx Proxy Manager, found nothing behind it, and came back as + * openresty's `502 Bad Gateway` page. The panel printed the markup. + * + * The irony is worth keeping: this plugin manages the proxy that produced the page it could not read. + * + * ── Why the check is "does it look like HTML" ── + * + * There is no status that separates the two. A 502 from the proxy and a 502 from this plugin's own router + * are the same number — `api/router.ts` uses 502 for "NPM answered and refused", which is a real, useful + * answer we must not swallow. What differs is the SHAPE of the body: ours is JSON and unwrapped to a + * sentence by the client, theirs is a document. So the body is the signal, and it is a cheap one. + */ + +/** A body that is a document rather than a message. Cheap and deliberately loose. */ +const looksLikeHtml = (text: string): boolean => /^\s*<(!doctype|html|head|body)/i.test(text); + +/** + * A gateway status with an HTML body means the hop in front of Officer answered, not Officer. + * + * Both halves are required. A gateway status alone is ambiguous — this plugin's own router returns 502 + * and 503 with real meaning — and HTML alone could be anything. + */ +const isProxyError = (status: number | undefined, message: string): boolean => + (status === 502 || status === 503 || status === 504) && looksLikeHtml(message); + +export const readableError = (error: unknown, fallback = 'Something went wrong.'): string => { + const status = (error as { status?: number })?.status; + const message = error instanceof Error ? error.message : ''; + + if (isProxyError(status, message)) { + return 'Officer itself did not answer — it is probably restarting. Wait a moment and try again.'; + } + // A document from anywhere else is still not a sentence. Say what happened rather than printing markup. + if (looksLikeHtml(message)) return status ? `The server answered ${status} with a web page, not an answer.` : fallback; + + return message || fallback; +};