Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ ui/**/dist/

# Coverage reports are BUILD OUTPUT: regenerated by `pnpm test`, timestamped, never reviewed.
coverage/

# The console's registry is per-DEPLOYMENT config; the smoke writes its own.
ui/console/public/console.config.json
8 changes: 7 additions & 1 deletion docs/rfc/goldpath-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ custom-develop ON, with the same kit, the same way they add features to the back
for every fleet. CorPay proves it: api + payments + eod under one console today.
- **Across services**: a registry of entries `{ name, adminBaseUrl }` — config-file or
Aspire service discovery. Each service contributes its capability panels under its
name; cross-service home aggregates them.
name; cross-service home aggregates them. SHIPPED (U4, 2026-07-27): the console reads
`console.config.json` from where it is served — adding a service is a config change,
never a rebuild — with `?base=` as the dev override and same-origin as the default.
Switching service re-runs discovery from scratch: one service's panels must never
appear under another's name. A registry that fails to load is ANNOUNCED, because an
operator who configured four services and silently sees one is looking at the wrong
console.
- **Auth**: the console signs in ONCE against the adopter's IdP and carries the token
to every service — all surfaces demand `goldpath-ops` (H2); the console is just a
well-dressed client of that floor. The login-gate primitive exists in the kit.
Expand Down
15 changes: 15 additions & 0 deletions scripts/console-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ cleanup() {
[ -n "$VITE_PID" ] && kill "$VITE_PID" 2>/dev/null || true
docker rm -f "$PG_NAME" >/dev/null 2>&1 || true
docker rm -f "$MQ_NAME" >/dev/null 2>&1 || true
rm -f "$ROOT/ui/console/public/console.config.json" 2>/dev/null || true
}
trap cleanup EXIT

Expand Down Expand Up @@ -91,6 +92,20 @@ done
grep -q "CONSOLEHOST-READY" /tmp/console-smoke-secured.log || { echo "the secured app never came up:"; tail -20 /tmp/console-smoke-secured.log; exit 1; }
grep -q "CONSOLEHOST-READY" /tmp/console-smoke-tenanted.log || { echo "the tenant-scoped app never came up:"; tail -20 /tmp/console-smoke-tenanted.log; exit 1; }

# The cross-service registry the console reads at runtime (console RFC §3). Written here
# rather than committed: it names the three apps THIS run started, and an adopter's file
# names theirs. Vite serves public/ at the root, which is where the console looks.
mkdir -p "$ROOT/ui/console/public"
cat > "$ROOT/ui/console/public/console.config.json" <<JSON
{
"services": [
{ "name": "open", "adminBaseUrl": "$SERVICE_URL" },
{ "name": "auth-floored", "adminBaseUrl": "$SECURED_URL" },
{ "name": "tenant-scoped", "adminBaseUrl": "$TENANT_URL" }
]
}
JSON

echo "── the console"
(cd "$ROOT/ui/console" && pnpm dev --port 5201 --strictPort > /tmp/console-smoke-vite.log 2>&1) &
VITE_PID=$!
Expand Down
11 changes: 5 additions & 6 deletions ui/console/dev/main.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
// The dev harness: the REAL console against a real service. Point it at a running app
// with `?base=http://localhost:5xxx` (CorPay's api head) or serve it from the app itself.
// The dev harness: the REAL console against real services. Point it at one app with
// `?base=http://localhost:5xxx`, or serve `console.config.json` next to the console to
// drive a whole registry.
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./console.css";
import { Console } from "../src/Console";

const base = new URLSearchParams(window.location.search).get("base") ?? "";
import { ConsoleApp } from "../src/ConsoleApp";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<Console baseUrl={base} title={base || "same-origin"} />
<ConsoleApp />
</StrictMode>,
);
25 changes: 25 additions & 0 deletions ui/console/e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,29 @@ test.describe("the run console against a real Goldpath app", () => {
// thing it must never do here is imply the trigger landed.
await expect(page.getByText(/did not reach the server/)).toBeVisible();
});

