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, 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(null) const panel = useSettingsPanel() const docsNav = useDocsNav() const [docsPanelTarget, setDocsPanelTarget] = useState(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 (
ottyl
{stats ? ( <>
{stats.log_count} logs
seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}
) : (
connecting…
)}
{openPanel && (

{PANEL_TITLES[openPanel]}

{openPanel === 'settings' && } {openPanel === 'presets' && }
{panel.error && openPanel !== 'docs' && (
{panel.error}
)} {openPanel === 'settings' && } {openPanel === 'presets' && } {openPanel === 'docs' && }
)}
) } function SettingsPanel({ panel }: { panel: ReturnType }) { const [filename, setFilename] = useState('') const [content, setContent] = useState('') const [maxLogsInput, setMaxLogsInput] = useState('') const [themeUploadError, setThemeUploadError] = useState(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(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) { 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) { 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 (

Colour scheme

{COLOR_SCHEMES.map((scheme) => (
{scheme !== 'system' && ( )}
))}

Custom themes

{themeEntries.length === 0 ? (
No custom themes yet.
) : (
    {themeEntries.map((entry) => (
  • {entry.kind === 'imported' ? ( ) : ( — )}
  • ))}
)}
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 &&
{themeUploadError}
}
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" />

Log retention

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" /> max logs kept in memory (blank = unlimited)

Ntfy sink

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" /> 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 &&
{ntfyTestError}
} {ntfyTestSuccess &&
Test notification sent.
}

Every alert rule delivers here — see the Alerts panel to define rules.

Import CSS

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" />