Add NavMenu settings/presets UI and profile HTTP routes (ticket 011)

Adds thin HTTP routes for profile CRUD and colour-scheme updates
(GET/PATCH /api/settings, GET/POST /api/settings/profiles,
DELETE /api/settings/profiles/:name, POST /api/settings/profiles/:name/activate)
following the existing CSS-route pattern in api.rs, with activate hot-swapping
the running engine's declared params/metrics so switching profiles needs no
restart. Builds out the NavMenu frontend with Settings (colour scheme picker,
CSS import/list/delete) and Presets (list/create/switch/delete profiles with
declaration counts) panels, and a useSettingsPanel hook that applies the
active colour scheme and imported CSS to the document live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:21:31 +01:00
co-authored by Claude Sonnet 5
parent 57d23c62e9
commit 801ac36b78
6 changed files with 772 additions and 26 deletions
+268 -22
View File
@@ -1,30 +1,276 @@
import { useState } from 'react'
import type { Stats } from '../lib/api'
import type { ColorScheme } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
type PanelName = 'settings' | 'presets' | null
const COLOR_SCHEMES: ColorScheme[] = ['light', 'dark', 'system']
export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null)
const panel = useSettingsPanel()
function togglePanel(name: PanelName) {
setOpenPanel((current) => (current === name ? null : name))
}
return (
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
<div>
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
Presets
</span>
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
Settings
</span>
</nav>
</div>
<div className="text-xs text-neutral-500">
{stats ? (
<>
<div>{stats.log_count} logs</div>
<div>seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}</div>
</>
) : (
<div>connecting…</div>
)}
<div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
<div>
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
<button
type="button"
onClick={() => togglePanel('presets')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
openPanel === 'presets' ? 'bg-neutral-800 text-neutral-100' : ''
}`}
>
Presets
</button>
<button
type="button"
onClick={() => togglePanel('settings')}
className={`rounded px-2 py-1 text-left hover:bg-neutral-800 ${
openPanel === 'settings' ? 'bg-neutral-800 text-neutral-100' : ''
}`}
>
Settings
</button>
</nav>
</div>
<div className="text-xs text-neutral-500">
{stats ? (
<>
<div>{stats.log_count} logs</div>
<div>
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
</div>
</>
) : (
<div>connecting…</div>
)}
</div>
</div>
{openPanel && (
<div className="fixed inset-y-0 left-48 z-20 flex w-80 flex-col overflow-y-auto border-r border-neutral-800 bg-neutral-900 p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-neutral-100">
{openPanel === 'settings' ? 'Settings' : 'Presets'}
</h2>
<button
type="button"
onClick={() => setOpenPanel(null)}
className="rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
aria-label="Close panel"
>
✕
</button>
</div>
{panel.error && (
<div className="mb-3 rounded border border-red-900 bg-red-950/50 px-2 py-1 text-xs text-red-300">
{panel.error}
</div>
)}
{openPanel === 'settings' ? <SettingsPanel panel={panel} /> : <PresetsPanel panel={panel} />}
</div>
)}
</div>
)
}
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [filename, setFilename] = useState('')
const [content, setContent] = useState('')
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setFilename(file.name)
setContent(await file.text())
}
async function handleImport(e: React.FormEvent) {
e.preventDefault()
if (!filename.trim() || !content.trim()) return
await panel.importCssEntry(filename.trim(), content)
setFilename('')
setContent('')
}
return (
<div className="flex flex-col gap-5 text-sm text-neutral-300">
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Colour scheme</h3>
<div className="flex gap-1">
{COLOR_SCHEMES.map((scheme) => (
<button
key={scheme}
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`flex-1 rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.color_scheme === scheme
? 'border-neutral-400 bg-neutral-800 text-neutral-100'
: 'border-neutral-700 text-neutral-400 hover:bg-neutral-800'
}`}
>
{scheme}
</button>
))}
</div>
<p className="mt-1 text-[11px] text-neutral-600">
"Light" only recolours the base page for now — most components still use fixed dark colours.
</p>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Import CSS</h3>
<form onSubmit={handleImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleFileChange}
className="text-xs text-neutral-400 file:mr-2 file:rounded file:border file:border-neutral-700 file:bg-neutral-800 file:px-2 file:py-1 file:text-neutral-200"
/>
<input
type="text"
placeholder="filename.css"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
/>
<textarea
placeholder="Paste CSS content here"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="resize-y rounded border border-neutral-700 bg-neutral-900 px-2 py-1 font-mono text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
>
Import
</button>
</form>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Configured CSS</h3>
{panel.cssEntries.length === 0 ? (
<div className="text-xs text-neutral-600">No CSS configured.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-neutral-800 px-2 py-1 text-xs"
>
<div className="min-w-0">
<div className="truncate text-neutral-200">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-neutral-600">{entry.kind}</div>
</div>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="ml-2 shrink-0 text-neutral-700" title="Linked CSS isn't deletable here">
—
</span>
)}
</li>
))}
</ul>
)}
</section>
</div>
)
}
function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [newName, setNewName] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
await panel.addProfile(newName.trim())
setNewName('')
}
return (
<div className="flex flex-col gap-4 text-sm text-neutral-300">
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="new profile name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
/>
<button
type="submit"
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-200 hover:bg-neutral-800"
>
Create
</button>
</form>
{panel.profiles.length === 0 ? (
<div className="text-xs text-neutral-600">No profiles yet.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.profiles.map((profile) => {
const isActive = panel.settings?.active_profile === profile.name
return (
<li
key={profile.name}
className={`rounded border px-2 py-1.5 text-xs ${
isActive ? 'border-neutral-500 bg-neutral-800' : 'border-neutral-800'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => !isActive && panel.switchProfile(profile.name)}
disabled={isActive}
className={`truncate text-left font-medium ${
isActive ? 'cursor-default text-neutral-100' : 'text-neutral-200 hover:underline'
}`}
title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
>
{profile.name}
{isActive && <span className="ml-1 text-[10px] uppercase text-neutral-400">active</span>}
</button>
<button
type="button"
onClick={() => panel.removeProfile(profile.name)}
className="ml-2 shrink-0 rounded px-1.5 text-neutral-500 hover:bg-neutral-800 hover:text-red-400"
aria-label={`Delete ${profile.name}`}
>
delete
</button>
</div>
<div className="mt-1 text-[10px] text-neutral-500">
{profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
</div>
</li>
)
})}
</ul>
)}
<p className="text-[11px] text-neutral-600">
Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
</p>
</div>
)
}
+14
View File
@@ -15,3 +15,17 @@ body {
@apply bg-neutral-950 text-neutral-200;
font: 14px/1.5 ui-monospace, Consolas, monospace;
}
/*
* Ticket 011: colour-scheme picker support. Most of the app's Tailwind
* classes are hardcoded to a dark palette (e.g. `bg-neutral-950` above)
* rather than using `dark:`-variant classes, so there isn't a full light
* theme to switch to yet. This override (keyed off the `light` class
* `useSettingsPanel` toggles on `<html>`) gives "Light" a real, if minimal,
* visible effect on the base page colours; a proper light theme would need
* the rest of the app's components reworked to use theme-aware classes.
*/
html.light body {
background-color: #f5f5f4;
color: #1c1917;
}
+83
View File
@@ -43,6 +43,29 @@ export interface Stats {
last_timestamp_ms: number | null
}
export type ColorScheme = 'light' | 'dark' | 'system'
export interface Settings {
color_scheme: ColorScheme
active_profile: string | null
}
export type CssEntryKind = 'imported' | 'linked'
export interface CssEntry {
id: string
kind: CssEntryKind
filename: string | null
path_or_url: string | null
}
export interface ProfileSummary {
name: string
param_count: number
metric_count: number
dashboard_count: number
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
@@ -51,6 +74,26 @@ async function getJson<T>(path: string): Promise<T> {
return res.json() as Promise<T>
}
async function sendJson<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
let message = `${path} failed: ${res.status}`
try {
const data = await res.json()
if (data?.error) message = data.error
} catch {
// response body wasn't JSON (or was empty) — fall back to the generic message
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
@@ -84,3 +127,43 @@ export function fetchStats(): Promise<Stats> {
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}
export function fetchSettings(): Promise<Settings> {
return getJson('/api/settings')
}
export function updateSettings(update: { colorScheme?: ColorScheme }): Promise<Settings> {
return sendJson('/api/settings', 'PATCH', { color_scheme: update.colorScheme })
}
export function fetchCssEntries(): Promise<CssEntry[]> {
return getJson('/api/settings/css')
}
export function importCss(filename: string, content: string): Promise<CssEntry> {
return sendJson('/api/settings/css', 'POST', { filename, content })
}
export function deleteCssEntry(id: string): Promise<void> {
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
}
export function cssContentUrl(id: string): string {
return `/api/settings/css/${encodeURIComponent(id)}/content`
}
export function fetchProfiles(): Promise<ProfileSummary[]> {
return getJson('/api/settings/profiles')
}
export function createProfile(name: string): Promise<ProfileSummary> {
return sendJson('/api/settings/profiles', 'POST', { name })
}
export function deleteProfile(name: string): Promise<void> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}`, 'DELETE')
}
export function activateProfile(name: string): Promise<Settings> {
return sendJson(`/api/settings/profiles/${encodeURIComponent(name)}/activate`, 'POST')
}
+181
View File
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useState } from 'react'
import {
activateProfile,
createProfile,
cssContentUrl,
deleteCssEntry,
deleteProfile,
fetchCssEntries,
fetchProfiles,
fetchSettings,
importCss,
updateSettings,
type ColorScheme,
type CssEntry,
type ProfileSummary,
type Settings,
} from './api'
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
/**
* Resolves `colorScheme` to an effective `light`/`dark` value (following the
* OS preference for `system`) and reflects it onto `<html>` as both a class
* (`light`/`dark`) and a `data-color-scheme` attribute. The app's Tailwind
* classes are mostly hardcoded to a dark palette (see `index.css`) rather
* than using `dark:` variants, so "Light" mode is necessarily limited to
* whatever `index.css` explicitly overrides for `.light` — this is a known,
* documented limitation rather than a full theme system.
*/
function applyColorScheme(colorScheme: ColorScheme) {
const root = document.documentElement
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
root.classList.remove('light', 'dark')
root.classList.add(effective)
root.dataset.colorScheme = colorScheme
}
/**
* Injects a `<link>` tag per CSS entry into `<head>` (imported entries point
* at `GET /api/settings/css/:id/content`, linked entries point straight at
* their `path_or_url`), after removing any tags this hook previously
* injected — so the rendered app reflects the current entry list immediately
* on every change, without a page reload.
*/
function applyCssEntries(entries: CssEntry[]) {
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
for (const entry of entries) {
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
if (!href) continue
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.setAttribute(INJECTED_CSS_ATTR, entry.id)
document.head.appendChild(link)
}
}
/**
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
* (ticket 011), and applies the colour scheme / imported CSS to the document
* as a side effect whenever that state changes — so toggling either is
* visible in the running app immediately, with no reload.
*/
export function useSettingsPanel() {
const [settings, setSettings] = useState<Settings | null>(null)
const [cssEntries, setCssEntries] = useState<CssEntry[]>([])
const [profiles, setProfiles] = useState<ProfileSummary[]>([])
const [error, setError] = useState<string | null>(null)
const refreshSettings = useCallback(async () => {
const next = await fetchSettings()
setSettings(next)
return next
}, [])
const refreshCss = useCallback(async () => {
const next = await fetchCssEntries()
setCssEntries(next)
return next
}, [])
const refreshProfiles = useCallback(async () => {
const next = await fetchProfiles()
setProfiles(next)
return next
}, [])
useEffect(() => {
refreshSettings().catch(() => {})
refreshCss().catch(() => {})
refreshProfiles().catch(() => {})
}, [refreshSettings, refreshCss, refreshProfiles])
useEffect(() => {
if (settings) applyColorScheme(settings.color_scheme)
}, [settings])
useEffect(() => {
applyCssEntries(cssEntries)
}, [cssEntries])
const guarded = useCallback((action: () => Promise<void>) => {
return async () => {
try {
setError(null)
await action()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
}
}
}, [])
const setColorScheme = useCallback(
(colorScheme: ColorScheme) =>
guarded(async () => {
const next = await updateSettings({ colorScheme })
setSettings(next)
})(),
[guarded],
)
const importCssEntry = useCallback(
(filename: string, content: string) =>
guarded(async () => {
await importCss(filename, content)
await refreshCss()
})(),
[guarded, refreshCss],
)
const removeCssEntry = useCallback(
(id: string) =>
guarded(async () => {
await deleteCssEntry(id)
await refreshCss()
})(),
[guarded, refreshCss],
)
const addProfile = useCallback(
(name: string) =>
guarded(async () => {
await createProfile(name)
await refreshProfiles()
})(),
[guarded, refreshProfiles],
)
const removeProfile = useCallback(
(name: string) =>
guarded(async () => {
await deleteProfile(name)
await Promise.all([refreshProfiles(), refreshSettings()])
})(),
[guarded, refreshProfiles, refreshSettings],
)
const switchProfile = useCallback(
(name: string) =>
guarded(async () => {
const next = await activateProfile(name)
setSettings(next)
})(),
[guarded],
)
return {
settings,
cssEntries,
profiles,
error,
setColorScheme,
importCssEntry,
removeCssEntry,
addProfile,
removeProfile,
switchProfile,
}
}