From ed71efbadb3948f17c56e076c19ff09587108261 Mon Sep 17 00:00:00 2001 From: cjimti Date: Mon, 11 May 2026 00:42:19 -0700 Subject: [PATCH 1/3] portal: stable audit list column + native dark-mode OpenAPI viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit: the event list column visibly resized whenever the right detail pane grew (long JSON header values pushed the proportional 2fr_3fr grid to give the right column more space). Replaced with a fixed left column and a min-w-0 right cell: grid-cols-1 lg:grid-cols-[420px_minmax(0,1fr)] minmax(0,1fr) is the load-bearing token — without the 0 floor, grid children inherit min-width: auto and the right column fights for space. Inner table now uses table-fixed with an explicit colgroup so the columns honor declared widths even when the path contains long tokens. EventDetail gained min-w-0 / truncate discipline so long Method+Path headers don't blow out the right pane. Discovery: dropped the /docs (Redoc) iframe. Redoc inside the iframe renders light-only and is unreadable against the portal's dark shell; piping a theme into the build-time Redoc HTML is heavier than rendering the OpenAPI document natively. Replaced with a native OpenAPI 3.1 reference view that fetches /openapi.json and renders groups, operations (collapsible cards), parameters, request body schema, responses, and per-media-type examples — all against the portal's CSS variables, so light and dark reach full parity. Includes a tag rail, search/filter, and per-block clipboard copy. /docs is still served and is linked from the page header for the canonical Redoc view. Accessibility / robustness: - aria-expanded on every disclosure button (operation cards, response rows, tag rail uses aria-pressed for filter state). - Tag rail and operation header use min-w-0 + truncate so long names and summaries don't push out the rest of the row. - Response rows with no body render a non-interactive div so keyboard users don't tab through a button that does nothing. - CodeBlock copy-feedback timer is cleared on unmount (useRef + useEffect) so React strict-mode double-mount doesn't leave a timer firing on a dead component. - Discovery fetch uses AbortController so a fast unmount cancels the in-flight request. - Loading / missing / error states show appropriate subtitles and hide the JSON/YAML/Redoc download links (they 404 together with /openapi.json — offering them when missing is just dead-link theater). Docs: portal.md, http-api.md, and overview.md all advertised Discovery as a Redoc/Swagger UI iframe — updated to describe the native renderer and /docs as a sibling link. --- docs/getting-started/overview.md | 5 +- docs/operations/portal.md | 2 +- docs/reference/http-api.md | 5 +- ui/src/pages/Audit.tsx | 41 +- ui/src/pages/Discovery.tsx | 756 ++++++++++++++++++++++++++++--- 5 files changed, 721 insertions(+), 88 deletions(-) diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index ee81750..cf8d2ef 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -40,8 +40,9 @@ behind that connection — the thing the gateway actually talks to. headers and bodies, the resolved identity, the response status and size, and the duration. - **Portal**: React 19 SPA embedded in the binary; Dashboard, - Endpoints with Try-It, Audit, API Keys, Config, Discovery (Redoc/Swagger - UI over `/openapi.json`). + Endpoints with Try-It, Audit, API Keys, Config, Discovery (native + OpenAPI 3.1 reference rendered from `/openapi.json`, with `/docs` + Redoc available as a sibling). - **OpenAPI document** at `/openapi.{json,yaml}`, generated in-tree from the registered endpoint metadata so it can't drift from the served routes. diff --git a/docs/operations/portal.md b/docs/operations/portal.md index e25bbfc..3335118 100644 --- a/docs/operations/portal.md +++ b/docs/operations/portal.md @@ -22,7 +22,7 @@ operator session cookie established via OIDC PKCE. | **Audit** | Filterable, paginated event view; click a row for the full request/response drawer with redaction overlays. | | **API Keys** | Create / revoke Postgres-backed bcrypt keys. Plaintext shown once. | | **Config** | Read-only YAML of the running server, with secrets masked. | -| **Discovery** | Redoc/Swagger UI iframe over `/openapi.json`; click-to-copy connection-registration YAML for the Plexara admin API. | +| **Discovery** | Native OpenAPI 3.1 reference rendered from `/openapi.json` (groups, operations, parameters, schemas, response samples; full dark/light parity with the portal). Links out to `/docs` (Redoc) for the canonical view. | | **About** | Build info + "test against Plexara" cheat sheet. | ## Authentication diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 18be160..c6c97e4 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -39,7 +39,10 @@ GET /docs ``` Renders a Redoc / Swagger UI view of `/openapi.json` for human -inspection. The portal's Discovery page iframes this. +inspection. The portal's Discovery page renders its own native +reference against the same `/openapi.json` document (for full +light/dark parity with the portal); `/docs` is still served as the +canonical Redoc view and is linked from the portal header. ## Well-known metadata diff --git a/ui/src/pages/Audit.tsx b/ui/src/pages/Audit.tsx index ea485bf..05a4eef 100644 --- a/ui/src/pages/Audit.tsx +++ b/ui/src/pages/Audit.tsx @@ -42,9 +42,26 @@ export default function Audit() { -
+ {/* + Fixed-width left column + flexible right column on lg+; stacked on + narrow viewports. `grid-cols-[420px_minmax(0,1fr)]` pins the event + list to a stable width so long JSON payloads on the right + (headers/body) can't push the list to reflow. `minmax(0,1fr)` is + the critical part: without the 0 floor, grid children inherit + `min-width: auto` (i.e. "wide enough to contain longest token"), + which makes the right pane fight for space whenever a header + value is long. `table-fixed` on the inner table forces declared + column widths instead of letting the browser size them to content. + */} +
- +
+ + + + + + @@ -60,10 +77,10 @@ export default function Audit() { onClick={() => setSelected(e.id)} className={`border-t border-border cursor-pointer hover:bg-muted/40 ${selected === e.id ? "bg-muted/60" : ""}`} > - - - - + + + @@ -74,7 +91,7 @@ export default function Audit() {
Time{new Date(e.timestamp).toLocaleTimeString()}{e.method}{e.path} + {new Date(e.timestamp).toLocaleTimeString()}{e.method}{e.path} {e.status}
-
+
{selected ? :
Click a row to inspect the request.
}
@@ -88,12 +105,12 @@ function EventDetail({ id }: { id: string }) { if (q.error || !q.data) return
Failed to load event.
; const e = q.data; return ( -
-
-
{e.method} {e.path}
- {e.status} +
+
+
{e.method} {e.path}
+ {e.status}
-
+
diff --git a/ui/src/pages/Discovery.tsx b/ui/src/pages/Discovery.tsx index 978c311..dab993f 100644 --- a/ui/src/pages/Discovery.tsx +++ b/ui/src/pages/Discovery.tsx @@ -1,93 +1,705 @@ -// Discovery page: embeds the Redoc viewer served at /docs (rendered -// against /openapi.json by the oapi-generator backend). The iframe -// loads the same /docs URL an operator would visit directly; the -// portal just frames it alongside the rest of the sidebar nav. +// Discovery page: native OpenAPI 3.1 reference view. +// +// We deliberately do NOT iframe /docs (Redoc). Reasons, in order of +// importance: +// 1. Design-system parity. The portal's CSS variables (light/dark) +// cannot reach into a same-origin iframe to restyle the embedded +// Redoc bundle; the operator sees a light Redoc against a dark +// shell. (Redoc itself supports a theme config, but plumbing it +// through a build-time-rendered docs HTML is a heavier lift than +// rendering the OpenAPI document directly here.) +// 2. In-portal search/filter — the iframe can't participate in the +// portal's tag rail or query bar. +// 3. Clipboard copy works without sandbox gymnastics. +// +// /docs is still linked from the page header for operators who want the +// canonical Redoc view; the link opens in a new tab. -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Search, ChevronDown, ChevronRight, Lock, Copy, Check, ExternalLink } from "lucide-react"; + +type OpenAPIDoc = { + openapi?: string; + info?: { title?: string; version?: string; description?: string }; + tags?: Array<{ name: string; description?: string }>; + paths?: Record>; + components?: { schemas?: Record; securitySchemes?: Record }; +}; + +type Operation = { + tags?: string[]; + summary?: string; + description?: string; + operationId?: string; + parameters?: Parameter[]; + requestBody?: { description?: string; required?: boolean; content?: Record }; + responses?: Record; + security?: Array>; + deprecated?: boolean; +}; + +type Parameter = { name: string; in: "query" | "header" | "path" | "cookie"; required?: boolean; description?: string; schema?: Schema }; +type MediaType = { schema?: Schema; example?: unknown; examples?: Record }; +type ResponseObject = { description?: string; content?: Record; headers?: Record }; +type Schema = { + type?: string | string[]; + format?: string; + description?: string; + enum?: unknown[]; + default?: unknown; + example?: unknown; + examples?: unknown[]; + items?: Schema; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | Schema; + $ref?: string; + oneOf?: Schema[]; + anyOf?: Schema[]; + allOf?: Schema[]; + nullable?: boolean; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + // OpenAPI 3.1 / JSON Schema allows arbitrary extension keywords. + [key: string]: unknown; +}; + +const METHODS = ["get", "post", "put", "patch", "delete", "options", "head"] as const; +type Method = (typeof METHODS)[number]; + +// Method palette is fixed in HSL so the badges stay legible against both +// the light card background and the dark slate. Each color stays inside +// the WCAG AA contrast band for foreground/background pairs we ship. +const METHOD_COLOR: Record = { + get: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/30", + post: "bg-sky-500/15 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/30", + put: "bg-amber-500/15 text-amber-700 dark:text-amber-300 ring-1 ring-amber-500/30", + patch: "bg-violet-500/15 text-violet-700 dark:text-violet-300 ring-1 ring-violet-500/30", + delete: "bg-rose-500/15 text-rose-700 dark:text-rose-300 ring-1 ring-rose-500/30", + options: "bg-slate-500/15 text-slate-700 dark:text-slate-300 ring-1 ring-slate-500/30", + head: "bg-slate-500/15 text-slate-700 dark:text-slate-300 ring-1 ring-slate-500/30", +}; export default function Discovery() { - const [reachable, setReachable] = useState<"checking" | "ok" | "missing">("checking"); + const [doc, setDoc] = useState(null); + const [status, setStatus] = useState<"loading" | "ok" | "missing" | "error">("loading"); + const [query, setQuery] = useState(""); + const [activeTag, setActiveTag] = useState("all"); useEffect(() => { - // Probe /openapi.json so we can show a friendly empty-state if - // the operator is running a build without the oapi-generator - // surface mounted. We HEAD the JSON rather than the HTML because - // a 404 on /openapi.json is the most reliable signal that the - // OpenAPI surface isn't wired up; the /docs HTML may exist as a - // static asset even when the generator is absent. - fetch("/openapi.json", { method: "HEAD", credentials: "include" }) - .then((r) => setReachable(r.ok ? "ok" : "missing")) - .catch(() => setReachable("missing")); + const ctrl = new AbortController(); + fetch("/openapi.json", { credentials: "include", signal: ctrl.signal }) + .then(async (r) => { + if (!r.ok) { + setStatus(r.status === 404 ? "missing" : "error"); + return; + } + setDoc(await r.json()); + setStatus("ok"); + }) + .catch((err) => { + if (err?.name === "AbortError") return; + setStatus("error"); + }); + return () => ctrl.abort(); }, []); + const groups = useMemo(() => groupOperations(doc), [doc]); + const tags = useMemo(() => ["all" as const, ...groups.map((g) => g.tag)], [groups]); + const filtered = useMemo(() => filterGroups(groups, query, activeTag), [groups, query, activeTag]); + + if (status === "loading") { + return
Loading OpenAPI document…
; + } + if (status === "missing") { + return ( + +
+
OpenAPI surface not available.
+

+ This deployment of api-test is running without the OpenAPI generator surface + mounted. The Discovery page expects /openapi.json to + be reachable; the request returned 404. +

+
+
+ ); + } + if (status === "error" || !doc) { + return ( + +
+ Failed to load /openapi.json. +
+
+ ); + } + return ( -
-
-
-

Discovery

-
- OpenAPI 3.1 reference rendered from{" "} - /openapi.json - {" "}via{" "} - /docs. + +
+ {/* Sticky tag rail. */} + + + {/* Main scroll area. */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Filter by path, summary, or operationId…" + className="w-full pl-8 pr-3 py-2 bg-background border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ {filtered.length === 0 && ( +
No operations match.
+ )} + {filtered.map((g) => ( +
+
+

{g.tag}

+ {g.operations.length} + {g.description && ( + {g.description} + )} +
+
+ {g.operations.map((op) => ( + + ))} +
+
+ ))} +
+
+
+
+ ); +} + +// DiscoveryShell holds the page chrome (title row + downloads + bordered +// frame) and slots the variable content underneath. Pulled out so the +// loading / missing / error states get the same chrome as the live view. +function DiscoveryShell({ + info, + status = "ok", + children, +}: { + info?: OpenAPIDoc["info"]; + status?: "ok" | "loading" | "missing" | "error"; + children: React.ReactNode; +}) { + const subtitle = (() => { + if (status === "loading") return "Fetching /openapi.json…"; + if (status === "missing") return "/openapi.json is not mounted on this deployment."; + if (status === "error") return "Could not load /openapi.json."; + // Avoid concatenating with an em-dash: many published OpenAPI titles + // already contain em-dashes, which would render "Foo — bar — /openapi.json". + if (info?.title) return `${info.title} / /openapi.json`; + return "OpenAPI 3.1 reference / /openapi.json"; + })(); + // /openapi.json, /openapi.yaml, and /docs are all mounted together + // (pkg/httpsrv/openapi.go); a missing/errored /openapi.json means the + // sibling links also 404. Hide them in those states so we don't offer + // dead links the user just told us are broken. + const showDownloads = status === "ok"; + return ( +
+
+
+

+ Discovery + {info?.version && ( + v{info.version} + )} +

+
{subtitle}
- + {showDownloads && ( + + )}
+ {children} +
+ ); +} - {reachable === "ok" && ( - // sandbox keeps the embedded Redoc from navigating the parent - // window if a future spec extension introduces an external link - // with target=_top. allow-scripts is required for the Redoc - // bundle to render; allow-same-origin lets it fetch - // /openapi.json relative to the parent origin. -