test("one console, three services: the registry switches between them and re-discovers each", async ({ page }) => {
// No ?base= — the console reads the registry the smoke wrote, exactly as an adopter's
// console reads theirs.
await page.goto("/");

const picker = page.getByLabel(/service/i);
await expect(picker).toHaveValue("open");
await expect(page.getByTestId("run-console")).toBeVisible();
await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible();

// The auth-floored app composes the same modules but refuses this operator: the
// sections stay NAMED and the reason is the server's.
await picker.selectOption("auth-floored");
await expect(page.getByRole("alert")).toContainText("lacks the ops role");
await expect(page.getByTestId("run-console")).toHaveCount(0);

// And the tenant-scoped app refuses differently — a call it cannot scope.
await picker.selectOption("tenant-scoped");
await expect(page.getByRole("alert")).toContainText("composed here but refused this request");

// Back to the open app: its panels come back, freshly discovered.
await picker.selectOption("open");
await expect(page.getByTestId("run-console")).toBeVisible();
});
});
16 changes: 15 additions & 1 deletion ui/console/src/Console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface ConsoleProps {
title?: string;
fetcher?: typeof fetch;
now?: Date;
/** The registry's other services — omitted entirely when there is only one. */
services?: string[];
activeService?: string;
onSelectService?: (name: string) => void;
}

type Capabilities = Record<ModuleName, Capability>;
Expand All @@ -33,7 +37,15 @@ const SECTION_LABEL: Record<ModuleName, string> = {
* role, or no tenant to scope the call to — says exactly that, in the server's words:
* "absent" is reserved for a module the app genuinely does not compose.
*/
export function Console({ baseUrl, title = "Goldpath console", fetcher, now }: ConsoleProps) {
export function Console({
baseUrl,
title = "Goldpath console",
fetcher,
now,
services,
activeService,
onSelectService,
}: ConsoleProps) {
const client = useMemo(() => new AdminClient({ baseUrl, fetcher }), [baseUrl, fetcher]);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
const [section, setSection] = useState<ModuleName>("jobs");
Expand Down Expand Up @@ -65,6 +77,8 @@ export function Console({ baseUrl, title = "Goldpath console", fetcher, now }: C
title={title}
nav={nav}
activeId={section}
services={services?.map((name) => ({ name, onSelect: () => onSelectService?.(name) }))}
activeService={activeService}
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed(!collapsed)}
>
Expand Down
112 changes: 112 additions & 0 deletions ui/console/src/ConsoleApp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConsoleApp } from "./ConsoleApp";

/**
* A fake estate: two services with DIFFERENT compositions, so switching has to re-discover
* rather than reuse what it already drew.
*/
function estate(options: { registry?: unknown; registryStatus?: number } = {}) {
const asked: string[] = [];
const fetcher = (async (input: RequestInfo | URL) => {
const url = String(input);
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });

if (url.includes("console.config.json")) {
if (options.registryStatus && options.registryStatus !== 200) return new Response("", { status: options.registryStatus });
return json(
options.registry ?? {
services: [
{ name: "payments", adminBaseUrl: "https://payments.internal" },
{ name: "claims", adminBaseUrl: "https://claims.internal" },
],
},
);
}

asked.push(url);
// payments composes jobs + bulk; claims composes archival only.
if (url.startsWith("https://payments.internal")) {
if (url.includes("/jobs/fleets")) return json([]);
if (url.includes("/bulk/definitions")) return json([]);
return new Response("", { status: 404 });
}

if (url.startsWith("https://claims.internal")) {
if (url.includes("/archival/definitions")) return json([]);
return new Response("", { status: 404 });
}

return new Response("", { status: 404 });
}) as typeof fetch;

return { fetcher, asked };
}

describe("the console across services", () => {
it("lists the registry's services and lands on the first", async () => {
const { fetcher } = estate();
render(<ConsoleApp fetcher={fetcher} search="" />);

const picker = await screen.findByLabelText(/service/i);
expect(picker).toHaveValue("payments");
expect(await screen.findByRole("button", { name: "Bulk intake" })).toBeInTheDocument();
});

it("switching service RE-DISCOVERS — one service's panels never appear under another's name", async () => {
const { fetcher, asked } = estate();
render(<ConsoleApp fetcher={fetcher} search="" />);

await screen.findByRole("button", { name: "Bulk intake" });

await userEvent.selectOptions(await screen.findByLabelText(/service/i), "claims");

// Claims composes archival only: bulk must be GONE, not carried over.
expect(await screen.findByRole("button", { name: "Archival" })).toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole("button", { name: "Bulk intake" })).toBeNull());

// And the probes went to the claims service, not to payments again.
expect(asked.some((url) => url.startsWith("https://claims.internal"))).toBe(true);
});

