diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 668ea07..940c85c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -56,4 +56,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90af4856ee69a657050a5670eb98513188ba # v4.0.5 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/Makefile b/Makefile index bfe0fc3..92dccd4 100644 --- a/Makefile +++ b/Makefile @@ -354,13 +354,20 @@ docs-serve: ## Playwright through every portal page. Requires the binary to ## be running on $(SHOTS_BASE_URL) (default http://localhost:8080). ## Re-run after any portal UI change. +## +## Sources .env.dev for MCPTEST_DEV_KEY so the captured-portal API +## key matches the running binary's accepted file key. Override +## SHOTS_API_KEY explicitly to point at a different deployment +## (e.g. staging); that wins over .env.dev. SHOTS_BASE_URL ?= http://localhost:8080 -SHOTS_API_KEY ?= devkey-please-change -screenshots: +screenshots: dev-secrets @command -v node >/dev/null || { echo "node is required"; exit 1; } - @cd scripts/screenshots && \ + @. ./.env.dev && \ + KEY="$${SHOTS_API_KEY:-$$MCPTEST_DEV_KEY}" && \ + if [ -z "$$KEY" ]; then echo "no API key: set SHOTS_API_KEY or run make dev-secrets"; exit 1; fi && \ + cd scripts/screenshots && \ (test -d node_modules || npm install) && \ - MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY=$(SHOTS_API_KEY) node screenshots.mjs + MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY="$$KEY" node screenshots.mjs ## run: Build and run run: build diff --git a/docs/images/portal/audit-compare-dark.png b/docs/images/portal/audit-compare-dark.png new file mode 100644 index 0000000..53f5e95 Binary files /dev/null and b/docs/images/portal/audit-compare-dark.png differ diff --git a/docs/images/portal/audit-compare-light.png b/docs/images/portal/audit-compare-light.png new file mode 100644 index 0000000..e0897b5 Binary files /dev/null and b/docs/images/portal/audit-compare-light.png differ diff --git a/docs/images/portal/audit-dark.png b/docs/images/portal/audit-dark.png index 91bc93d..52da184 100644 Binary files a/docs/images/portal/audit-dark.png and b/docs/images/portal/audit-dark.png differ diff --git a/docs/images/portal/audit-drawer-dark.png b/docs/images/portal/audit-drawer-dark.png new file mode 100644 index 0000000..d30dc8d Binary files /dev/null and b/docs/images/portal/audit-drawer-dark.png differ diff --git a/docs/images/portal/audit-drawer-light.png b/docs/images/portal/audit-drawer-light.png new file mode 100644 index 0000000..d818096 Binary files /dev/null and b/docs/images/portal/audit-drawer-light.png differ diff --git a/docs/images/portal/audit-jsonb-dark.png b/docs/images/portal/audit-jsonb-dark.png new file mode 100644 index 0000000..22e2fc4 Binary files /dev/null and b/docs/images/portal/audit-jsonb-dark.png differ diff --git a/docs/images/portal/audit-jsonb-light.png b/docs/images/portal/audit-jsonb-light.png new file mode 100644 index 0000000..cc53bdb Binary files /dev/null and b/docs/images/portal/audit-jsonb-light.png differ diff --git a/docs/images/portal/audit-light.png b/docs/images/portal/audit-light.png index 55d281e..804f13e 100644 Binary files a/docs/images/portal/audit-light.png and b/docs/images/portal/audit-light.png differ diff --git a/docs/images/portal/audit-livetail-dark.png b/docs/images/portal/audit-livetail-dark.png new file mode 100644 index 0000000..a1f23a9 Binary files /dev/null and b/docs/images/portal/audit-livetail-dark.png differ diff --git a/docs/images/portal/audit-livetail-light.png b/docs/images/portal/audit-livetail-light.png new file mode 100644 index 0000000..c9940a7 Binary files /dev/null and b/docs/images/portal/audit-livetail-light.png differ diff --git a/docs/images/portal/config-dark.png b/docs/images/portal/config-dark.png index f47a268..5fd8617 100644 Binary files a/docs/images/portal/config-dark.png and b/docs/images/portal/config-dark.png differ diff --git a/docs/images/portal/config-light.png b/docs/images/portal/config-light.png index 99f5af5..baa508c 100644 Binary files a/docs/images/portal/config-light.png and b/docs/images/portal/config-light.png differ diff --git a/docs/images/portal/dashboard-dark.png b/docs/images/portal/dashboard-dark.png index 7767782..ea3da97 100644 Binary files a/docs/images/portal/dashboard-dark.png and b/docs/images/portal/dashboard-dark.png differ diff --git a/docs/images/portal/dashboard-light.png b/docs/images/portal/dashboard-light.png index 2c15a75..233be8d 100644 Binary files a/docs/images/portal/dashboard-light.png and b/docs/images/portal/dashboard-light.png differ diff --git a/docs/images/portal/keys-dark.png b/docs/images/portal/keys-dark.png index 18327a7..9e06004 100644 Binary files a/docs/images/portal/keys-dark.png and b/docs/images/portal/keys-dark.png differ diff --git a/docs/images/portal/keys-light.png b/docs/images/portal/keys-light.png index dfa4c4e..b2f6a59 100644 Binary files a/docs/images/portal/keys-light.png and b/docs/images/portal/keys-light.png differ diff --git a/docs/images/portal/tools-dark.png b/docs/images/portal/tools-dark.png index 1625cbb..0f661e2 100644 Binary files a/docs/images/portal/tools-dark.png and b/docs/images/portal/tools-dark.png differ diff --git a/docs/images/portal/tools-light.png b/docs/images/portal/tools-light.png index df716fb..209e1df 100644 Binary files a/docs/images/portal/tools-light.png and b/docs/images/portal/tools-light.png differ diff --git a/docs/images/portal/tools-tryit-dark.png b/docs/images/portal/tools-tryit-dark.png index 2369824..efd48d9 100644 Binary files a/docs/images/portal/tools-tryit-dark.png and b/docs/images/portal/tools-tryit-dark.png differ diff --git a/docs/images/portal/tools-tryit-light.png b/docs/images/portal/tools-tryit-light.png index 0f75fad..07cef35 100644 Binary files a/docs/images/portal/tools-tryit-light.png and b/docs/images/portal/tools-tryit-light.png differ diff --git a/docs/images/portal/wellknown-dark.png b/docs/images/portal/wellknown-dark.png index f34b93b..c09cc2a 100644 Binary files a/docs/images/portal/wellknown-dark.png and b/docs/images/portal/wellknown-dark.png differ diff --git a/docs/images/portal/wellknown-light.png b/docs/images/portal/wellknown-light.png index 7affd7a..9db121e 100644 Binary files a/docs/images/portal/wellknown-light.png and b/docs/images/portal/wellknown-light.png differ diff --git a/docs/javascripts/shots.js b/docs/javascripts/shots.js index 04ddd00..f5275e5 100644 --- a/docs/javascripts/shots.js +++ b/docs/javascripts/shots.js @@ -1,10 +1,17 @@ /** - * Portal-screenshots carousel. Slides the track via `transform: translateX` - * with a CSS transition for the actual animation; JS just tracks the - * current index and computes the offset. + * Portal-screenshots carousel + lightbox. * - * Wraparound: clicking next at the last slide returns to 0; prev at 0 - * jumps to the last slide. Buttons stay enabled the whole time. + * Carousel: slides the track via `transform: translateX` with a CSS + * transition for the actual animation; JS just tracks the current index + * and computes the offset. Wrap-around is implicit; buttons stay enabled. + * + * Lightbox: clicking any frame opens a near-full-screen modal showing + * the screenshot at full size. ESC closes; arrow keys step between + * shots. Backdrop click closes. Focus is captured on open and restored + * on close. + * + * Material's instant-nav can re-mount the page; we guard re-binding via + * data attributes on each subscribed root. */ (function () { function init() { @@ -20,6 +27,9 @@ var slides = Array.from(track.querySelectorAll(".plex-shots__slide")); if (slides.length === 0) return; + var counter = root.querySelector("[data-shots-counter]"); + var lightbox = root.querySelector("[data-shots-lightbox]"); + var index = 0; function step() { @@ -34,15 +44,33 @@ slides.forEach(function (s, i) { s.classList.toggle("is-active", i === index); }); + if (counter) { + counter.innerHTML = + "" + String(index + 1).padStart(2, "0") + "" + + " / " + String(slides.length).padStart(2, "0"); + } } - next.addEventListener("click", function () { - index = (index + 1) % slides.length; - apply(); - }); - prev.addEventListener("click", function () { - index = (index - 1 + slides.length) % slides.length; + function go(delta) { + index = (index + delta + slides.length) % slides.length; apply(); + } + + next.addEventListener("click", function () { go(1); }); + prev.addEventListener("click", function () { go(-1); }); + + // Keyboard nav on the carousel itself: when focus lives within the + // stage, arrow keys advance. The lightbox owns its own keyboard + // handler when open, so this only fires while it's closed. + root.addEventListener("keydown", function (e) { + if (lightbox && !lightbox.hidden && lightbox.classList.contains("is-open")) return; + if (e.target.closest(".plex-shots__frame")) { + // Don't hijack Enter/Space on the frame button: those + // legitimately open the lightbox. + if (e.key === "Enter" || e.key === " ") return; + } + if (e.key === "ArrowLeft") { go(-1); } + else if (e.key === "ArrowRight") { go(1); } }); // Keep the offset correct across resizes (slide widths are vw-based). @@ -53,6 +81,143 @@ }); apply(); + bindLightbox(root, slides, lightbox, function (newIndex) { + // When the lightbox navigates, sync the carousel so closing + // returns the user to the slide they were last viewing. + index = newIndex; + apply(); + }, function () { return index; }); + }); + } + + function bindLightbox(root, slides, lightbox, onIndex, getIndex) { + if (!lightbox || lightbox.dataset.lightboxBound === "1") return; + lightbox.dataset.lightboxBound = "1"; + + var imgLight = lightbox.querySelector("[data-lightbox-img-light]"); + var imgDark = lightbox.querySelector("[data-lightbox-img-dark]"); + var titleEl = lightbox.querySelector("#plex-lightbox-title"); + var bodyEl = lightbox.querySelector("[data-lightbox-body]"); + var countEl = lightbox.querySelector("[data-lightbox-count]"); + var prevBtn = lightbox.querySelector("[data-lightbox-prev]"); + var nextBtn = lightbox.querySelector("[data-lightbox-next]"); + // Note: data-shots-close lives on BOTH the backdrop div and the close + // button; the close button is what we focus on open (the backdrop + // isn't focusable). The dismiss handler iterates closeBtns to attach + // click handlers to both. + var closeBtns = lightbox.querySelectorAll("[data-shots-close]"); + var closeBtn = lightbox.querySelector("button[data-shots-close]"); + + var lastFocus = null; + var current = 0; + + function fillFromSlide(slideIndex) { + var slide = slides[slideIndex]; + var frame = slide && slide.querySelector(".plex-shots__frame"); + if (!frame) return; + current = slideIndex; + + var title = frame.getAttribute("data-zoom-title") || ""; + var body = frame.getAttribute("data-zoom-body") || ""; + var light = frame.getAttribute("data-zoom-light") || ""; + var dark = frame.getAttribute("data-zoom-dark") || ""; + + if (titleEl) titleEl.textContent = title; + if (bodyEl) bodyEl.textContent = body; + if (imgLight) { + imgLight.src = light; + imgLight.alt = "Portal " + title + " screen, light theme"; + } + if (imgDark) { + imgDark.src = dark; + imgDark.alt = "Portal " + title + " screen, dark theme"; + } + if (countEl) { + countEl.textContent = + String(slideIndex + 1).padStart(2, "0") + + " / " + + String(slides.length).padStart(2, "0"); + } + } + + function open(slideIndex) { + // If a previous close() is still in its 260ms post-fade hide + // window, cancel the pending hide so the freshly-opened modal + // doesn't get yanked back to hidden mid-display. + if (lightbox._hideTimer) { + clearTimeout(lightbox._hideTimer); + lightbox._hideTimer = null; + } + lastFocus = document.activeElement; + fillFromSlide(slideIndex); + lightbox.hidden = false; + // Force a reflow so the transition runs on the next paint. + void lightbox.offsetWidth; + lightbox.classList.add("is-open"); + document.documentElement.classList.add("plex-lightbox-open"); + // Focus the close button so ESC and Enter both work immediately + // and screen-reader users land inside the dialog. + if (closeBtn) closeBtn.focus(); + window.addEventListener("keydown", onKey, true); + } + + function close() { + lightbox.classList.remove("is-open"); + document.documentElement.classList.remove("plex-lightbox-open"); + window.removeEventListener("keydown", onKey, true); + // After the fade completes, hide outright so the modal can't + // catch tab focus or screen-reader attention. + lightbox._hideTimer = setTimeout(function () { + lightbox.hidden = true; + }, 260); + onIndex(current); + if (lastFocus && typeof lastFocus.focus === "function") { + lastFocus.focus(); + } + lastFocus = null; + } + + function onKey(e) { + if (e.key === "Escape") { + e.stopPropagation(); + close(); + } else if (e.key === "ArrowLeft") { + e.stopPropagation(); + e.preventDefault(); + fillFromSlide((current - 1 + slides.length) % slides.length); + } else if (e.key === "ArrowRight") { + e.stopPropagation(); + e.preventDefault(); + fillFromSlide((current + 1) % slides.length); + } + } + + // Click handlers on the carousel frames. + slides.forEach(function (slide, i) { + var frame = slide.querySelector("[data-shots-zoom]"); + if (!frame) return; + frame.addEventListener("click", function (e) { + e.preventDefault(); + // Sync the carousel to the clicked slide first so the + // background reflects the lightbox's starting frame. open() + // handles its own pending-hide-timer cleanup. + if (i !== getIndex()) onIndex(i); + open(i); + }); + }); + + // Lightbox buttons. + if (prevBtn) prevBtn.addEventListener("click", function () { + fillFromSlide((current - 1 + slides.length) % slides.length); + }); + if (nextBtn) nextBtn.addEventListener("click", function () { + fillFromSlide((current + 1) % slides.length); + }); + closeBtns.forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.preventDefault(); + close(); + }); }); } diff --git a/docs/operations/inspection.md b/docs/operations/inspection.md index 2f7d0d4..77883f7 100644 --- a/docs/operations/inspection.md +++ b/docs/operations/inspection.md @@ -1,6 +1,6 @@ --- title: Inspection workflow -description: End-to-end walkthrough of the audit inspection utility — capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export. +description: End-to-end walkthrough of the audit inspection utility (capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export). --- # Inspection workflow @@ -17,8 +17,8 @@ The audit pipeline records every tool call. The inspection utility is the operat The pipeline captures every `tools/call` automatically when `audit.enabled: true` (default). Two tables are written in one transaction: -- `audit_events` — indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering. -- `audit_payloads` — full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only. +- `audit_events`: indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering. +- `audit_payloads`: full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only. To produce a fresh row to inspect, fire any tool. The portal's Try-It page (`/portal/tools/`) is the easiest way; any MCP client works too. @@ -26,11 +26,15 @@ To produce a fresh row to inspect, fire any tool. The portal's Try-It page (`/po In the portal, navigate to `Audit`. Each row in the events table is clickable; the click opens a side drawer with four tabs: +![Audit drawer with the four tabs (Overview / Request / Response / Notifications) open over the events table](../images/portal/audit-drawer-light.png#only-light) +![Audit drawer with the four tabs (Overview / Request / Response / Notifications) open over the events table](../images/portal/audit-drawer-dark.png#only-dark) + + ### Overview tab Timing, identity, request id, session id, source (`mcp` for real client calls, `portal-tryit` for /admin/tryit invocations, `portal-replay` for replays), and the replay linkage (`Replayed from`) when present. ### Request tab -The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true` — credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`. +The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true`; credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`. ### Response tab The full `CallToolResult` content blocks (text, image, audio, structured) plus `response_error` when the call errored. The shape matches what the SDK serializes to the wire so you can see what the client saw. A truncation warning fires when the response body was too large. @@ -41,7 +45,7 @@ Chronological list of every `notifications/*` (progress, log message) the tool d Drawer interactions: - The browser URL gets `?id=` appended so the drawer is deep-linkable; share the URL and the recipient lands on the same row. -- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected" — clicking opens the comparison page with both events. +- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected"; clicking opens the comparison page with both events. - The **Replay** button is the next step. - `Esc` and the backdrop close the drawer. @@ -59,11 +63,15 @@ Per-identity rate limit (scoped by API key id or OIDC subject): 5 burst, one tok Two events you stashed via the drawer's Compare button can be opened side-by-side at `/portal/audit/compare?a=&b=`. The page renders: +![Comparison page showing the structural diff of two audit events with summary, request_params, response_result, and notifications panels](../images/portal/audit-compare-light.png#only-light) +![Comparison page showing the structural diff of two audit events with summary, request_params, response_result, and notifications panels](../images/portal/audit-compare-dark.png#only-dark) + + - A summary block (tool, source, result, duration, user, auth type) with diffs highlighted. - Per-payload diff trees for `request_params`, `response_result`, `response_error`, plus a count comparison for notifications. - Each leaf in the tree is annotated: same (muted), differ (warning color, `before → after`), only-in-A (red `-`), only-in-B (green `+`). -The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened — not as a wall of red lines. +The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened, not as a wall of red lines. Common compare workflows: @@ -75,11 +83,15 @@ Common compare workflows: The Audit page has a **JSONB filters** toggle that opens an editor for the path-aware filters the server compiles to JSONB containment queries. Operators routinely live with these set: -- `param.user.id=alice` — every call where the request param at the dotted path `user.id` equals `alice`. -- `response.isError=true` — every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected). -- `header.User-Agent=curl/8.0` — every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`). -- `has=response_error` — every call that recorded a transport-level error. -- `has=notifications` — every call that fired any notification. +![JSONB filter editor expanded with one applied filter (`param.user.id=alice`) above the events table](../images/portal/audit-jsonb-light.png#only-light) +![JSONB filter editor expanded with one applied filter (`param.user.id=alice`) above the events table](../images/portal/audit-jsonb-dark.png#only-dark) + + +- `param.user.id=alice`: every call where the request param at the dotted path `user.id` equals `alice`. +- `response.isError=true`: every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected). +- `header.User-Agent=curl/8.0`: every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`). +- `has=response_error`: every call that recorded a transport-level error. +- `has=notifications`: every call that fired any notification. Filters are AND-combined with each other and with the indexed-column filters (tool, user, success, etc.). They run against `audit_payloads` via `EXISTS` subqueries that hit the existing GIN indexes on `request_params` and `response_result`; `request_headers` is unindexed today so pair `header.*` with a time-range filter on busy deployments. @@ -89,6 +101,10 @@ Filters are AND-combined with each other and with the indexed-column filters (to The **Live tail** toggle on the Audit page opens an SSE connection to `/api/v1/portal/audit/stream`. New audit events appear in a fixed-cap most-recent-first list (cap 20) above the table as they're written; clicking one opens the drawer. The table itself stays a historical-filter view so the live tail doesn't blow away your filtered context. +![Audit page with Live tail toggled on, showing the buffer of recent SSE-delivered events above the historical filter view](../images/portal/audit-livetail-light.png#only-light) +![Audit page with Live tail toggled on, showing the buffer of recent SSE-delivered events above the historical filter view](../images/portal/audit-livetail-dark.png#only-dark) + + The stream sends an opening `: connected` comment on connect, an `event: audit\ndata: ` per write, and a `: keepalive` comment every 30 seconds. Slow consumers see per-subscriber drops; the producer never blocks. ## 7. Export diff --git a/docs/operations/portal.md b/docs/operations/portal.md index 0d675a2..add3510 100644 --- a/docs/operations/portal.md +++ b/docs/operations/portal.md @@ -16,7 +16,8 @@ binary via `go:embed all:dist`. There's no separate frontend server. | `/portal/` | Dashboard: 1-hour stats and recent activity. | | `/portal/tools` | Tool catalog grouped by category. | | `/portal/tools/` | Per-tool detail with Overview / Try It tabs. | -| `/portal/audit` | Filterable event browser with pagination. | +| `/portal/audit` | Filterable event browser with pagination, click-to-expand drawer, JSONB filters, SSE live tail. | +| `/portal/audit/compare` | Side-by-side structural diff of two events; staged from the drawer's Compare button (`?a=&b=`). | | `/portal/keys` | DB-backed API key management. | | `/portal/config` | Read-only JSON view of the running config (secrets redacted). | | `/portal/wellknown` | Pretty-print of the protected-resource and authorization-server metadata that gateways read. | @@ -50,6 +51,12 @@ A small inline script in `index.html` applies the `.dark` class to `` before stylesheets load to avoid the classic light-flash on dark systems. +## Audit inspection + +The audit page is the operator-facing surface for the audit pipeline. Clicking any row opens a four-tab drawer (Overview / Request / Response / Notifications) deep-linked via `?id=`. Inline buttons replay the captured call against the live MCP server (with confirmation; rate-limited per identity) or stash the event for side-by-side comparison at `/portal/audit/compare`. A **Live tail** toggle subscribes to the SSE stream so new events surface above the historical filter view in real time, and a **JSONB filters** toggle opens the editor for the path-aware filters that compile to GIN-indexed containment queries against `audit_payloads`. + +The full operator workflow (capture a call, inspect it, replay it, compare to a baseline, filter, export) is in [Inspection workflow](inspection.md). + ## Try It The Tools page's **Try It** tab renders a per-tool form: sliders for diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 779fbf9..349e362 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -63,45 +63,97 @@

