Merge branch 'worktree-agent-a6820222665a87090'

# Conflicts:
#	webapp/src/lib/api.ts
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:23:08 +01:00
6 changed files with 772 additions and 26 deletions
+83
View File
@@ -75,6 +75,29 @@ export class ApiError extends Error {
}
}
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) {
@@ -118,6 +141,26 @@ async function postJson<T>(path: string, body: unknown): 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
@@ -182,3 +225,43 @@ export function fetchMetricBucketValues(
if (params.limit !== undefined) search.set('limit', String(params.limit))
return getJson(`/api/metrics/${encodeURIComponent(name)}?${search.toString()}`)
}
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,
}
}