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
5 changes: 3 additions & 2 deletions docs/getting-started/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file modified docs/images/portal/about-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/about-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-detail-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-detail-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/config-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/config-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/dashboard-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/dashboard-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/discovery-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/discovery-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/endpoints-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/endpoints-detail-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/endpoints-detail-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/endpoints-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/keys-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/keys-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 18 additions & 1 deletion docs/operations/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,6 +53,23 @@ and a curl hint for invoking the route directly.
![Endpoints catalog with the right-pane detail card for a selected route, light theme](../images/portal/endpoints-detail-light.png#only-light)
![Endpoints catalog with the right-pane detail card for a selected route, dark theme](../images/portal/endpoints-detail-dark.png#only-dark)

## Discovery

A native OpenAPI 3.1 reference rendered directly from
[/openapi.json](../reference/http-api.md#discovery), with full
light / dark parity against the portal's design tokens. Operations are
grouped by tag in a left rail; each card expands to show parameters,
request body schema, responses, and a minimal example synthesized from
the schema. The page-level filter searches path, summary, and
operationId across every operation.

The canonical Redoc view at `/docs` is still served by the binary and
is linked from the page header (top right) for operators who prefer
it.

![Discovery page with the tag rail, operation list, and an expanded operation card, light theme](../images/portal/discovery-light.png#only-light)
![Discovery page with the tag rail, operation list, and an expanded operation card, dark theme](../images/portal/discovery-dark.png#only-dark)

## Audit log

The Audit page is the filterable, paginated event view. Filters cover
Expand Down
3 changes: 2 additions & 1 deletion docs/overrides/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ <h2>Inspect every request from the browser</h2>
["audit", "Audit log", "Every request, filterable by method, path, and success / error. Auto-refreshing on a 5-second interval; click a row to inspect the full request and response."],
["audit-detail", "Inspection panel", "Side-pane detail card with timestamp, duration, request id, identity, remote address, byte counts, and the full headers / query / body trees for both sides of the call."],
["endpoints", "Endpoints", "Catalog of every registered route, grouped by behavior — identity, deterministic data, echo, controlled failure modes."],
["endpoints-detail", "Endpoint detail", "Method, path, group, auth requirement, and an inline curl hint per route. Try-It panel arrives with the OpenAPI generator in."],
["endpoints-detail", "Endpoint detail", "Method, path, group, auth requirement, and an inline curl hint per route, plus a Try-It panel that dispatches a real call through the portal API."],
["discovery", "Discovery", "Native OpenAPI 3.1 reference rendered from /openapi.json — groups, operations, parameters, schemas, examples — with full light / dark parity against the portal's design tokens."],
["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."],
["config", "Config", "Read-only view of the running server config, with secrets masked. Useful for sanity-checking what's actually loaded."],
["about", "About", "Build info plus the same well-known metadata an MCP / API client sees: api endpoint, OIDC issuer, audience."]
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions scripts/screenshots/screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ const PAGES = [
await page.waitForTimeout(400);
} },

// discovery: native OpenAPI 3.1 reference rendered from /openapi.json.
// Capture an expanded operation so the screenshot communicates what the
// page actually does (parameter tables, response schema, examples) and
// not just the closed-card list.
{ slug: "discovery",
path: "/portal/discovery",
requiresAuth: true,
prep: async (page) => {
await page.waitForSelector('section button code', { timeout: 5000 });
// Click the first operation card so its parameter/response/schema
// detail expands.
await page.locator('section button').first().click();
await page.waitForTimeout(400);
} },

// audit-detail: click the first row so the right-pane EventDetail card
// (timestamp, duration, request_id, auth, user, remote, bytes, plus
// request/response headers / query / body trees) is rendered.
Expand Down
11 changes: 9 additions & 2 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ export default function App() {
}

return (
<div className="grid grid-cols-[14rem_1fr] min-h-full bg-background text-foreground">
<aside className="border-r border-border bg-card p-4 flex flex-col">
// h-full (not min-h-full) so <main> is height-constrained: with
// min-h-full the outer grid grows with its content and the
// document does the scrolling, which means main's overflow-auto
// never fires and position: sticky inside any page has no scroll
// context to anchor to. h-full pins the chrome to the viewport so
// main owns the scroll, sticky works, and pages get the
// h-[calc(100vh-...)] sizing they already assume.
<div className="grid grid-cols-[14rem_1fr] h-full bg-background text-foreground">
<aside className="border-r border-border bg-card p-4 flex flex-col overflow-y-auto">
<SidebarBrand />
<nav className="flex flex-col gap-1">
{NAV.map((it) => (
Expand Down
148 changes: 117 additions & 31 deletions ui/src/pages/Audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function Audit() {
return (
<div className="space-y-4 max-w-6xl">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-semibold">Audit</h1>
<h1 className="text-2xl font-semibold tracking-tight">Audit</h1>
<div className="text-xs text-muted-foreground">
{q.data ? `${q.data.events.length} of ${q.data.total}` : "…"}
</div>
Expand All @@ -42,11 +42,37 @@ export default function Audit() {
</select>
</div>

<div className="grid grid-cols-[2fr_3fr] gap-4">
{/*
Fixed-width left column + flexible right column on lg+; stacked on
narrow viewports. `minmax(0,1fr)` on the right cell is required:
without the 0 floor, grid children inherit `min-width: auto` and
the right column fights the left when JSON values are long.
`table-fixed` + an explicit `<colgroup>` pin the inner table so
the cells honor the declared widths.
*/}
<div className="grid grid-cols-1 lg:grid-cols-[460px_minmax(0,1fr)] gap-4">
<div className="bg-card text-card-foreground border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-muted-foreground">
<tr>
<table className="w-full text-sm table-fixed">
{/*
Column widths are sized empirically (measured via getBoundingClientRect):
- Time: 76px fits "23:59:59" in mono text-xs with breathing room.
We deliberately use 24-hour format so a 12-hour "PM" suffix
can't push the column wider than the colgroup declares.
- Method: 88px fits the widest pill — DELETE (~78px rendered)
and OPTIONS (~80px rendered) — including px-1.5 padding,
tracking-wider, and ring. 72px was too narrow and DELETE
pills overflowed the Path column by ~7px.
- Status: 64px fits a 3-digit pill ("500") right-aligned.
- Path takes the remainder and truncates with a title tooltip.
*/}
<colgroup>
<col className="w-[76px]" />
<col className="w-[88px]" />
<col />
<col className="w-[64px]" />
</colgroup>
<thead className="bg-muted/40 text-muted-foreground border-b border-border">
<tr className="text-xs uppercase tracking-wider">
<th className="text-left px-3 py-2 font-medium">Time</th>
<th className="text-left px-3 py-2 font-medium">Method</th>
<th className="text-left px-3 py-2 font-medium">Path</th>
Expand All @@ -58,13 +84,17 @@ export default function Audit() {
<tr
key={e.id}
onClick={() => setSelected(e.id)}
className={`border-t border-border cursor-pointer hover:bg-muted/40 ${selected === e.id ? "bg-muted/60" : ""}`}
className={`border-t border-border/60 cursor-pointer transition-colors hover:bg-muted/40 ${selected === e.id ? "bg-muted/60" : ""}`}
>
<td className="px-3 py-1.5 text-muted-foreground mono text-xs">{new Date(e.timestamp).toLocaleTimeString()}</td>
<td className="px-3 py-1.5 mono">{e.method}</td>
<td className="px-3 py-1.5 mono truncate max-w-xs">{e.path}</td>
<td className="px-3 py-1.5 text-right mono">
<span className={statusColor(e.status)}>{e.status}</span>
<td className="px-3 py-2 text-muted-foreground mono text-xs whitespace-nowrap">
{formatTime(e.timestamp)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<MethodPill method={e.method} />
</td>
<td className="px-3 py-2 mono text-xs truncate" title={e.path}>{e.path}</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<StatusPill status={e.status} />
</td>
</tr>
))}
Expand All @@ -74,7 +104,20 @@ export default function Audit() {
</tbody>
</table>
</div>
<div>
{/*
Sticky detail panel. Click an event at the bottom of a long
list and you should NOT have to scroll back to the top to see
the detail. `lg:sticky lg:top-6 lg:self-start` pins the panel
to the top of the viewport (top-6 = 1.5rem, matching the
parent <main className="p-6"> padding). `lg:self-start` is
required: without it the right grid track stretches to the
left track's height and `sticky` has nothing to anchor to.
`lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto` lets the
detail itself scroll when its JSON payloads exceed the
viewport — so long bodies don't push the sticky panel off
screen.
*/}
<div className="min-w-0 lg:sticky lg:top-6 lg:self-start lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
{selected ? <EventDetail id={selected} /> : <div className="text-muted-foreground text-sm">Click a row to inspect the request.</div>}
</div>
</div>
Expand All @@ -88,23 +131,26 @@ function EventDetail({ id }: { id: string }) {
if (q.error || !q.data) return <div className="text-destructive text-sm">Failed to load event.</div>;
const e = q.data;
return (
<div className="bg-card text-card-foreground border border-border rounded-lg p-4 space-y-3 text-sm">
<div className="flex items-baseline justify-between">
<div className="font-semibold mono">{e.method} {e.path}</div>
<span className={`mono ${statusColor(e.status)}`}>{e.status}</span>
<div className="bg-card text-card-foreground border border-border rounded-lg p-4 space-y-4 text-sm min-w-0">
<div className="flex items-center justify-between gap-3 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<MethodPill method={e.method} />
<code className="mono text-sm font-medium truncate min-w-0" title={e.path}>{e.path}</code>
</div>
<StatusPill status={e.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs min-w-0">
<Field label="Timestamp" value={new Date(e.timestamp).toLocaleString()} />
<Field label="Duration" value={`${e.duration_ms}ms`} />
<Field label="Request ID" value={e.request_id || "-"} />
<Field label="Request ID" value={e.request_id || "-"} mono />
<Field label="Auth" value={e.auth_type || "-"} />
<Field label="User" value={e.user_email || e.user_subject || "-"} />
<Field label="Remote" value={e.remote_addr || "-"} />
<Field label="Bytes in" value={String(e.bytes_in)} />
<Field label="Bytes out" value={String(e.bytes_out)} />
<Field label="Remote" value={e.remote_addr || "-"} mono />
<Field label="Bytes in" value={String(e.bytes_in)} mono />
<Field label="Bytes out" value={String(e.bytes_out)} mono />
</div>
{e.payload && (
<div className="space-y-2">
<div className="space-y-3 pt-2 border-t border-border">
{e.payload.request_headers && <JsonView label="request_headers" value={e.payload.request_headers} />}
{e.payload.request_query && <JsonView label="request_query" value={e.payload.request_query} />}
{e.payload.request_body && <JsonView label="request_body" value={tryParseJSON(e.payload.request_body)} />}
Expand All @@ -116,6 +162,17 @@ function EventDetail({ id }: { id: string }) {
);
}

// formatTime renders an ISO timestamp as locale-independent 24-hour HH:MM:SS.
// Built explicitly (not via toLocaleTimeString) so a user with an en-US
// locale doesn't get "12:34:56 PM" that overflows the 76px Time column.
function formatTime(iso: string): string {
const d = new Date(iso);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}

function tryParseJSON(s: string): unknown {
try {
return JSON.parse(s);
Expand All @@ -124,11 +181,11 @@ function tryParseJSON(s: string): unknown {
}
}

function Field({ label, value }: { label: string; value: string }) {
function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="text-muted-foreground">{label}</div>
<div className="mono truncate" title={value}>{value}</div>
<div className="min-w-0">
<div className="text-muted-foreground text-[10px] uppercase tracking-wider mb-0.5">{label}</div>
<div className={`truncate ${mono ? "mono" : ""}`} title={value}>{value}</div>
</div>
);
}
Expand All @@ -145,9 +202,38 @@ function FilterInput({ placeholder, value, onChange }: { placeholder: string; va
);
}

function statusColor(status: number): string {
if (status >= 500) return "text-destructive";
if (status >= 400) return "text-destructive/80";
if (status >= 300) return "text-muted-foreground";
return "text-success";
// Method/status pills are pulled into local helpers (no Discovery import)
// to avoid a cross-page coupling. The HSL palette is the same one
// Discovery uses, replicated here to keep both pages visually consistent;
// if a third surface ever needs the colors, factor into ui/src/lib/.
const METHOD_PILL: Record<string, string> = {
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",
};

function MethodPill({ method }: { method: string }) {
const cls = METHOD_PILL[method.toUpperCase()] ?? METHOD_PILL.OPTIONS;
return (
<span className={`mono text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${cls}`}>
{method}
</span>
);
}

function StatusPill({ status }: { status: number }) {
let cls = "bg-slate-500/15 text-slate-700 dark:text-slate-300 ring-1 ring-slate-500/30";
if (status >= 500) cls = "bg-rose-500/15 text-rose-700 dark:text-rose-300 ring-1 ring-rose-500/30";
else if (status >= 400) cls = "bg-amber-500/15 text-amber-700 dark:text-amber-300 ring-1 ring-amber-500/30";
else if (status >= 300) cls = "bg-sky-500/15 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/30";
else if (status >= 200) cls = "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 ring-1 ring-emerald-500/30";
return (
<span className={`mono text-[10px] font-semibold tabular-nums px-1.5 py-0.5 rounded ${cls}`}>
{status}
</span>
);
}
Loading
Loading