-
- Portal preview -

Inspect every call from the browser

+ Portal preview +

Inspect every call from the browser

+

Click any frame to open it full-size. Use the side rails or the arrow keys to step through.

+
+ +
+ + +
+
+ {% set shots = [ + ["dashboard", "Dashboard", "Live counts, error rate, p50/p95 latency, and the most recent calls across every tool."], + ["audit", "Audit log", "Every call, filterable by tool, user, status, and source. Sanitized parameters and full response shape are one click away."], + ["audit-drawer", "Inspection drawer", "Click any row for the four-tab side panel: Overview / Request / Response / Notifications. Replay and Compare are inline; the URL deep-links to the open event so a drawer state is shareable."], + ["audit-compare", "Side-by-side compare", "Stash any two events and the comparison page renders a JSON-path-aware structural diff. Walks objects and arrays by key/index, so reordered keys don't masquerade as changes."], + ["audit-livetail","Live tail", "SSE stream of new audit events as they're written, surfaced in a fixed-cap buffer above the historical filter view. The table itself stays still so the live read doesn't blow away your filtered context."], + ["audit-jsonb", "JSONB filters", "Server-side path filters compile to JSONB containment against the existing GIN indexes: param.=v, response.=v, header.=v, has=. Type-detected values; quote to force string."], + ["tools", "Tools", "Twelve test fixtures grouped by category. Pick one to see its schema, run it, or browse the audit rows it produced."], + ["tools-tryit", "Try it", "Per-tool form generated from the JSON schema. Sliders, dropdowns, and inline help; progress notifications stream in over SSE."], + ["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."], + ["wellknown", "Discovery", "RFC 9728 protected-resource metadata and the upstream OIDC authorization-server document, rendered the way an MCP client will see them."], + ["config", "Config", "Read-only YAML view of the running server, with secrets masked. Useful for sanity-checking what's actually loaded."] + ] %} + {% for slug, title, body in shots %} +
+ +
+ Portal +

