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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

67 changes: 0 additions & 67 deletions internal/admin/dashboard/static/dist/assets/index-BOEWOpVo.js

This file was deleted.

67 changes: 67 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-Dd3LDA86.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 23 additions & 10 deletions web/dashboard/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ follow these rules so the pages compose into one coherent app.
the Go backend implements it — `internal/admin/*.go` is the source of
truth. Do not invent endpoints or payload fields.
2. **CSS lives with its owner.** Component-specific styles are scoped
`<style>` blocks in the owning component; `src/styles/dashboard.css`
holds only the shared design system (tokens, reset, typography, buttons,
forms, tables, alerts, layout, keyframes) plus rules that target
child-component DOM — a class passed as a prop into `Icon` or
`LoadingState` renders in the child's markup, where the parent's scope
hash cannot match. Reuse existing shared class names for standard
`<style>` blocks in the owning component; the shared design system
(tokens, reset, typography, buttons, forms, tables, alerts, layout,
keyframes) plus rules that target child-component DOM — a class passed
as a prop into `Icon` or `LoadingState` renders in the child's markup,
where the parent's scope hash cannot match — lives in `src/styles/`.
`dashboard.css` is only the entry point: it `@import`s the modules in
cascade order, so append new rules to the module that owns them and
never reorder the imports. No CSS preprocessor, on purpose: scoped
styles, custom properties, and native nesting cover the need with zero
extra dependencies. Reuse existing shared class names for standard
elements.
3. **Mind the scope hash when moving CSS.** Scoping adds specificity, and it
cuts both ways:
Expand Down Expand Up @@ -64,6 +68,11 @@ const saved = await sendJSON("/admin/foo", "POST", payload, { label: "save foo"
pure page logic and its `node:test` suite import them from there directly.
- `apiFetch(path, options)` is the raw escape hatch (SSE, blobs); it adds auth
+ timezone headers and the base path. Never call `fetch` on `/admin/...`.
- **CRUD stores use `$lib/api/adminCrud.js`**: `loadAdminList` /
`sendAdminMutation` reduce a request to an outcome object with the guard
ladder applied in the one correct order (stale first, then
unavailable-503, then errors; 401 loads stay silent). Apply the outcome
to your `$state` fields instead of re-implementing the branches.

### Stores (`$lib/stores/*.svelte.js`) — all singletons

Expand All @@ -77,15 +86,19 @@ confirmations).

- **atoms** — `Icon` (kebab-case lucide names), `Spinner`, `Modal`,
`EmptyState`, `NoDataIllustration`, `CopyButton`, `TableActionButton`,
`DialogCloseButton`, `SegmentedControl`.
`DialogCloseButton`, `SegmentedControl`, `EnabledToggle`, `GoModelLogo`.
- **molecules** — `LoadingState`, `Pagination`, `DatePicker`, `FilterInput`,
`InlineHelpSection`, `ChartCanvas`, `DemoModeBanner`.
- **organisms** — `AuthBanner`, `AuthDialog`, `Sidebar`, `ThemeToggle`,
`FlashMessages`, `TypedConfirmationDialog`.
`InlineHelpSection`, `ChartCanvas`, `DemoModeBanner`, `FormField`.
- **organisms** — `AuthBanner`, `AuthDialog`, `Sidebar` (nav items in
`navigation.js`), `ThemeToggle`, `FlashMessages`,
`TypedConfirmationDialog`, `EditorDialog`.

`Modal` handles Escape/backdrop/scroll-lock and autofocuses
`[data-modal-autofocus]`. `ChartCanvas` runs its `build()` inside an effect,
so reactive reads are tracked and theme changes rebuild automatically.
**Every create/edit modal composes `EditorDialog`** (shell + header + error
banner + actions + the Escape-under-auth-dialog guard) with `FormField`
cells inside — never hand-roll that shell again.

### Utils

Expand Down
114 changes: 114 additions & 0 deletions web/dashboard/src/lib/api/adminCrud.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Shared request lifecycle for the admin CRUD stores (budgets, rate limits,
// MCP servers, guardrails, auth keys, provider credentials, workflows, ...).
// Every store repeats the same guard ladder around getJSON/sendJSON; these
// helpers encode it once, in the one correct order:
//
// 1. stale — the response belongs to a previous API key: the caller must
// return without touching ANY state (CONVENTIONS.md), so it is
// checked before everything else, including 503.
// 2. unavailable — the feature is disabled on the gateway (503, sometimes
// 404): clear the list and flip the page's "available" flag.
// 3. error — 401 stays silent (the global auth dialog owns it); other
// failures produce a human-readable message.
//
// The helpers never touch store state themselves — each store applies the
// returned outcome to its own $state fields, so public store APIs stay put.
//
// Outcomes: loadAdminList resolves to { status, items, error, result } and
// sendAdminMutation to { status, error, result }, where status is one of
// "ok" | "stale" | "unavailable" | "error", `error` is the message for the
// page/form error slot (empty unless status === "error", except the
// unavailable mutation message), and `result` is the raw getJSON/sendJSON
// envelope (null when the request itself threw).

import { getJSON, isAbortError, sendJSON } from "./client.js";
import { errorPayloadMessage } from "./errors.js";

// loadAdminList GETs an admin collection and reduces the response to an
// outcome the store can apply in a few lines. Options:
// label — console/error label ("budgets")
// errorFallback — inline error fallback (default "Unable to load {label}.")
// unavailableStatuses — statuses meaning "feature disabled" (default [503])
// normalize — maps the raw payload to rows (default as-is array, else [])
// options — extra fetch options (signal, ...)
export async function loadAdminList(
path,
{
label,
errorFallback = `Unable to load ${label}.`,
unavailableStatuses = [503],
normalize = (data) => (Array.isArray(data) ? data : []),
options,
},
) {
let result;
try {
result = await getJSON(path, { ...(options || {}), label });
} catch (e) {
// Aborted requests stay silent: the caller's signal.aborted guard is
// what decides whether the outcome is applied at all.
if (!isAbortError(e)) {
console.error(`Failed to fetch ${label}:`, e);
}
return { status: "error", items: [], error: errorFallback, result: null };
}
if (result.stale) {
return { status: "stale", items: [], error: "", result };
}
if (unavailableStatuses.includes(result.status)) {
return { status: "unavailable", items: [], error: "", result };
}
if (!result.ok) {
const error =
result.status === 401
? ""
: errorPayloadMessage(result.data, errorFallback);
return { status: "error", items: [], error, result };
}
return { status: "ok", items: normalize(result.data), error: "", result };
}

// sendAdminMutation wraps sendJSON (PUT/POST/DELETE) with the same ladder.
// Flash messages, closing forms, and refetching stay in the store — only the
// guard boilerplate lives here. Options:
// label — console/error label ("save budget")
// errorFallback — form error fallback (default "Unable to {label}.")
// unavailableStatuses — statuses meaning "feature disabled" (default [503])
// unavailableMessage — form error text for the unavailable outcome
// options — extra fetch options
export async function sendAdminMutation(
path,
method,
body,
{
label,
errorFallback = `Unable to ${label}.`,
unavailableStatuses = [503],
unavailableMessage = "This feature is unavailable on the gateway.",
options,
},
) {
let result;
try {
result = await sendJSON(path, method, body, { ...(options || {}), label });
} catch (e) {
if (!isAbortError(e)) {
console.error(`Failed to ${label}:`, e);
}
return { status: "error", error: errorFallback, result: null };
}
if (result.stale) {
return { status: "stale", error: "", result };
}
if (unavailableStatuses.includes(result.status)) {
return { status: "unavailable", error: unavailableMessage, result };
}
if (!result.ok) {
const error =
result.status === 401
? "Authentication required."
: errorPayloadMessage(result.data, errorFallback);
return { status: "error", error, result };
}
return { status: "ok", error: "", result };
}
34 changes: 34 additions & 0 deletions web/dashboard/src/lib/components/atoms/EnabledToggle.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script>
// Enabled/Disabled slide toggle shared by editors (MCP server, provider
// credential, failover rule, virtual model) and list rows. Renders the
// .alias-toggle track/thumb pair from the global sheet.
//
// Props:
// enabled — current state (parent flips it in onclick)
// label — what is being toggled, for the aria-label ("MCP server")
// disabled — read-only (config-managed rows)
// restricted — the "enabled but user-path-scoped" amber state
// onclick — toggle handler
// text — visible caption override; defaults to Enabled/Disabled
let {
enabled = false,
label = "",
disabled = false,
restricted = false,
onclick,
text,
} = $props();
</script>

<button
type="button"
class="alias-toggle"
class:enabled
class:restricted
{disabled}
aria-label={(enabled ? "Disable " : "Enable ") + label}
{onclick}
>
<span class="alias-toggle-track"><span class="alias-toggle-thumb"></span></span>
<span>{text ?? (enabled ? "Enabled" : "Disabled")}</span>
</button>
8 changes: 8 additions & 0 deletions web/dashboard/src/lib/components/atoms/GoModelLogo.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!-- GoModel hexagon logo mark. Renders a bare inline SVG that fills its
container and inherits currentColor — the parent owns sizing/color
(Sidebar's `.sidebar-logo` wrapper styles it via `:global(svg)`). -->
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 3L25.39 9L25.39 21L15 27L4.61 21L4.61 9Z" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="15" cy="15" r="4" fill="currentColor" opacity="0.3"/>
<path d="M15 9.5L15 6M15 20.5L15 24M19.76 12.25L22.79 10.5M10.24 17.75L7.21 19.5M19.76 17.75L22.79 19.5M10.24 12.25L7.21 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
Comment on lines +4 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mark the decorative logo as hidden from assistive tech.

The SVG has no accessible name and no aria-hidden, so screen readers may announce it as an unlabeled graphic next to the product name.

♿ Proposed fix
-<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
📝 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.

Suggested change
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 3L25.39 9L25.39 21L15 27L4.61 21L4.61 9Z" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="15" cy="15" r="4" fill="currentColor" opacity="0.3"/>
<path d="M15 9.5L15 6M15 20.5L15 24M19.76 12.25L22.79 10.5M10.24 17.75L7.21 19.5M19.76 17.75L22.79 19.5M10.24 12.25L7.21 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M15 3L25.39 9L25.39 21L15 27L4.61 21L4.61 9Z" stroke="currentColor" stroke-width="2" fill="none"/>
<circle cx="15" cy="15" r="4" fill="currentColor" opacity="0.3"/>
<path d="M15 9.5L15 6M15 20.5L15 24M19.76 12.25L22.79 10.5M10.24 17.75L7.21 19.5M19.76 17.75L22.79 19.5M10.24 12.25L7.21 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
🤖 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 `@web/dashboard/src/lib/components/atoms/GoModelLogo.svelte` around lines 4 -
8, Update the decorative SVG in GoModelLogo to include aria-hidden="true",
ensuring assistive technologies ignore the unlabeled logo while preserving its
visual rendering.

14 changes: 14 additions & 0 deletions web/dashboard/src/lib/components/molecules/FormField.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
// FormField — the label + control wrapper every editor form-grid cell uses.
// The control itself stays in the caller (inputs need page-specific
// bindings, autofocus flags, and datalists), so this only standardizes the
// .form-field / .form-field-label shell around it.
//
// Props: id (the control's id, wired to the label), label, children.
let { id, label = "", children } = $props();
</script>

<div class="form-field">
<label class="form-field-label" for={id}>{label}</label>
{@render children?.()}
</div>
118 changes: 118 additions & 0 deletions web/dashboard/src/lib/components/organisms/EditorDialog.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script>
// EditorDialog — the shared editor-modal shell used by every create/edit
// dialog: Modal wrapper, editor header (title + close button), the form
// element, the error banner, and the footer actions row.
//
// Props:
// open — bind to the store's formOpen flag
// title — heading text ("Edit Budget"); also the default aria-label
// ariaLabel — accessible dialog name when it should differ from title
// error — form-level error text (rendered above the actions row)
// submitting — disables the submit button and swaps its label
// submitLabel / submittingLabel / submitIcon — submit button content
// cancel — set false to drop the Cancel button (default true)
// dialogClass — extra classes on the dialog shell next to .model-editor
// novalidate — skip native form validation (hidden-control traps)
// canClose — extra guard consulted on Escape/backdrop close, for
// editors that stack another dialog on top
// onclose — close the editor (called for Cancel/close/Escape)
// onsubmit — submit the form (preventDefault is already handled)
// Snippets:
// header — replaces the default <h3>{title}</h3> block
// headerHint — optional extra content under the default title
// children — the form fields
// extraActions — extra footer buttons between Cancel and submit
//
// Escape/backdrop closes are ignored while the auth dialog is on top, so
// a 401 never silently discards the form underneath it.
import Modal from "$lib/components/atoms/Modal.svelte";
import DialogCloseButton from "$lib/components/atoms/DialogCloseButton.svelte";
import Icon from "$lib/components/atoms/Icon.svelte";
import { auth } from "$lib/stores/auth.svelte.js";

let {
open = false,
title = "",
ariaLabel = "",
error = "",
submitting = false,
submitLabel = "Save",
submittingLabel = "Saving...",
submitIcon = "save",
cancel = true,
dialogClass = "",
novalidate = false,
canClose = () => true,
onclose,
onsubmit,
header,
headerHint,
children,
extraActions,
} = $props();

function onModalClose() {
if (auth.dialogOpen) return;
if (!canClose()) return;
onclose?.();
}
</script>

<Modal {open} variant="editor" onclose={onModalClose}>
<div
class={"model-editor" + (dialogClass ? " " + dialogClass : "")}
role="dialog"
aria-modal="true"
aria-label={ariaLabel || title}
>
<form
class="form"
{novalidate}
onsubmit={(event) => {
event.preventDefault();
onsubmit?.();
}}
>
<div class="editor-header">
<div>
{#if header}
{@render header()}
{:else}
<h3>{title}</h3>
{#if headerHint}
{@render headerHint()}
{/if}
{/if}
</div>
<DialogCloseButton
label={"Close " + (ariaLabel || title).toLowerCase()}
onclick={() => onclose?.()}
/>
</div>

{@render children?.()}

{#if error}
<p class="form-error" role="alert" aria-live="assertive">{error}</p>
{/if}
<div class="form-actions">
{#if cancel}
<button type="button" class="btn" onclick={() => onclose?.()}>
Cancel
</button>
{/if}
{#if extraActions}
{@render extraActions()}
{/if}
<button
type="submit"
class="btn btn-primary btn-with-icon"
disabled={submitting}
>
<Icon name={submitIcon} class="form-action-icon" />
<span>{submitting ? submittingLabel : submitLabel}</span>
</button>
Comment on lines +107 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Separate "in flight" from "not submittable".

submitting drives both disabled and the label swap, so consumers that need a permanently-disabled submit (failoverFormManaged, vmDeleting, vmFormManaged) must pass a fake submitting and then re-derive submittingLabel to suppress "Saving...". Two callers already duplicate that logic; a dedicated submitDisabled prop keeps the label honest.

♻️ Proposed refactor
     submitting = false,
+    submitDisabled = false,
     submitLabel = "Save",
         <button
           type="submit"
           class="btn btn-primary btn-with-icon"
-          disabled={submitting}
+          disabled={submitting || submitDisabled}
         >

Callers then pass e.g. submitting={failover.failoverSaving} and submitDisabled={failover.failoverFormManaged || failover.failoverGenerating} and drop the custom submittingLabel.

🤖 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 `@web/dashboard/src/lib/components/organisms/EditorDialog.svelte` around lines
107 - 114, Separate the EditorDialog submit button’s in-flight state from its
disabled state by adding and using a dedicated submitDisabled prop for the
disabled attribute, while keeping submitting responsible only for the submitting
label and icon state. Update callers that currently fake submitting or override
submittingLabel for failoverFormManaged, vmDeleting, or vmFormManaged to pass
their real saving/deleting state as submitting and their permanent
non-submittable conditions as submitDisabled, removing redundant custom
submittingLabel values.

</div>
</form>
</div>
</Modal>
Loading