Files
ottyl/webapp/src/components/NavMenu.tsx
T
Hugh KaznowskiandClaude Sonnet 5 18c2d72d22 Move the ntfy alert sink to Settings, shared by all alert rules
Alert rules no longer carry their own ntfy topic/server; Settings now
holds one process-wide ntfy sink (with its own "send test notification"),
and the Alerts panel is left to just define rule conditions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 18:13:51 +01:00

600 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api'
import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon'
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
type PanelName = 'settings' | 'presets' | 'docs' | null
const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
settings: 'Settings',
presets: 'Presets',
docs: 'Docs',
}
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
const DEFAULT_PROFILE_NAME = 'Default'
const COLOR_SCHEMES: ColorScheme[] = [
'system',
'light',
'dark',
'grey',
'red',
'green',
'blue',
'yellow',
'pink',
'orange',
]
export function NavMenu({ stats }: { stats: Stats | null }) {
const [openPanel, setOpenPanel] = useState<PanelName>(null)
const panel = useSettingsPanel()
const docsNav = useDocsNav()
const [docsPanelTarget, setDocsPanelTarget] = useState<DocsTarget | null>(null)
function togglePanel(name: PanelName) {
setOpenPanel((current) => (current === name ? null : name))
}
// A "?" icon always opens Docs to its section and flashes it, even if Docs is already open elsewhere — hence forcing `openPanel` here rather than toggling.
useEffect(() => {
if (!docsNav.target) return
setOpenPanel('docs')
setDocsPanelTarget(docsNav.target)
}, [docsNav.target])
// Opening Docs via this button (not a "?" icon) has no forced section or flash, so clear any pending help-triggered target.
function handleDocsButtonClick() {
togglePanel('docs')
setDocsPanelTarget(null)
}
return (
<div className="flex h-full">
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
<div>
<div className="mb-4 text-lg font-semibold text-text">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-text-muted">
<span className="rounded bg-surface-2 px-2 py-1 text-text">Dashboard</span>
<button
type="button"
onClick={() => togglePanel('presets')}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'presets' ? 'bg-surface-2 text-text' : ''
}`}
>
Presets
</button>
<button
type="button"
onClick={() => togglePanel('settings')}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'settings' ? 'bg-surface-2 text-text' : ''
}`}
>
Settings
</button>
<button
type="button"
onClick={handleDocsButtonClick}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'docs' ? 'bg-surface-2 text-text' : ''
}`}
>
Docs
</button>
</nav>
</div>
<div className="text-xs text-text-subtle">
{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 flex-col overflow-y-auto border-r border-border bg-surface-1 p-4 shadow-xl ${
openPanel === 'docs' ? 'w-[32rem]' : 'w-80'
}`}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
{openPanel === 'settings' && <HelpIcon sectionId="settings" label="Help: Settings" />}
{openPanel === 'presets' && <HelpIcon sectionId="presets" label="Help: Presets" />}
</div>
<button
type="button"
onClick={() => setOpenPanel(null)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label="Close panel"
>
✕
</button>
</div>
{panel.error && openPanel !== 'docs' && (
<div className="mb-3 rounded border border-danger-border bg-danger-bg/50 px-2 py-1 text-xs text-danger-text-strong">
{panel.error}
</div>
)}
{openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'presets' && <PresetsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel target={docsPanelTarget} />}
</div>
)}
</div>
)
}
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [filename, setFilename] = useState('')
const [content, setContent] = useState('')
const [maxLogsInput, setMaxLogsInput] = useState('')
const [themeUploadError, setThemeUploadError] = useState<string | null>(null)
const [themeFilename, setThemeFilename] = useState('')
const [themeContent, setThemeContent] = useState('')
const [themeLinkUrl, setThemeLinkUrl] = useState('')
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
const [ntfyTestSuccess, setNtfyTestSuccess] = useState(false)
const [ntfyTesting, setNtfyTesting] = useState(false)
useEffect(() => {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
}, [panel.settings?.max_logs])
useEffect(() => {
setNtfyTopicInput(panel.settings?.ntfy_topic ?? '')
setNtfyServerInput(panel.settings?.ntfy_server ?? '')
}, [panel.settings?.ntfy_topic, panel.settings?.ntfy_server])
function handleNtfyTopicBlur() {
panel.setNtfySink(ntfyTopicInput.trim() || null, panel.settings?.ntfy_server ?? null)
}
function handleNtfyServerBlur() {
panel.setNtfySink(panel.settings?.ntfy_topic ?? null, ntfyServerInput.trim() || null)
}
async function handleNtfyTest() {
setNtfyTestError(null)
setNtfyTestSuccess(false)
if (!ntfyTopicInput.trim()) {
setNtfyTestError('Enter a topic to send a test notification.')
return
}
setNtfyTesting(true)
try {
await sendTestAlertNotification(ntfyTopicInput.trim(), ntfyServerInput.trim() || null)
setNtfyTestSuccess(true)
} catch (err) {
setNtfyTestError(err instanceof Error ? err.message : 'Failed to send test notification')
} finally {
setNtfyTesting(false)
}
}
function handleMaxLogsBlur() {
const trimmed = maxLogsInput.trim()
if (trimmed === '') {
panel.setMaxLogs(null)
return
}
const parsed = Number(trimmed)
if (!Number.isFinite(parsed) || parsed <= 0) {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
return
}
panel.setMaxLogs(Math.floor(parsed))
}
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('')
}
function downloadBuiltinTheme(name: string) {
const vars = BUILTIN_THEMES[name]
if (!vars) return
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
}
async function downloadThemeEntry(entry: CssEntry) {
if (entry.kind === 'imported') {
const res = await fetch(cssContentUrl(entry.id))
const text = await res.text()
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
} else if (entry.path_or_url) {
window.open(entry.path_or_url, '_blank', 'noopener')
}
}
async function handleThemeFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setThemeFilename(file.name)
setThemeContent(await file.text())
setThemeUploadError(null)
}
async function handleThemeUpload(e: React.FormEvent) {
e.preventDefault()
setThemeUploadError(null)
if (!themeFilename.trim() || !themeContent.trim()) return
const { missing } = parseThemeCss(themeContent)
if (missing.length > 0) {
setThemeUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
return
}
await panel.importThemeEntry(themeFilename.trim(), themeContent)
setThemeFilename('')
setThemeContent('')
}
async function handleThemeLink(e: React.FormEvent) {
e.preventDefault()
if (!themeLinkUrl.trim()) return
await panel.linkThemeEntry(themeLinkUrl.trim())
setThemeLinkUrl('')
}
const themeEntries = panel.cssEntries.filter((entry) => entry.is_theme)
const arbitraryCssEntries = panel.cssEntries.filter((entry) => !entry.is_theme)
return (
<div className="flex flex-col gap-5 text-sm text-text-secondary">
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
<div className="flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => (
<div key={scheme} className="flex items-center">
<button
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`rounded-l border px-2 py-1 text-xs capitalize ${
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{scheme}
</button>
{scheme !== 'system' && (
<button
type="button"
onClick={() => downloadBuiltinTheme(scheme)}
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
aria-label={`Download ${scheme} theme`}
title="Download as .css"
>
↓
</button>
)}
</div>
))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Custom themes</h3>
{themeEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No custom themes yet.</div>
) : (
<ul className="mb-2 flex flex-col gap-1">
{themeEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<button
type="button"
onClick={() => panel.setCustomTheme(entry.id)}
className={`min-w-0 flex-1 truncate text-left capitalize ${
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
}`}
>
{entry.filename ?? entry.path_or_url}
</button>
<div className="ml-2 flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => downloadThemeEntry(entry)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
title="Download as .css"
>
↓
</button>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
—
</span>
)}
</div>
</li>
))}
</ul>
)}
<form onSubmit={handleThemeUpload} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleThemeFileChange}
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
/>
<input
type="text"
placeholder="theme name.css"
value={themeFilename}
onChange={(e) => setThemeFilename(e.target.value)}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
{themeUploadError && <div className="text-xs text-danger-text-strong">{themeUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Upload theme
</button>
</form>
<form onSubmit={handleThemeLink} className="mt-2 flex gap-2">
<input
type="text"
placeholder="https://example.com/theme.css"
value={themeLinkUrl}
onChange={(e) => setThemeLinkUrl(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link theme
</button>
</form>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
placeholder="Unlimited"
value={maxLogsInput}
onChange={(e) => setMaxLogsInput(e.target.value)}
onBlur={handleMaxLogsBlur}
className="w-28 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<span className="text-xs text-text-faint">max logs kept in memory (blank = unlimited)</span>
</div>
</section>
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Ntfy sink</h3>
<HelpIcon sectionId="alerts" label="Help: Ntfy sink" />
</div>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="topic"
value={ntfyTopicInput}
onChange={(e) => setNtfyTopicInput(e.target.value)}
onBlur={handleNtfyTopicBlur}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<input
type="text"
placeholder="https://ntfy.sh"
value={ntfyServerInput}
onChange={(e) => setNtfyServerInput(e.target.value)}
onBlur={handleNtfyServerBlur}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
{ntfyTestError && <div className="text-xs text-danger-text-strong">{ntfyTestError}</div>}
{ntfyTestSuccess && <div className="text-xs text-text-secondary">Test notification sent.</div>}
<button
type="button"
onClick={handleNtfyTest}
disabled={ntfyTesting}
className="self-start rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2 disabled:opacity-50"
>
{ntfyTesting ? 'Sending…' : 'Send test notification'}
</button>
<p className="text-[11px] text-text-faint">Every alert rule delivers here — see the Alerts panel to define rules.</p>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">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-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
/>
<input
type="text"
placeholder="filename.css"
value={filename}
onChange={(e) => setFilename(e.target.value)}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong 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-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
</form>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
{arbitraryCssEntries.length === 0 ? (
<div className="text-xs text-text-faint">No CSS configured.</div>
) : (
<ul className="flex flex-col gap-1">
{arbitraryCssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0">
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-text-faint">{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-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="ml-2 shrink-0 text-text-faintest" 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-text-secondary">
<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-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Create
</button>
</form>
{panel.profiles.length === 0 ? (
<div className="text-xs text-text-faint">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-border-strong bg-surface-2' : 'border-border'
}`}
>
<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-text' : 'text-text hover:underline'
}`}
title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
>
{profile.name}
{isActive && <span className="ml-1 text-[10px] uppercase text-text-muted">active</span>}
</button>
{profile.name === DEFAULT_PROFILE_NAME ? (
<span className="ml-2 shrink-0 text-text-faintest" title="The Default profile can't be deleted">
—
</span>
) : (
<button
type="button"
onClick={() => panel.removeProfile(profile.name)}
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${profile.name}`}
>
delete
</button>
)}
</div>
<div className="mt-1 text-[10px] text-text-subtle">
{profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
</div>
</li>
)
})}
</ul>
)}
<p className="text-[11px] text-text-faint">
A new profile starts as a copy of the currently active one (or empty, if none is active).
</p>
<p className="text-[11px] text-text-faint">
Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
</p>
</div>
)
}