> = {},
+ fallback: Capability["kind"] = "absent",
+): Capabilities =>
+ Object.fromEntries(MODULES.map((module) => [module, over[module] ?? { kind: fallback }])) as Capabilities;
const client = new AdminClient({ fetcher: (async () => new Response("[]", { status: 200 })) as typeof fetch });
@@ -47,6 +50,14 @@ describe("one service's panels", () => {
}
});
+ it("a service that did not answer says SO — it is not an app without modules", () => {
+ render();
+
+ expect(screen.getByRole("alert")).toHaveTextContent(/did not answer at all/);
+ expect(screen.getByRole("alert")).toHaveTextContent(/CORS policy/);
+ expect(screen.queryByText(/composes none of them/)).toBeNull();
+ });
+
it("composedSections keeps the standard order, whatever order the probes answered in", () => {
expect(composedSections(capabilities({ campaign: { kind: "present" }, jobs: { kind: "forbidden" } }))).toEqual([
"jobs",
diff --git a/ui/console/src/sections.tsx b/ui/console/src/sections.tsx
index 3eab1a1..be83302 100644
--- a/ui/console/src/sections.tsx
+++ b/ui/console/src/sections.tsx
@@ -22,6 +22,11 @@ export function composedSections(capabilities: Capabilities | null): ModuleName[
return capabilities ? MODULES.filter((module) => capabilities[module].kind !== "absent") : [];
}
+/** True when NOTHING answered — the service is down or blocking us, not module-less. */
+export function isUnreachable(capabilities: Capabilities | null): boolean {
+ return capabilities !== null && MODULES.every((module) => capabilities[module].kind === "unreachable");
+}
+
export interface ServicePanelsProps {
client: AdminClient;
capabilities: Capabilities | null;
@@ -42,10 +47,20 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
return Discovering capabilities…
;
}
+ if (isUnreachable(capabilities)) {
+ return (
+
+ This service did not answer at all — it may be down, or its CORS policy may not allow this console's
+ origin. That is different from an app that composes no Goldpath module, and the console will not
+ pretend otherwise.
+
+ );
+ }
+
if (composedSections(capabilities).length === 0) {
return (
- No Goldpath admin surface answered here — this app composes none, or the service is unreachable.
+ No Goldpath admin surface answered here — this app composes none of them.
);
}
@@ -61,6 +76,12 @@ export function ServicePanels({ client, capabilities, section, now }: ServicePan
)}
+ {capability.kind === "unreachable" && (
+
+ {SECTION_LABEL[section]} did not answer — the service may be down, or blocking this console's origin.
+
+ )}
+
{capability.kind === "refused" && (
{SECTION_LABEL[section]} is composed here but refused this request.
diff --git a/ui/console/src/triage.test.ts b/ui/console/src/triage.test.ts
index 0dcf327..a73d222 100644
--- a/ui/console/src/triage.test.ts
+++ b/ui/console/src/triage.test.ts
@@ -304,6 +304,32 @@ describe("triage — what is wrong, read from the contract's own lists", () => {
expect(rows[0].detail).toContain("the 'goldpath-ops' role is required");
});
+ it("a service that never answered is named as such, not as five unreadable surfaces", async () => {
+ const dead = Object.fromEntries(MODULES.map((module) => [module, { kind: "unreachable" }])) as Capabilities;
+
+ const rows = await collectServiceTriage("claims", client({}), dead, NOW);
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ blind: true, tone: "danger" });
+ expect(rows[0].headline).toBe("claims did not answer at all");
+ expect(rows[0].detail).toContain("blocking this console's origin");
+ });
+
+ it("ONE dead probe among honest refusals is not an outage — the count is told, not dramatised", async () => {
+ const mixed = Object.fromEntries(
+ MODULES.map((module, index) => [
+ module,
+ index === 0 ? { kind: "unreachable" } : { kind: "forbidden", message: "the 'goldpath-ops' role is required" },
+ ]),
+ ) as Capabilities;
+
+ const rows = await collectServiceTriage("payments", client({}), mixed, NOW);
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0].headline).toBe("5 surfaces on payments cannot be read");
+ expect(rows[0].headline).not.toContain("did not answer at all");
+ });
+
it("one unreadable surface reads in the singular, and without a message says what it can", async () => {
const rows = await collectServiceTriage(
"payments",
diff --git a/ui/console/src/triage.ts b/ui/console/src/triage.ts
index 0837c26..b68c0e4 100644
--- a/ui/console/src/triage.ts
+++ b/ui/console/src/triage.ts
@@ -1,6 +1,6 @@
import { deadlineVerdict, humanizeSeconds } from "@goldpath/kit";
import { MODULES, type AdminClient, type ModuleName } from "./adminClient";
-import type { Capabilities } from "./sections";
+import { isUnreachable, type Capabilities } from "./sections";
/**
* One thing worth an operator's attention. `tone` is the semantic ramp, not a priority
@@ -54,7 +54,7 @@ export async function collectServiceTriage(
// identical "forbidden" rows would bury the estate's real problems under our own.
const unreadable = MODULES.filter((module) => {
const kind = capabilities[module].kind;
- return kind === "forbidden" || kind === "refused";
+ return kind === "forbidden" || kind === "refused" || kind === "unreachable";
});
if (unreadable.length > 0) {
@@ -65,8 +65,18 @@ export async function collectServiceTriage(
section: unreadable[0],
tone: "danger",
blind: true,
- headline: `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
- detail: said ? `the service said: “${said}”` : "this operator may not see them, or the request cannot be scoped",
+ // "Did not answer AT ALL" must mean exactly that — EVERY probe threw. One dead
+ // probe among four honest refusals is not an outage, and reporting it as one is the
+ // same false confidence this row exists to prevent (review R1 on this PR). The
+ // shell's own predicate is reused rather than re-derived.
+ headline: isUnreachable(capabilities)
+ ? `${service} did not answer at all`
+ : `${unreadable.length} surface${unreadable.length === 1 ? "" : "s"} on ${service} cannot be read`,
+ detail: said
+ ? `the service said: “${said}”`
+ : isUnreachable(capabilities)
+ ? "it may be down, or blocking this console's origin — triage cannot speak for it"
+ : "this operator may not see them, or the request cannot be scoped",
});
}