{{ title }}

+

{{ body }}

+
+
+ {% endfor %}
- + +
-
-
- {% set shots = [ - ["dashboard", "Dashboard", "Live counts, error rate, p50/p95 latency, and the most recent calls across every tool."], - ["audit", "Audit log", "Every call, filterable by tool, user, status, and source. Sanitized parameters and full response shape are one click away."], - ["tools", "Tools", "Twelve test fixtures grouped by category. Pick one to see its schema, run it, or browse the audit rows it produced."], - ["tools-tryit", "Try it", "Per-tool form generated from the JSON schema. Sliders, dropdowns, and inline help; progress notifications stream in over SSE."], - ["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."], - ["wellknown", "Discovery", "RFC 9728 protected-resource metadata and the upstream OIDC authorization-server document, rendered the way an MCP client will see them."], - ["config", "Config", "Read-only YAML view of the running server, with secrets masked. Useful for sanity-checking what's actually loaded."] - ] %} - {% for slug, title, body in shots %} -
-
- Portal {{ title }} screen, light theme - Portal {{ title }} screen, dark theme +
+
+ + {# Lightbox modal. Hidden by default; shots.js shows it on slide click. #} +
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index d28f6b2..e1b73ff 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1363,11 +1363,11 @@ body, @media (min-width: 1024px) { .plex-shots__inner { padding: 0 32px; } } .plex-shots__head { - display: flex; - align-items: end; - justify-content: space-between; - gap: 1rem; - margin-bottom: 1.75rem; + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; + margin-bottom: 1.5rem; + max-width: 720px; } .plex-shots__head h2 { font-family: "Outfit", system-ui, sans-serif; @@ -1375,18 +1375,39 @@ body, font-size: clamp(1.4rem, 2vw + 0.75rem, 2rem); line-height: 1.15; letter-spacing: -0.018em; - margin: 0.4rem 0 0; + margin: 0; color: var(--md-default-fg-color); } [data-md-color-scheme="slate"] .plex-shots__head h2 { color: #fff; } +.plex-shots__lede { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.55; + color: var(--md-default-fg-color--light); + margin: 0.25rem 0 0; +} -.plex-shots__nav { - display: flex; - gap: 0.5rem; +/* Stage = three-column grid: left rail | viewport | right rail. The rails + sit alongside the viewport so the active slide always has its + navigation immediately at the edge instead of hunting in the header. */ +.plex-shots__stage { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + position: relative; +} +@media (min-width: 1024px) { + .plex-shots__stage { gap: 1rem; } } -.plex-shots__btn { - width: 2.4rem; - height: 2.4rem; + +/* Side-rail nav. Buttons sit at the carousel edges, vertically centered. + They're chunkier than the old header buttons so they read as primary + navigation, not afterthoughts. */ +.plex-shots__rail { + flex: 0 0 auto; + width: 2.6rem; + height: 2.6rem; display: inline-flex; align-items: center; justify-content: center; @@ -1395,73 +1416,115 @@ body, border: 1px solid var(--md-default-fg-color--lightest); color: var(--md-default-fg-color); cursor: pointer; - transition: border-color 160ms ease, color 160ms ease, transform 160ms ease; + transition: border-color 180ms ease, color 180ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 180ms ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), + 0 8px 24px -12px rgba(15, 23, 42, 0.18); } -.plex-shots__btn:hover { +@media (min-width: 1024px) { + .plex-shots__rail { width: 3rem; height: 3rem; } +} +.plex-shots__rail svg { width: 1.25rem; height: 1.25rem; } +.plex-shots__rail:hover { border-color: var(--copper-500); color: var(--copper-700); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), + 0 12px 28px -10px rgba(20, 184, 171, 0.28); } -.plex-shots__btn:active { transform: scale(0.96); } -.plex-shots__btn[disabled] { opacity: 0.35; cursor: default; } -.plex-shots__btn svg { width: 1.1rem; height: 1.1rem; } - -[data-md-color-scheme="slate"] .plex-shots__btn { +.plex-shots__rail--prev:hover { transform: translateX(-2px); } +.plex-shots__rail--next:hover { transform: translateX(2px); } +.plex-shots__rail:active { transform: scale(0.96); } +.plex-shots__rail:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: 3px; +} +[data-md-color-scheme="slate"] .plex-shots__rail { background: var(--midnight-900); border-color: var(--midnight-800); color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), + 0 12px 28px -16px rgba(0, 0, 0, 0.6); } -[data-md-color-scheme="slate"] .plex-shots__btn:hover { +[data-md-color-scheme="slate"] .plex-shots__rail:hover { border-color: var(--copper-400); color: var(--copper-300); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), + 0 12px 28px -10px rgba(45, 211, 203, 0.28); +} + +/* Counter under the stage: "3 / 11". Decimal-tabular figures so the digits + don't jiggle as the index changes. */ +.plex-shots__counter { + margin: 1rem auto 0; + text-align: center; + font-family: "DM Sans", system-ui, sans-serif; + font-feature-settings: "tnum" 1; + font-size: 0.7rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); +} +[data-md-color-scheme="slate"] .plex-shots__counter { color: var(--midnight-300); } +.plex-shots__counter strong { + color: var(--copper-700); + font-weight: 600; + margin-right: 0.15em; } +[data-md-color-scheme="slate"] .plex-shots__counter strong { color: var(--copper-300); } -/* Outer viewport: clips the off-screen slides. The track itself slides via - `transform: translateX(...)` controlled by JS. */ +/* Outer viewport: clips off-screen slides. The track inside translates via + JS-computed offsets. */ .plex-shots__viewport { overflow: hidden; - padding: 0.5rem 0 1.5rem; + padding: 0.5rem 0 0.75rem; } .plex-shots__track { display: flex; - gap: 1.25rem; + gap: 1rem; transform: translate3d(0, 0, 0); transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1); will-change: transform; } @media (min-width: 1024px) { - .plex-shots__track { gap: 1.5rem; } + .plex-shots__track { gap: 1.25rem; } } @media (prefers-reduced-motion: reduce) { - .plex-shots__track { transition: none; } -} -.plex-shots__track::-webkit-scrollbar { height: 6px; } -.plex-shots__track::-webkit-scrollbar-thumb { - background: var(--copper-500); - border-radius: 3px; + .plex-shots__track, + .plex-shots__slide, + .plex-shots__rail, + .plex-shots__zoomhint, + .plex-shots__frame img { transition: none; } } -/* Material's `.md-typeset figure { width: fit-content }` outranks a plain - `.plex-shots__slide` selector, so we double up the class for specificity - instead of reaching for !important. */ +/* Slides ~30% smaller than the previous layout so neighbors are always + peeking in. Material's `.md-typeset figure { width: fit-content }` + outranks a plain `.plex-shots__slide` selector, so we double up the + class for specificity instead of reaching for !important. */ .plex-shots__slide.plex-shots__slide { flex: 0 0 auto; - width: min(66vw, 704px); + width: min(72vw, 480px); border-radius: var(--plex-radius-lg); - overflow: hidden; background: var(--md-default-bg-color); border: 1px solid var(--md-default-fg-color--lightest); display: flex; flex-direction: column; margin: 0; + overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 32px -16px rgba(15, 23, 42, 0.18); - transition: border-color 200ms ease, box-shadow 200ms ease, opacity 520ms ease; - opacity: 0.55; + transition: border-color 200ms ease, box-shadow 200ms ease, + opacity 520ms ease, transform 520ms cubic-bezier(0.22, 1, 0.36, 1); + opacity: 0.5; + transform: scale(0.96); } -.plex-shots__slide.is-active { opacity: 1; } @media (min-width: 1024px) { - .plex-shots__slide.plex-shots__slide { width: min(56vw, 832px); } + .plex-shots__slide.plex-shots__slide { width: min(40vw, 580px); } +} +.plex-shots__slide.is-active { + opacity: 1; + transform: scale(1); } .plex-shots__slide:hover { border-color: var(--copper-500); @@ -1475,12 +1538,25 @@ body, 0 24px 48px -20px rgba(0, 0, 0, 0.6); } +/* Frame is now an interactive button (the click target for the lightbox). + Strip default button chrome and rebuild as a flush surface. The + zoomhint badge slides in on hover/focus to telegraph the action. */ .plex-shots__frame { position: relative; aspect-ratio: 1440 / 900; background: var(--md-default-bg-color); overflow: hidden; + border: 0; border-bottom: 1px solid var(--md-default-fg-color--lightest); + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: zoom-in; + display: block; + width: 100%; + text-align: left; + transition: filter 220ms ease; } [data-md-color-scheme="slate"] .plex-shots__frame { background: var(--midnight-950); @@ -1494,15 +1570,53 @@ body, object-fit: cover; object-position: top center; display: block; + transition: transform 1200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-shots__slide.is-active .plex-shots__frame:hover img, +.plex-shots__slide.is-active .plex-shots__frame:focus-visible img { + transform: scale(1.025); +} +.plex-shots__frame:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: -2px; } -/* Theme-gated visibility: show the matching screenshot, hide the other. - Defaults assume light scheme; slate scheme flips it. */ +/* Theme-gated visibility: show the matching screenshot, hide the other. */ .plex-shots__frame img[data-theme="dark"] { display: none; } .plex-shots__frame img[data-theme="light"] { display: block; } [data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="light"] { display: none; } [data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="dark"] { display: block; } +/* Zoom hint: small copper-tinted glyph in the top-right corner of the + active slide's frame. Stays subtle until hover, then lifts. */ +.plex-shots__zoomhint { + position: absolute; + top: 0.65rem; + right: 0.65rem; + width: 1.85rem; + height: 1.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.55); + color: #fff; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + transform: translateY(-4px); + transition: opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 180ms ease; + pointer-events: none; +} +.plex-shots__zoomhint svg { width: 1rem; height: 1rem; } +.plex-shots__slide.is-active .plex-shots__zoomhint { opacity: 0.85; transform: translateY(0); } +.plex-shots__frame:hover .plex-shots__zoomhint, +.plex-shots__frame:focus-visible .plex-shots__zoomhint { + opacity: 1; + background: var(--copper-700); +} + .plex-shots__caption { padding: 1rem 1.25rem 1.1rem; display: flex; @@ -1541,3 +1655,308 @@ body, @media (prefers-reduced-motion: reduce) { .plex-shots__track { scroll-behavior: auto; } } + +/* ───────────────────────────────────────────────────────────────────────── + Lightbox: focused-artifact view of a single screenshot. + + Aesthetic: the rest of the page recedes behind a deep midnight backdrop + with a faint copper haze; the screenshot floats as a single object + inside a panel that uses the same border / radius / shadow vocabulary + as the carousel slides, just sized up. Caption sits below the image + in the same Outfit + DM Sans typography so the modal feels like part + of the document, not a foreign overlay. Motion vocabulary mirrors the + carousel (same cubic-bezier(0.22, 1, 0.36, 1)) so opening one feels + like the carousel zooming into focus. + ───────────────────────────────────────────────────────────────────── */ + +.plex-lightbox { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + padding: clamp(0.75rem, 2vw, 2rem); + opacity: 0; + pointer-events: none; + transition: opacity 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox[hidden] { display: none; } +.plex-lightbox.is-open { + opacity: 1; + pointer-events: auto; +} + +.plex-lightbox__backdrop { + position: absolute; + inset: 0; + background: + radial-gradient( + ellipse at 50% 20%, + rgba(20, 184, 171, 0.10) 0%, + transparent 60% + ), + rgba(7, 18, 36, 0.78); + backdrop-filter: blur(8px) saturate(120%); + -webkit-backdrop-filter: blur(8px) saturate(120%); + cursor: zoom-out; +} +[data-md-color-scheme="slate"] .plex-lightbox__backdrop { + background: + radial-gradient( + ellipse at 50% 20%, + rgba(45, 211, 203, 0.13) 0%, + transparent 60% + ), + rgba(2, 6, 12, 0.82); +} + +.plex-lightbox__shell { + position: relative; + width: min(96vw, 1480px); + max-height: 92vh; + display: flex; + flex-direction: column; + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: var(--plex-radius-lg); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 24px 60px -20px rgba(0, 0, 0, 0.55), + 0 64px 120px -40px rgba(20, 184, 171, 0.18); + overflow: hidden; + transform: translateY(8px) scale(0.98); + transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox.is-open .plex-lightbox__shell { transform: translateY(0) scale(1); } +[data-md-color-scheme="slate"] .plex-lightbox__shell { + background: var(--midnight-950); + border-color: var(--midnight-800); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 24px 60px -20px rgba(0, 0, 0, 0.7), + 0 64px 120px -40px rgba(45, 211, 203, 0.18); +} + +/* Top bar: title on the left, count + nav + close on the right. Compact + toolbar height (~3.25rem) so the screenshot, not the chrome, is + what dominates the viewport. Material's `.md-typeset h3` and span + defaults are aggressive on margins; the rules below use element- + qualified selectors so they tie on specificity (0,1,1) and source + order picks the lightbox style as the winner. */ +.plex-lightbox__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.65rem 0.55rem 1rem; + min-height: 0; + border-bottom: 1px solid var(--md-default-fg-color--lightest); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)) 0%, + var(--md-default-bg-color) 100% + ); +} +[data-md-color-scheme="slate"] .plex-lightbox__bar { + border-color: var(--midnight-800); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--midnight-900) 88%, var(--copper-700)) 0%, + var(--midnight-900) 100% + ); +} + +.plex-lightbox__meta { + display: flex; + align-items: baseline; + gap: 0.6rem; + min-width: 0; + flex: 1 1 auto; +} +/* Element-qualified selectors below: Material's `.md-typeset h3` + (0,1,1) and span defaults beat single-class rules on specificity, so + we tag the element explicitly to win. */ +span.plex-lightbox__eyebrow { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--copper-700); + margin: 0; + line-height: 1; + flex: 0 0 auto; +} +[data-md-color-scheme="slate"] span.plex-lightbox__eyebrow { color: var(--copper-300); } +h3.plex-lightbox__title { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: 0.95rem; + letter-spacing: -0.012em; + margin: 0; + padding: 0; + color: var(--md-default-fg-color); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1 1 auto; +} +@media (min-width: 720px) { + h3.plex-lightbox__title { font-size: 1.05rem; } +} +[data-md-color-scheme="slate"] h3.plex-lightbox__title { color: #fff; } + +.plex-lightbox__controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +.plex-lightbox__count { + font-family: "DM Sans", system-ui, sans-serif; + font-feature-settings: "tnum" 1; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); + margin-right: 0.35rem; +} +[data-md-color-scheme="slate"] .plex-lightbox__count { color: var(--midnight-300); } + +.plex-lightbox__navbtn, +.plex-lightbox__close { + width: 2.1rem; + height: 2.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: transparent; + border: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease, + background-color 160ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox__navbtn svg, +.plex-lightbox__close svg { width: 1rem; height: 1rem; } +.plex-lightbox__navbtn:hover, +.plex-lightbox__close:hover { + border-color: var(--copper-500); + color: var(--copper-700); + background: color-mix(in srgb, var(--copper-500) 8%, transparent); +} +.plex-lightbox__navbtn:active, +.plex-lightbox__close:active { transform: scale(0.94); } +.plex-lightbox__navbtn:focus-visible, +.plex-lightbox__close:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: 2px; +} +[data-md-color-scheme="slate"] .plex-lightbox__navbtn, +[data-md-color-scheme="slate"] .plex-lightbox__close { + border-color: var(--midnight-700); + color: #fff; +} +[data-md-color-scheme="slate"] .plex-lightbox__navbtn:hover, +[data-md-color-scheme="slate"] .plex-lightbox__close:hover { + border-color: var(--copper-400); + color: var(--copper-300); + background: color-mix(in srgb, var(--copper-500) 12%, transparent); +} + +/* Stage: the screenshot itself. We use object-fit:contain so the entire + image fits without cropping; aspect-ratio keeps the panel proportional + so a missing image doesn't collapse the layout. */ +.plex-lightbox__stage { + position: relative; + margin: 0; + padding: clamp(0.75rem, 1.5vw, 1.5rem); + display: flex; + flex-direction: column; + gap: 0.85rem; + min-height: 0; + flex: 1 1 auto; + overflow: auto; + background: linear-gradient( + 180deg, + var(--md-default-bg-color) 0%, + color-mix(in srgb, var(--md-default-bg-color) 92%, var(--copper-500)) 100% + ); +} +[data-md-color-scheme="slate"] .plex-lightbox__stage { + background: linear-gradient( + 180deg, + var(--midnight-950) 0%, + color-mix(in srgb, var(--midnight-950) 86%, var(--copper-700)) 100% + ); +} + +.plex-lightbox__img { + display: block; + width: 100%; + height: auto; + /* Budget ~5.5rem for the toolbar + caption + stage padding so the + screenshot owns the rest. Was 9rem when the bar was ~3x taller. */ + max-height: calc(92vh - 5.5rem); + object-fit: contain; + border-radius: calc(var(--plex-radius-lg) - 6px); + border: 1px solid var(--md-default-fg-color--lightest); + background: var(--md-default-bg-color); + box-shadow: 0 12px 32px -16px rgba(15, 23, 42, 0.35); +} +[data-md-color-scheme="slate"] .plex-lightbox__img { + border-color: var(--midnight-800); + background: var(--midnight-900); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.6); +} + +/* Theme-gated visibility for the two stacked img tags inside the + lightbox stage. Same pattern as the carousel frame. */ +.plex-lightbox__img[data-theme="dark"] { display: none; } +.plex-lightbox__img[data-theme="light"] { display: block; } +[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="light"] { display: none; } +[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="dark"] { display: block; } + +.plex-lightbox__caption { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.9rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + text-align: left; + max-width: 70ch; + margin: 0 auto; +} +[data-md-color-scheme="slate"] .plex-lightbox__caption { color: var(--midnight-200); } + +@media (max-width: 720px) { + .plex-lightbox { padding: 0; } + .plex-lightbox__shell { + width: 100vw; + max-height: 100vh; + height: 100vh; + border-radius: 0; + border: 0; + } + .plex-lightbox__bar { padding: 0.7rem 0.85rem; } + .plex-lightbox__count { display: none; } + .plex-lightbox__navbtn, + .plex-lightbox__close { width: 2rem; height: 2rem; } + .plex-lightbox__img { max-height: calc(100vh - 5.5rem); } +} + +@media (prefers-reduced-motion: reduce) { + .plex-lightbox, + .plex-lightbox__shell { + transition: none; + } +} + +/* When the lightbox is open, lock the body scroll. JS toggles this class + on . We avoid `overflow:hidden` on body alone because Material's + layout fights it. */ +html.plex-lightbox-open, +html.plex-lightbox-open body { + overflow: hidden; +} diff --git a/mkdocs.yml b/mkdocs.yml index b4ea5dd..4758c02 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - Streaming: tools/streaming.md - Operations: - Audit Log: operations/audit.md + - Inspection workflow: operations/inspection.md - Portal: operations/portal.md - Deployment: operations/deployment.md - Testing a Gateway: operations/gateway-testing.md diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md new file mode 100644 index 0000000..984f5ce --- /dev/null +++ b/scripts/screenshots/README.md @@ -0,0 +1,73 @@ +# Portal screenshots + +Generates the `docs/images/portal/*-{light,dark}.png` set the documentation site embeds (homepage carousel + inline captures in `docs/operations/inspection.md`). Idempotent: the script truncates `audit_events` / `audit_payloads` / `api_keys` and re-seeds deterministic mock data each run. + +## Prerequisites + +The dev stack must be running. Open a separate terminal: + +```sh +make dev +``` + +This brings up Postgres, Keycloak, and the mcp-test binary on `http://localhost:8080`. The script connects to the same Postgres to seed mock data and points a headless Chromium at the same binary to capture frames. + +## Capture + +From the repo root: + +```sh +make screenshots +``` + +This runs `node scripts/screenshots/screenshots.mjs`. On first run it `npm install`s the script's `package.json` (Playwright + `pg`). + +Override host / API key for non-default deployments: + +```sh +SHOTS_BASE_URL=https://staging.example.com \ +SHOTS_API_KEY=$REAL_KEY \ +make screenshots +``` + +## What gets captured + +Twelve screens × two themes = 24 PNGs at 1440×900 @ 2x DPR. The homepage carousel embeds 11 of these (login is captured but kept out of the rotation). + +| slug | shows | +| --- | --- | +| `login` | Sign-in screen (no auth required for capture). | +| `dashboard` | 1-hour stats + recent activity table. | +| `tools` | Tool catalog grouped by category. | +| `tools-tryit` | The Try-It form for the `progress` tool. | +| `audit` | Filterable event browser, populated with seeded data. | +| `audit-drawer` | Drawer open over the events table; deep-linked to a seeded event id with payload + notifications. | +| `audit-compare` | `/portal/audit/compare` side-by-side diff of two seeded events. | +| `audit-livetail` | Live-tail toggle on, SSE stream connected, buffer rendering above the table. | +| `audit-jsonb` | JSONB filter editor expanded with `param.user.id=alice` applied. | +| `keys` | API key listing. | +| `config` | Read-only config viewer. | +| `wellknown` | Discovery metadata. | + +## Preview + +After capturing, two preview paths: + +```sh +open docs/images/portal/ # raw PNG view in Finder / Preview +make docs-serve # http://127.0.0.1:8001 (full site context) +``` + +`make docs-serve` is the closer-to-production view: the homepage carousel cycles through the screenshots (theme-paired with `data-theme` attributes; the page footer toggle swaps which one is visible), and `inspection.md` embeds use the mkdocs-material `#only-light` / `#only-dark` URL fragments to switch per the reader's selected theme. + +## When to re-run + +Any portal UI change that would shift pixels: layout tweaks, copy edits, new components, theme adjustments. The seed step is deterministic (a fixed PRNG seed produces the same audit events across runs), so re-running on an unchanged binary gives byte-stable PNGs, meaning git diffs only show real visual changes. + +## Troubleshooting + +**`Postgres connection failed`**: `make dev` isn't running or the stack hasn't finished starting. `make dev-wait` blocks until both Postgres and Keycloak are reachable. + +**`Drawer empty / "No response captured"`**: `audit_payloads` seed didn't run; check the Postgres logs for `relation "audit_payloads" does not exist`. Run migrations: `make migrate`. + +**`Timeout waiting for selector role="dialog"`**: the deep-link target id wasn't seeded. The script picks the two most-recent successful payload events for the drawer / compare captures; if the random seed produces fewer than two successful payloads (very unlikely with 100 events), the script aborts before capture with a clear message. diff --git a/scripts/screenshots/screenshots.mjs b/scripts/screenshots/screenshots.mjs index 98bca8b..a65e1f6 100644 --- a/scripts/screenshots/screenshots.mjs +++ b/scripts/screenshots/screenshots.mjs @@ -34,6 +34,11 @@ const OUT_DIR = resolve(REPO_ROOT, "docs/images/portal"); const VIEWPORT = { width: 1440, height: 900 }; const DEVICE_SCALE = 2; // retina-sharp screenshots +// PAGES: each entry produces one screenshot per theme. `path` may be a +// string or a 0-arg function (so deep-link targets can reference state +// stashed by seed(), e.g. DRAWER_TARGET_ID). `prep` runs after navigation +// to drive the UI into the right state for the capture (open a drawer, +// toggle live tail, expand a panel). const PAGES = [ { slug: "login", path: "/portal/login", requiresAuth: false, prep: null }, { slug: "dashboard", path: "/portal/", requiresAuth: true, prep: null }, @@ -46,6 +51,72 @@ const PAGES = [ await page.waitForTimeout(200); } }, { slug: "audit", path: "/portal/audit", requiresAuth: true, prep: null }, + + // v1.2 inspection-utility captures. + // + // audit-drawer: deep-link to a seeded event with payload so the four + // tabs (Overview / Request / Response / Notifications) all have + // meaningful content. Targets a successful "progress" or similar tool + // call so notifications render. + { slug: "audit-drawer", + path: () => `/portal/audit?id=${DRAWER_TARGET_ID}`, + requiresAuth: true, + prep: async (page) => { + // Wait for the drawer panel (role=dialog) to mount and for the + // detail query to resolve so the header shows the tool name + // rather than the loading spinner. + await page.waitForSelector('[role="dialog"][aria-label="Audit event detail"]', { timeout: 5000 }); + await page.waitForTimeout(500); + } }, + + // audit-compare: side-by-side diff. Two seeded event ids piped via + // the URL params; the page renders Summary + per-payload diff trees. + { slug: "audit-compare", + path: () => `/portal/audit/compare?a=${COMPARE_A_ID}&b=${COMPARE_B_ID}`, + requiresAuth: true, + prep: async (page) => { + // Wait for both event queries to resolve before snapping; the + // headers go from "Loading..." to the tool name on success. + await page.waitForSelector('text="Compare events"', { timeout: 5000 }); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(400); + } }, + + // audit-livetail: live tail toggled on with the buffer visible. + // Doesn't actually need new SSE traffic to capture; the toggle is + // visually distinctive (animate-pulse Radio icon, success-colored + // border, "Waiting for events..." or buffer rendered above table). + { slug: "audit-livetail", + path: "/portal/audit", + requiresAuth: true, + prep: async (page) => { + const tail = page.locator('button:has-text("Live tail")').first(); + await tail.click(); + // Give the SSE handshake a beat to land before snapping. The + // server emits a `: connected` comment immediately, but the + // empty-buffer state ("Waiting for events...") is also a valid + // shot. + await page.waitForTimeout(800); + } }, + + // audit-jsonb: filter editor expanded with one sample filter applied. + { slug: "audit-jsonb", + path: "/portal/audit", + requiresAuth: true, + prep: async (page) => { + const toggle = page.locator('button:has-text("JSONB filters")').first(); + await toggle.click(); + await page.waitForTimeout(200); + // Add a representative `param.user.id=alice` filter so the + // editor and the active-filter chip both render. + const pathInput = page.locator('input[placeholder="dotted.path"]').first(); + const valueInput = page.locator('input[placeholder="value"]').first(); + await pathInput.fill("user.id"); + await valueInput.fill("alice"); + await page.locator('button:has-text("add")').first().click(); + await page.waitForTimeout(400); + } }, + { slug: "keys", path: "/portal/keys", requiresAuth: true, prep: null }, { slug: "config", path: "/portal/config", requiresAuth: true, prep: null }, { slug: "wellknown", path: "/portal/wellknown", requiresAuth: true, prep: null }, @@ -157,8 +228,11 @@ async function seed() { await client.connect(); try { - console.log("→ truncating audit_events + api_keys"); - await client.query("TRUNCATE audit_events"); + console.log("→ truncating audit_events + audit_payloads + api_keys"); + // audit_payloads cascades on audit_events delete, but TRUNCATE is + // explicit per table so the order doesn't matter here. + await client.query("TRUNCATE audit_payloads"); + await client.query("TRUNCATE audit_events CASCADE"); await client.query("TRUNCATE api_keys"); console.log("→ inserting api_keys"); @@ -214,12 +288,142 @@ async function seed() { values, ); - console.log(`✓ seeded ${events.length} audit events + ${apiKeys.length} api keys`); + // Seed audit_payloads for the 20 most-recent events so the v1.2 + // drawer / compare / inspection screenshots have meaningful Request + // / Response / Notifications tabs to render. The other 80 events + // stay summary-only; that's a realistic mix for a deployment with + // capture_payloads on but post-retention payload pruning. + console.log("→ inserting audit_payloads (20 most-recent events)"); + const recent = [...events] + .sort((a, b) => b.ts.getTime() - a.ts.getTime()) + .slice(0, 20); + for (const e of recent) { + // Build payload request_params from the seeded summary params (when + // present), and inject a realistic gateway-context block: `user.id` + // mirrors the audit row's identity so demo JSONB filters like + // `param.user.id=alice` actually return matches; tenant/region are + // there to make the JSON view look like a real production payload. + const baseParams = e.parameters ? JSON.parse(e.parameters) : {}; + const userId = e.user_email + ? e.user_email.split("@")[0] + : (e.api_key_name || "anonymous"); + const params = { + ...baseParams, + user: { + id: userId, + tenant: pick(["acme", "globex", "initech"]), + }, + request_id: e.request_id, + }; + let result = null; + let errBlob = null; + let notifications = null; + if (e.success) { + // Build a plausible-looking CallToolResult JSON for the Response tab. + result = { + content: [ + { type: "text", text: makeResponseText(e.tool_name, params) }, + ], + isError: false, + }; + if (e.tool_name === "progress" || e.tool_name === "chatty") { + notifications = makeProgressNotifications(e.ts, params); + } + } else { + errBlob = { message: e.error_message, category: e.error_category }; + } + await client.query( + `INSERT INTO audit_payloads ( + event_id, jsonrpc_method, + request_params, request_size_bytes, + request_headers, request_remote_addr, + response_result, response_error, response_size_bytes, + notifications, notifications_truncated, + captured_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, + [ + e.id, + "tools/call", + JSON.stringify(params), + e.request_chars, + JSON.stringify({ + "User-Agent": [e.user_agent], + "X-Forwarded-For": [e.remote_addr], + "Content-Type": ["application/json"], + // Sensitive headers are stored redacted; mirror the pkg/auth + // RedactHeaders contract so the drawer Request tab shows the + // [redacted] values an operator will see in production. + "Authorization": ["[redacted]"], + "Cookie": ["[redacted]"], + }), + e.remote_addr, + result ? JSON.stringify(result) : null, + errBlob ? JSON.stringify(errBlob) : null, + e.response_chars, + notifications ? JSON.stringify(notifications) : null, + false, + e.ts, + ], + ); + } + + console.log(`✓ seeded ${events.length} audit events (${recent.length} with payloads) + ${apiKeys.length} api keys`); + + // Stash the two most-recent successful event ids so the capture + // step can navigate to /audit?id= and /audit/compare?a=<>&b=<> + // without guessing. + const successful = recent.filter((e) => e.success); + if (successful.length < 2) { + throw new Error("seed produced fewer than 2 successful payload events; reduce errorRate or increase event count"); + } + DRAWER_TARGET_ID = successful[0].id; + COMPARE_A_ID = successful[0].id; + COMPARE_B_ID = successful[1].id; } finally { await client.end(); } } +// makeResponseText shapes a plausible response-block string per tool so +// the Response tab renders something that looks like the real call. +function makeResponseText(tool, params) { + switch (tool) { + case "echo": return JSON.stringify({ ...params, echoed_at: new Date().toISOString() }); + case "fixed_response": return JSON.stringify({ key: params.key, value: "static-fixture" }); + case "sized_response": return "x".repeat(Math.min(params.size, 240)) + "..."; + case "lorem": return "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod..."; + case "whoami": return JSON.stringify({ subject: "alice@example.com", auth_type: "oidc" }); + case "headers": return JSON.stringify({ "User-Agent": "claude-code/1.0", "X-Forwarded-For": "10.0.1.42" }); + case "slow": return JSON.stringify({ slept_ms: params.milliseconds }); + case "progress": return JSON.stringify({ steps_completed: params.steps }); + default: return `result for ${tool}`; + } +} + +function makeProgressNotifications(eventTs, params) { + const steps = params.steps ?? 5; + const stepMs = params.step_ms ?? 200; + const out = []; + for (let i = 1; i <= steps; i++) { + out.push({ + ts: new Date(eventTs.getTime() - (steps - i) * stepMs).toISOString(), + method: "notifications/progress", + params: { + progressToken: "demo-token", + progress: i, + total: steps, + message: `step ${i}/${steps}`, + }, + }); + } + return out; +} + +// Filled by seed() and consumed by the capture step's prep functions. +let DRAWER_TARGET_ID = null; +let COMPARE_A_ID = null; +let COMPARE_B_ID = null; + // --------------------------------------------------------------------------- // Capture // --------------------------------------------------------------------------- @@ -262,7 +466,8 @@ async function capture() { { themeSlug: theme.slug, apiKey: API_KEY, requiresAuth: target.requiresAuth }, ); - await page.goto(`${BASE_URL}${target.path}`, { waitUntil: "networkidle" }); + const targetPath = typeof target.path === "function" ? target.path() : target.path; + await page.goto(`${BASE_URL}${targetPath}`, { waitUntil: "networkidle" }); // Some portal pages trigger queries; wait a short beat for them to settle. await page.waitForTimeout(500);