it("a single-service console shows no picker at all — nothing to choose between", async () => {
const fetcher = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("console.config.json")) return new Response("", { status: 404 });
return new Response(JSON.stringify([]), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;

render(<ConsoleApp fetcher={fetcher} search="" />);

await screen.findByRole("button", { name: "Runs" });
expect(screen.queryByLabelText(/service/i)).toBeNull();
});

it("a registry that failed to load is ANNOUNCED — an operator who configured four services must not silently see one", async () => {
const { fetcher } = estate({ registryStatus: 500 });
render(<ConsoleApp fetcher={fetcher} search="" />);

expect(await screen.findByRole("alert")).toHaveTextContent(/service registry answered 500 — showing this service only/);
});

it("a partially broken registry names the loss — the console did NOT fall back, it is incomplete", async () => {
const { fetcher } = estate({
registry: { services: [{ name: "payments", adminBaseUrl: "https://payments.internal" }, { adminBaseUrl: "https://nameless.internal" }] },
});
render(<ConsoleApp fetcher={fetcher} search="" />);

const banner = await screen.findByRole("alert");
expect(banner).toHaveTextContent("1 registry entry has no name and was skipped");
expect(banner).toHaveTextContent("missing a service you configured");
expect(banner).not.toHaveTextContent("showing this service only");
});

it("?base= drives one named service, whatever the registry says", async () => {
const { fetcher } = estate();
render(<ConsoleApp fetcher={fetcher} search="?base=https://payments.internal" />);

await screen.findByRole("button", { name: "Bulk intake" });
expect(screen.queryByLabelText(/service/i)).toBeNull(); // one service: no picker
});
});
68 changes: 68 additions & 0 deletions ui/console/src/ConsoleApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Banner } from "@goldpath/kit";
import { Console } from "./Console";
import { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry";

export interface ConsoleAppProps {
fetcher?: typeof fetch;
/** Injected in tests; defaults to the browser's own query string. */
search?: string;
now?: Date;
}

/**
* The console, across services (console RFC §3 + D2). It owns exactly two things the
* per-service screen must not: WHICH services exist (config, not discovery) and which one
* the operator is looking at.
*
* Each service gets its own `Console` instance, keyed by name, so switching re-runs
* capability discovery from scratch — a payments service's panels must never be shown
* under a claims service's name because the shell happened to reuse the component.
*/
export function ConsoleApp({ fetcher, search, now }: ConsoleAppProps) {
const [services, setServices] = useState<ServiceEntry[] | null>(null);
const [problem, setProblem] = useState<{ text: string; fellBack: boolean } | null>(null);
const [active, setActive] = useState<string | null>(null);

useEffect(() => {
let live = true;
void loadRegistry(fetcher, search).then((result) => {
if (!live) return;
setServices(result.services);
setProblem(result.problem ? { text: result.problem, fellBack: result.fellBack === true } : null);
setActive((current) => current ?? result.services[0]?.name ?? null);
});
return () => {
live = false;
};
}, [fetcher, search]);

if (services === null) {
return <p className="p-6 text-sm text-muted-foreground">Reading the service registry…</p>;
}

const service = services.find((entry) => entry.name === active) ?? services[0] ?? SAME_ORIGIN;

return (
<>
{problem && (
// Config that failed to load is NOT a quiet fallback: an operator who configured
// four services and sees one is looking at the wrong console.
<Banner tone="warning" live="alert">
{problem.text}
{problem.fellBack ? " — showing this service only." : " — the console is missing a service you configured."}
</Banner>
)}
<Console
key={service.name}
baseUrl={service.adminBaseUrl}
title={service.name}
fetcher={fetcher}
now={now}
services={services.length > 1 ? services.map((entry) => entry.name) : undefined}
activeService={service.name}
onSelectService={setActive}
/>
</>
);
}
2 changes: 2 additions & 0 deletions ui/console/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { AdminClient, AdminHttpError, MODULES, type AdminClientOptions, type ModuleName, type FleetInfo, type JobInfo, type RunSummary, type RunDetail, type AdminResult } from "./adminClient";
export { RunConsole, type RunConsoleProps } from "./RunConsole";
export { Console, type ConsoleProps } from "./Console";
export { ConsoleApp, type ConsoleAppProps } from "./ConsoleApp";
export { loadRegistry, SAME_ORIGIN, type ServiceEntry } from "./registry";
Loading
Loading