feat(dashboard): bundle the operator user guide and serve it at /#/docs - #434
Conversation
A self-hosted gateway can run a version of Otari that differs from otari.ai, so the dashboard should carry the guide that matches the running build rather than sending operators to a separate docs site that may describe a different version. The dashboard now renders docs/dashboard.md at /#/docs, behind the existing master-key sign-in, and links to it from the sidebar footer. The guide is imported with Vite's ?raw so it inlines into the (lazy-loaded) docs chunk at build time, keeping it version-matched to the bundle. Sibling doc links that are not bundled here are rewritten to the rendered source on GitHub and open in a new tab. Because the guide lives outside web/, vite.config.ts grants server.fs access to the repo root, and the dashboard staleness workflow now also triggers on docs/dashboard.md so an edit that leaves the committed bundle stale is caught. Fixes #329 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 29 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (19)
WalkthroughAdds an authenticated ChangesDashboard user guide
Estimated code review effort: 3 (Moderate) | ~30 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/otari-dashboard.yml:
- Around line 15-20: Add the workflow file itself to the pull_request path
filters in the relevant trigger configuration, matching the existing push
coverage so changes to the workflow’s staleness logic run validation before
merge.
In `@web/src/styles/globals.css`:
- Around line 203-210: Replace the hard-coded foreground color in
.otari-markdown pre code with a named --otari-* token, and define that token in
the :root section of globals.css using the existing `#e8eef0` value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 611c782f-f76e-4dfd-85c3-d1e416c87ecf
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (29)
.github/workflows/otari-dashboard.ymlAGENTS.mdsrc/gateway/static/dashboard/assets/ActivityPage-B5EUMS4E.jssrc/gateway/static/dashboard/assets/AliasesPage-DONisBbD.jssrc/gateway/static/dashboard/assets/BudgetsPage-BFFSfxbi.jssrc/gateway/static/dashboard/assets/ConfirmDialog-DJSi-9PG.jssrc/gateway/static/dashboard/assets/DocsPage-I5B5QJVx.jssrc/gateway/static/dashboard/assets/KeysPage-Cy-JTip2.jssrc/gateway/static/dashboard/assets/ModelScopeControl-CKbp2KZ3.jssrc/gateway/static/dashboard/assets/ModelsPage-CyF9pIyI.jssrc/gateway/static/dashboard/assets/OverviewPage-D3crDRZk.jssrc/gateway/static/dashboard/assets/ProvidersPage-B-f-Neug.jssrc/gateway/static/dashboard/assets/SettingsPage-jZ4yfj2x.jssrc/gateway/static/dashboard/assets/TablePagination-ku5ssWSA.jssrc/gateway/static/dashboard/assets/ToolsGuardrailsPage-BGS4wNea.jssrc/gateway/static/dashboard/assets/UsagePage-CqeKwk9A.jssrc/gateway/static/dashboard/assets/UsersPage-Bsvcg8ok.jssrc/gateway/static/dashboard/assets/index-BpNdDlRZ.jssrc/gateway/static/dashboard/assets/index-BzgMrLHf.jssrc/gateway/static/dashboard/assets/index-C6_9f8lh.csssrc/gateway/static/dashboard/index.htmlweb/package.jsonweb/src/App.tsxweb/src/components/AppShell.test.tsxweb/src/components/AppShell.tsxweb/src/pages/DocsPage.test.tsxweb/src/pages/DocsPage.tsxweb/src/styles/globals.cssweb/vite.config.ts
💤 Files with no reviewable changes (1)
- src/gateway/static/dashboard/assets/index-BzgMrLHf.js
| paths: | ||
| - 'web/**' | ||
| - 'src/gateway/static/dashboard/**' | ||
| # The dashboard bundles this guide (web/src/pages/DocsPage.tsx), so a change | ||
| # to it makes the committed bundle stale until web is rebuilt; run the check. | ||
| - 'docs/dashboard.md' |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Trigger validation when this workflow changes in a pull request.
The workflow file is included for push but omitted from pull_request.paths, so PR-only edits to its staleness logic are not validated before merge.
Proposed fix
pull_request:
branches: [ main ]
paths:
- 'web/**'
- 'src/gateway/static/dashboard/**'
+ - '.github/workflows/otari-dashboard.yml'
- 'docs/dashboard.md'📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| paths: | |
| - 'web/**' | |
| - 'src/gateway/static/dashboard/**' | |
| # The dashboard bundles this guide (web/src/pages/DocsPage.tsx), so a change | |
| # to it makes the committed bundle stale until web is rebuilt; run the check. | |
| - 'docs/dashboard.md' | |
| paths: | |
| - 'web/**' | |
| - 'src/gateway/static/dashboard/**' | |
| - '.github/workflows/otari-dashboard.yml' | |
| # The dashboard bundles this guide (web/src/pages/DocsPage.tsx), so a change | |
| # to it makes the committed bundle stale until web is rebuilt; run the check. | |
| - 'docs/dashboard.md' |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/otari-dashboard.yml around lines 15 - 20, Add the workflow
file itself to the pull_request path filters in the relevant trigger
configuration, matching the existing push coverage so changes to the workflow’s
staleness logic run validation before merge.
…eadings Only strip the guide's opening line when it is a real ATX heading (`#`..`######` followed by whitespace), so a first line that merely starts with `#` is left intact. No behavior change for the current guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the raw #e8eef0 hex in .otari-markdown pre code with a named --otari-code-ink token defined in :root, matching the dashboard's token-only color convention (addresses a CodeRabbit review note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an authenticated “User guide” experience to the admin dashboard by bundling docs/dashboard.md into the dashboard build and exposing it at /#/docs, ensuring the running gateway ships documentation that matches the deployed version.
Changes:
- Add a new
docsroute and sidebar footer link that renders bundled Markdown usingreact-markdown+remark-gfm. - Add scoped global CSS for Markdown typography and code blocks using existing
--otari-*design tokens. - Update Vite/Vitest config and CI workflow triggers so edits to
docs/dashboard.mdare caught as “stale bundle” unless the dashboard build output is regenerated and committed.
Reviewed changes
Copilot reviewed 26 out of 30 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| web/vite.config.ts | Allows dev server/Vitest to read the bundled guide from outside web/. |
| web/src/styles/globals.css | Adds scoped .otari-markdown styles and a new --otari-code-ink token for guide code blocks. |
| web/src/pages/DocsPage.tsx | New page that bundles and renders docs/dashboard.md, with link rewriting for sibling docs. |
| web/src/pages/DocsPage.test.tsx | Tests rendering, heading de-duplication, GFM tables, and link rewriting behavior. |
| web/src/components/AppShell.tsx | Adds a “User guide” footer NavLink in the sidebar rail. |
| web/src/components/AppShell.test.tsx | Adds a test asserting the footer link is present. |
| web/src/App.tsx | Registers a lazy-loaded docs route under the authenticated app shell. |
| web/package.json | Adds react-markdown and remark-gfm dependencies. |
| src/gateway/static/dashboard/index.html | Updates hashed asset references from the rebuilt dashboard bundle. |
| src/gateway/static/dashboard/assets/UsersPage-Cf7XFqqg.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/UsagePage-B-wsXA_7.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CqzvXJGv.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/TablePagination-B6n-P8hP.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/SettingsPage-DuaBIB9C.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/OverviewPage-CnyUyPnI.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/ModelScopeControl-C86gAanu.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/KeysPage-D6zoVTgo.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/ConfirmDialog-BED6F4he.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/BudgetsPage-DspuW7X2.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/AliasesPage-9azvXhVZ.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| src/gateway/static/dashboard/assets/ActivityPage-C0KlBw31.js | Regenerated dashboard asset output (hash/cross-chunk references updated). |
| AGENTS.md | Documents the new guide bundling behavior and rebuild coupling. |
| .github/workflows/otari-dashboard.yml | Ensures CI “stale bundle” checks run when docs/dashboard.md changes. |
Files not reviewed (2)
- src/gateway/static/dashboard/assets/index-6c8CgnYl.js: Generated file
- web/package-lock.json: Generated file
| const markdownComponents: Components = { | ||
| a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { | ||
| const resolved = resolveDocHref(href); | ||
| // In-page anchors stay in the tab; every other link (bundled-out doc pages | ||
| // rewritten to GitHub, or already-external URLs) opens in a new tab so the | ||
| // operator does not lose the dashboard. | ||
| const external = href?.startsWith("#") ? false : isExternal(resolved); | ||
| return ( | ||
| <a href={resolved} {...(external ? { target: "_blank", rel: "noreferrer" } : {})} {...props}> | ||
| {children} | ||
| </a> | ||
| ); | ||
| }, | ||
| }; |
| // Allow reading the bundled user guide (docs/dashboard.md) from the repo | ||
| // root; the default only permits web/ and its workspace. | ||
| fs: { allow: [repoRoot] }, | ||
| proxy: { |
khaledosman
left a comment
There was a problem hiding this comment.
Reviewed the source diff (bundle excluded). One blocker (dev-server secret exposure), one confirmed rendering bug, and three a11y issues on the new Markdown styles. I reproduced the first two by running the code; details inline.
Also: this conflicts with #435 in web/src/components/AppShell.tsx and AppShell.test.tsx (both rewrite the sidebar footer, both insert tests at the same anchor). Whoever merges second should take this PR's footer <div> structure, re-apply aria-hidden to both svgs inside it (this PR's new book icon never had one, and resolving in favour of this block drops #435's addition on the otari.ai icon), keep both test blocks, run npm ci before rebuilding, and regenerate the bundle from scratch.
Not worth blocking on: the DocsPage lazy chunk is 168 KB for react-markdown + remark-gfm + the inlined guide. It is genuinely lazy, so it only costs visitors of /#/docs.
🤖 Review generated with Claude Code
| server: { | ||
| // Allow reading the bundled user guide (docs/dashboard.md) from the repo | ||
| // root; the default only permits web/ and its workspace. | ||
| fs: { allow: [repoRoot] }, |
There was a problem hiding this comment.
Blocker: this exposes config.yml (and otari.db) over the dev server.
An explicit server.fs.allow replaces Vite's default rather than extending it, and the default fs.deny only covers .env*, *.{crt,pem}, **/.git/**. Widening the root to the whole repo makes every gitignored file readable at /@fs/<repoRoot>/....
Reproduced on this branch with a sentinel config.yml:
pr-434: curl /@fs/<repo>/config.yml -> HTTP 200 master_key: sk-SENTINEL-SECRET-123
main: curl /@fs/<repo>/config.yml -> HTTP 403
Anyone who can reach the port gets the master key and provider API keys: npm run dev -- --host, a devcontainer, or a forwarded port in Codespaces.
Only docs/ needs to be readable. web/ has to stay listed explicitly, since the default is replaced:
fs: {
allow: [
fileURLToPath(new URL("./", import.meta.url)),
fileURLToPath(new URL("../docs", import.meta.url)),
],
},| a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { | ||
| const resolved = resolveDocHref(href); | ||
| // In-page anchors stay in the tab; every other link (bundled-out doc pages | ||
| // rewritten to GitHub, or already-external URLs) opens in a new tab so the | ||
| // operator does not lose the dashboard. | ||
| const external = href?.startsWith("#") ? false : isExternal(resolved); | ||
| return ( | ||
| <a href={resolved} {...(external ? { target: "_blank", rel: "noreferrer" } : {})} {...props}> |
There was a problem hiding this comment.
Every link in the guide ships an invalid node="[object Object]" attribute.
react-markdown 9 passes the hast node to custom components. Destructuring only href and children leaves node in ...props, and it gets forwarded onto the DOM element. Verified by rendering the page:
ANCHORS: 8 WITH_NODE_ATTR: 8
<a href="https://github.com/mozilla-ai/otari/blob/main/docs/configuration.md"
target="_blank" rel="noreferrer" node="[object Object]">Configuration</a>
Typecheck does not catch it because the parameter is annotated AnchorHTMLAttributes<HTMLAnchorElement> rather than Components["a"], so TS never sees the extra prop. That also stringifies the whole hast subtree once per link.
a: ({ node: _node, href, children, ...props }: ComponentPropsWithoutRef<"a"> & ExtraProps) => {(ExtraProps is exported from react-markdown.) Worth an assertion in DocsPage.test.tsx that anchors carry no node attribute.
| // In-page anchors stay in the tab; every other link (bundled-out doc pages | ||
| // rewritten to GitHub, or already-external URLs) opens in a new tab so the | ||
| // operator does not lose the dashboard. | ||
| const external = href?.startsWith("#") ? false : isExternal(resolved); |
There was a problem hiding this comment.
In-page anchors are kept in-tab here, but HashRouter owns location.hash, so this is the one link shape that breaks.
A ](#the-two-key-model) link renders as <a href="#the-two-key-model">; clicking it rewrites #/docs to #the-two-key-model, react-router parses the pathname as /the-two-key-model, nothing matches, and the path="*" route in App.tsx:77 redirects the operator to Overview. Reproduced against the real route table under Vitest.
Compounding it, there is no rehype-slug, so headings carry no id for such a link to target even under BrowserRouter.
Latent today (the guide has no ](# links), but remark-gfm footnotes generate #user-content-fn-* anchors that hit the same path. Either rewrite #frag to the GitHub source like the sibling doc links, or add rehype-slug and preventDefault() + scrollIntoView on click.
| .otari-markdown table { | ||
| display: block; | ||
| width: max-content; | ||
| max-width: 100%; | ||
| overflow-x: auto; |
There was a problem hiding this comment.
display: block on a <table> strips its table semantics.
Overriding the display removes the table box from the layout tree, and engines that derive a11y exposure from layout (WebKit definitively) drop role=table along with the row/cell/columnheader relationships on descendants. The guide's two-key table (docs/dashboard.md:19-25) then reaches VoiceOver as an undifferentiated run of text with no header-to-cell association, on a table whose third column states that losing OTARI_SECRET_KEY makes every stored provider key undecryptable.
DocsPage.test.tsx:33 cannot catch this: jsdom applies no stylesheet, so getAllByRole("table") resolves the implicit role from the bare <table> tag and passes either way.
Keep the native display and move the scroll to a wrapper via a components.table override in DocsPage.tsx:
table: ({ node: _node, ...props }) => (
<div className="otari-markdown-scroll" tabIndex={0} role="region" aria-label="Table">
<table {...props} />
</div>
),with .otari-markdown-scroll { overflow-x: auto; max-width: 100%; }.
| padding: 0.85rem 1rem; | ||
| background-color: var(--otari-ink); | ||
| border-radius: 0.6rem; | ||
| overflow-x: auto; |
There was a problem hiding this comment.
Scrollable region with no keyboard access (axe scrollable-region-focusable, WCAG 2.1.1): this pre and the table rule below both set overflow-x: auto but get no tabindex and contain no focusable descendants, so a keyboard-only or switch user tabs straight past them and can never reach the clipped content.
The table is the unconditional case (width: max-content sizes the two-key table to ~1200px, then clips); the pre blocks only overflow on narrow viewports. Add tabIndex={0} plus role="region" and an aria-label on the scroll containers via markdownComponents overrides, alongside the table wrapper fix.
| .otari-markdown a:hover { | ||
| color: var(--otari-brand); | ||
| } |
There was a problem hiding this comment.
Hover drops link contrast below AA: --otari-brand-dark #3c6678 is 6.2:1 on the white card, --otari-brand #4e8295 is 4.24:1. .otari-markdown sets font-size: 0.875rem at normal weight, so WCAG 1.4.3 requires 4.5:1. Contrast falls below threshold in exactly the state meant to emphasise the link.
Keep --otari-brand-dark and signal hover another way (text-decoration-thickness, or a --otari-brand-tint background), or darken rather than lighten.
Resolve khaledosman's review findings on PR #434: - Security: narrow the dev server's server.fs.allow to web/ and docs/ instead of the repo root. An explicit allow list replaces Vite's default, so the repo root exposed gitignored secrets (config.yml, otari.db) at /@fs/... over `npm run dev`. Verified config.yml now returns 403 while docs/dashboard.md still loads. - Bug: destructure react-markdown's `node` out of the `a`/`table`/`pre` DOM spread so links no longer ship node="[object Object]"; type the components with ComponentPropsWithoutRef & ExtraProps so TS catches the extra prop. - Rewrite in-page "#frag" links (and remark-gfm footnote anchors) to the GitHub source, since HashRouter owns location.hash and would bounce the operator to Overview. - A11y: keep the native <table> display (a display:block table drops role=table in WebKit) and move the horizontal scroll to a focusable region wrapper; make scrollable <pre> blocks focusable too (axe scrollable-region-focusable); signal link hover by thickening the underline instead of lightening the text below the 4.5:1 AA threshold; mark the sidebar guide icon aria-hidden. Adds tests that anchors carry no `node` attribute and that the table renders inside a focusable scroll region with its semantics intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/gateway/static/dashboard/assets/DocsPage-CX3Pja6K.js`:
- Around line 29-236: Regenerate the inlined guide constant Uc used by DocsPage
from the current docs/dashboard.md source so the bundled content matches HEAD,
including the # Admin dashboard heading and all guide updates. Keep the existing
DocsPage rendering and link-handling logic unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 04add438-86ce-4474-b806-6c80aabf6624
📒 Files selected for processing (23)
src/gateway/static/dashboard/assets/ActivityPage-yp-Y6d4o.jssrc/gateway/static/dashboard/assets/AliasesPage-BT4dCoEs.jssrc/gateway/static/dashboard/assets/BudgetsPage-BWlLKmfo.jssrc/gateway/static/dashboard/assets/ConfirmDialog-CT328Brd.jssrc/gateway/static/dashboard/assets/DocsPage-CX3Pja6K.jssrc/gateway/static/dashboard/assets/KeysPage-ApLcycd8.jssrc/gateway/static/dashboard/assets/ModelScopeControl-D-rIiK14.jssrc/gateway/static/dashboard/assets/ModelsPage-DSBlJhqc.jssrc/gateway/static/dashboard/assets/OverviewPage-C67k-EK5.jssrc/gateway/static/dashboard/assets/ProvidersPage-BIsJAuF5.jssrc/gateway/static/dashboard/assets/SettingsPage-RiKmM5Jg.jssrc/gateway/static/dashboard/assets/TablePagination-BEQ2yTEV.jssrc/gateway/static/dashboard/assets/ToolsGuardrailsPage-BX7O3avK.jssrc/gateway/static/dashboard/assets/UsagePage-CaSTdNwd.jssrc/gateway/static/dashboard/assets/UsersPage-ncvRRs6a.jssrc/gateway/static/dashboard/assets/index-73ln6Ld4.csssrc/gateway/static/dashboard/assets/index-DYaMwNVC.jssrc/gateway/static/dashboard/index.htmlweb/src/components/AppShell.tsxweb/src/pages/DocsPage.test.tsxweb/src/pages/DocsPage.tsxweb/src/styles/globals.cssweb/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- src/gateway/static/dashboard/index.html
- web/src/pages/DocsPage.test.tsx
- web/src/styles/globals.css
- web/src/components/AppShell.tsx
| plaintext key is write-only. Providers configured in \`config.yml\` also appear | ||
| here, marked \`config\` and read-only; keys you add in the UI are marked \`stored\` | ||
| and can be edited, tested, and deleted. | ||
|
|
||
| ### 6. Test the connection | ||
|
|
||
| On the Providers page, use **Test the connection** for the provider you just | ||
| added. Otari makes a live call to confirm the credential works before you route | ||
| real traffic through it. | ||
|
|
||
| ### 7. Send your first request | ||
|
|
||
| The Providers page includes a "Send your first request" snippet you can copy. | ||
| Point any OpenAI-compatible client at the gateway using an Otari API key or the | ||
| master key, and select a model in \`provider:model\` form (for example | ||
| \`openai:gpt-4o\`). See the [Quickstart](quickstart.md) for a full end-to-end | ||
| example. | ||
|
|
||
| ### 8. (Optional) Set up keys, users, and budgets | ||
|
|
||
| For multi-user or multi-app deployments, use the **Access** section of the | ||
| sidebar to hand out scoped API keys, define users, and attach budgets so spend | ||
| is enforced before each call. These are optional: a single-operator setup can | ||
| run on the master key alone. | ||
|
|
||
| ## Page-by-page reference | ||
|
|
||
| The sidebar groups pages by what they do. This section is filled in as pages | ||
| land; the groups below match the current dashboard. | ||
|
|
||
| ### Overview | ||
|
|
||
| The landing page. An at-a-glance view of spend, traffic, and health across the | ||
| gateway. | ||
|
|
||
| ### Observability | ||
|
|
||
| - **Activity**: the per-request log of what the gateway served, with filters. | ||
| Use it to inspect individual requests, their models, and their outcomes. | ||
| - **Usage**: aggregate usage and analytics, showing spend and volume over time, | ||
| broken down by model and by user. | ||
|
|
||
| ### Catalog | ||
|
|
||
| - **Providers**: add, edit, test, and delete provider credentials at runtime | ||
| (standalone only). Stored keys are encrypted with \`OTARI_SECRET_KEY\`; config | ||
| providers appear read-only. See the first-run walkthrough above. | ||
| - **Models**: browse the model catalogue and set per-model pricing, with specs | ||
| and modality metadata where available (from models.dev). | ||
| - **Aliases**: friendly names that resolve to a real provider model. Callers | ||
| use the alias; the underlying model stays private to the gateway. | ||
|
|
||
| ### Access | ||
|
|
||
| - **Users**: the principals that keys and budgets attach to, including the | ||
| default model access for a user's keys. | ||
| - **API keys**: issue and revoke gateway API keys, optionally restricting the | ||
| models a key may call and setting an expiry (leave blank for a key that never | ||
| expires). | ||
| - **Budgets**: spending limits callers are held to, with per-period resets. | ||
|
|
||
| For how users, keys, and budgets fit together and the management endpoints behind these pages, see [Access control](access-control.md). | ||
|
|
||
| ### System | ||
|
|
||
| - **Tools & Guardrails**: configure the backends for built-in tools (for | ||
| example the \`otari_web_search\` search backend) and request-level guardrails. | ||
| - **Settings**: search and toggle runtime settings, review and apply default | ||
| pricing updates, and rotate the generated master key. Rotating the master key | ||
| issues a fresh \`otari-mk-…\` value and keeps your current session signed in. | ||
|
|
||
| ## Security notes | ||
|
|
||
| - **The master key is an admin credential.** Anyone who has it can read and | ||
| change gateway configuration through the management API. Rotate it if you | ||
| suspect it leaked. | ||
| - **Use HTTPS for anything but local access.** The \`http://localhost:8000/\` | ||
| examples here assume you are on the same machine (loopback). The master key | ||
| authorizes every management request and must never travel over cleartext HTTP, | ||
| so put the gateway behind HTTPS or a trusted reverse proxy before signing in | ||
| from another host. | ||
| - **Session storage, not local storage.** The dashboard keeps the master key in | ||
| the browser tab's session storage, so it does not persist across tabs or | ||
| survive closing the tab, and signing out clears it along with any cached admin | ||
| data. | ||
| - **Provider keys are write-only over the API.** Once stored, the plaintext is | ||
| never returned; the UI shows only the last four characters. Losing | ||
| \`OTARI_SECRET_KEY\` makes stored keys undecryptable, so back it up separately | ||
| from the database and rotate it by prepending a new key (see | ||
| [Configuration](configuration.md#runtime-provider-management)). | ||
|
|
||
| ## See also | ||
|
|
||
| - [Configuration](configuration.md): every environment variable and config | ||
| field, including \`OTARI_MASTER_KEY\` and \`OTARI_SECRET_KEY\`. | ||
| - [Quickstart](quickstart.md): get the gateway running and make your first | ||
| request. | ||
| - [Modes](modes.md): standalone versus hybrid, and why the dashboard is | ||
| standalone-only. | ||
| `,er="https://github.com/mozilla-ai/otari/blob/main/docs/",qc="dashboard.md";function Vc(e){return!e||/^[a-z][a-z0-9+.-]*:/i.test(e)||e.startsWith("//")?e:e.startsWith("#")?er+qc+e:er+e.replace(/^\.\//,"")}function $c(e){return!!e&&(/^https?:\/\//i.test(e)||e.startsWith("//"))}const Wc=Uc.replace(/^#{1,6}[ \t]+.*\r?\n+/,""),Yc={a:({node:e,href:t,children:n,...r})=>{const i=Vc(t),o=$c(i);return re.jsx("a",{href:i,...o?{target:"_blank",rel:"noreferrer"}:{},...r,children:n})},table:({node:e,...t})=>re.jsx("div",{className:"otari-markdown-scroll",tabIndex:0,role:"region","aria-label":"Table",children:re.jsx("table",{...t})}),pre:({node:e,...t})=>re.jsx("pre",{tabIndex:0,role:"region","aria-label":"Code",...t})};function Jc(){return re.jsxs("div",{className:"flex flex-col gap-6",children:[re.jsx(gi,{title:"User guide",description:"The operator guide for this dashboard, bundled with and version-matched to the running gateway."}),re.jsx(ct,{children:re.jsx(ct.Content,{className:"p-5 sm:p-6",children:re.jsx("div",{className:"otari-markdown",children:re.jsx(vu,{remarkPlugins:[Hc],components:Yc,children:Wc})})})})]})}export{Jc as DocsPage}; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Compare the guide text embedded in the committed bundle against docs/dashboard.md.
fd -t f 'DocsPage-.*\.js' src/gateway/static/dashboard/assets
fd -t f '^dashboard\.md$' docs
python - <<'PY'
import re, pathlib, sys
bundles = sorted(pathlib.Path('src/gateway/static/dashboard/assets').glob('DocsPage-*.js'))
src = pathlib.Path('docs/dashboard.md')
if not bundles or not src.exists():
print('bundle or docs/dashboard.md not found'); sys.exit(0)
bundle_text = bundles[0].read_text(encoding='utf-8')
# The guide is inlined as a template literal starting at the top-level heading.
m = re.search(r'`# Admin dashboard\n(.*?)`,', bundle_text, re.S)
if not m:
print('could not locate inlined guide in', bundles[0]); sys.exit(0)
embedded = ('# Admin dashboard\n' + m.group(1))
embedded = embedded.replace('\\`', '`').replace('\\$', '$').replace('\\\\', '\\')
on_disk = src.read_text(encoding='utf-8')
print('bundle:', bundles[0].name)
print('embedded chars:', len(embedded), '| docs/dashboard.md chars:', len(on_disk))
print('MATCH' if embedded.strip() == on_disk.strip() else 'MISMATCH — rebuild the dashboard bundle')
PYRepository: mozilla-ai/otari
Length of output: 351
Keep the inlined dashboard guide synced with docs/dashboard.md.
The bundle contains # Admin dashboard, but it does not match the Markdown source at HEAD, so any guide edits won’t be version-matched until the dashboard bundle is rebuilt.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/gateway/static/dashboard/assets/DocsPage-CX3Pja6K.js` around lines 29 -
236, Regenerate the inlined guide constant Uc used by DocsPage from the current
docs/dashboard.md source so the bundled content matches HEAD, including the #
Admin dashboard heading and all guide updates. Keep the existing DocsPage
rendering and link-handling logic unchanged.
Decorative icon on a text-labelled link; hide it from assistive tech, matching the new user-guide icon beside it. This also makes the footer block a complete superset of PR #435's a11y pass, so resolving the (latent) footer merge conflict in favour of this block drops none of #435's aria-hidden coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uide The guide is reachable only through a running, signed-in dashboard, so the getting-started walkthrough (start the gateway, find the master key, sign in) is circular there. Drop that section from the rendered view so /#/docs reads as an operator reference (two-key model, page-by-page reference, security notes), and point newcomers at /welcome for the get-started flow. The trim is render-side only: docs/dashboard.md stays whole for readers on GitHub and the /welcome tutorial. Its post-sign-in substance (adding a provider, keys, budgets) is already covered by the retained page-by-page reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
khaledosman
left a comment
There was a problem hiding this comment.
Re-reviewed at 30340fa. All six findings from my last pass are fixed, and I re-ran each check rather than reading the diff:
| Finding | Verification |
|---|---|
server.fs.allow served repo secrets |
Live dev server on this branch: config.yml → 403 (was 200, master key in the body), pyproject.toml → 403, docs/dashboard.md?raw → 200, app root → 200. |
node="[object Object]" on every anchor |
Rendered page: 0 elements carry a node attribute (was 8/8 anchors). |
display: block stripped table semantics |
<table> keeps its native display and renders inside .otari-markdown-scroll. |
| Scroll regions unreachable by keyboard | Table wrapper and all 3 <pre> now tabindex="0". No global outline reset in the built CSS (the one outline:none is scoped to a toast button), so the UA focus ring shows. |
#frag links broke HashRouter |
Rewritten to …/docs/dashboard.md#frag; all 8 links now resolve to absolute GitHub URLs with target="_blank". |
| Hover contrast 4.24:1 | text-decoration-thickness instead, keeping --otari-brand-dark at 6.2:1. |
tsc -b --noEmit clean, 291/291 tests pass, and a fresh npm run build leaves src/gateway/static/dashboard/ byte-identical, so the committed bundle is current.
The aria-hidden added on the book icon also clears the merge hazard I flagged against #435: both svgs now carry it on either side, so resolving in favour of this PR's footer block no longer silently reverts that a11y fix.
Two nits inline, neither blocking.
🤖 Review generated with Claude Code
| // in) is circular here. Drop it; it stays in docs/dashboard.md for readers | ||
| // on GitHub and the /welcome tutorial, and its post-sign-in substance is | ||
| // covered by the page-by-page reference below. | ||
| const guideBody = dropSection( | ||
| dashboardGuide.replace(/^#{1,6}[ \t]+.*\r?\n+/, ""), | ||
| "First-run walkthrough", | ||
| ); | ||
|
|
||
| // react-markdown passes each node's hast `node` to custom components; it must be | ||
| // destructured out of the DOM spread or it renders as node="[object Object]" on |
There was a problem hiding this comment.
Nit: role="region" with an accessible name is a landmark, so the guide now injects four of them into the landmark list, three identically named:
landmark regions: 4
pres: role=region,region,region labels: Code,Code,Code
Navigating by landmark gives "Code, Code, Code, Table" with nothing to tell them apart.
scrollable-region-focusable only requires tabindex; the role is what makes them landmarks. role="group" keeps the name announced when focus lands without polluting landmark navigation:
- table: ({ node: _node, ...props }: MdProps<"table">) => (
- <div className="otari-markdown-scroll" tabIndex={0} role="region" aria-label="Table">
+ table: ({ node: _node, ...props }: MdProps<"table">) => (
+ <div className="otari-markdown-scroll" tabIndex={0} role="group" aria-label="Table">Same for the pre override.
| // The two-key model is a Markdown table; rendering it as a real <table> | ||
| // proves remark-gfm is wired up (plain Markdown would leave it as text). | ||
| const tables = screen.getAllByRole("table"); | ||
| expect(tables.length).toBeGreaterThan(0); |
There was a problem hiding this comment.
Nit: the new coverage for the table region and the node leak is good, but two behaviours added in the fix commit are untested:
- the
#frag→dashboard.md#fragrewrite, which is the case that originally broke under HashRouter prefocusability (the table wrapper is asserted, the code blocks are not)
Both extend this test naturally, e.g. asserting an #anchor href resolves to https://github.com/mozilla-ai/otari/blob/main/docs/dashboard.md#… and that every <pre> carries tabindex="0".
Description
Fixes #329.
A self-hosted gateway can run a version of Otari that differs from otari.ai, so the dashboard should carry the guide that matches the running build rather than sending operators to a separate docs site that may describe a different version.
What this does:
/#/docs(behind the existing master-key sign-in) that rendersdocs/dashboard.mdinline, with a "User guide" link in the sidebar footer.?raw, so it inlines into the lazy-loaded docs chunk at build time and stays version-matched to the bundle. The chunk loads only when the page is visited, so the shell stays small.react-markdown+remark-gfm(the guide has GFM tables and fenced code). Prose is styled with the existing--otari-*tokens via a scoped.otari-markdownstylesheet, mirroring the.otari-tablepattern (no typography plugin added).configuration.md) are rewritten to the rendered source on GitHub and open in a new tab; in-page anchors stay in the tab.Frontend only:
/#/docsis a client-sideHashRouterroute served by the existingindex.html, so no backend route was needed. Because the guide lives outsideweb/,web/vite.config.tsgrantsserver.fsaccess to the repo root, and the dashboard staleness workflow now also triggers ondocs/dashboard.mdso an edit that leaves the committed bundle stale is caught in CI.PR Type
Relevant issues
Fixes #329
Checklist
web/src/pages/DocsPage.test.tsx,web/src/components/AppShell.test.tsx; this is aweb/change, so the JS suite covers it rather thantests/unit).npm --prefix web run typecheck,npm --prefix web test,npm --prefix web run build; no Python changed).AI Usage
AI Model/Tool used: Claude Code (Opus 4.8)
Any additional AI details you'd like to share: Implemented via back-and-forth with @njbrake; the reasoning and decisions are his.
Summary
/docsdashboard page that embeds the version-matched user guide.Benefits
Users can access the deployed version’s guide directly from the dashboard without leaving the application, while related documentation remains easy to discover and the rendered content is more accessible.