import { useEffect, useState } from 'react' import type { Stats } from '../lib/api' import { sendTestAlertNotification } from '../lib/api' import { useSettingsPanel } from '../lib/useSettingsPanel' import { useDocsNav, type DocsTarget } from '../lib/docsNav' import { DocsPanel } from './Docs' import { HelpIcon } from './HelpIcon' import ottylIcon from '../assets/ottyl-icon.svg' type PanelName = 'settings' | 'docs' | null const PANEL_TITLES: Record, string> = { settings: 'Settings', docs: 'Docs', } /** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */ const DEFAULT_PROFILE_NAME = 'Default' /** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */ function themeLabel(theme: string): string { return theme.replace(/_/g, ' ') } /** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */ function formatIngestLag(ms: number): string { if (ms < 1000) return `${ms}ms` const totalSeconds = ms / 1000 if (totalSeconds < 60) return `${totalSeconds % 1 === 0 ? totalSeconds : totalSeconds.toFixed(1)}s` const minutes = Math.floor(totalSeconds / 60) const seconds = Math.round(totalSeconds % 60) return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s` } 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}
{stats.approx_bytes_total.toLocaleString()} bytes
lag {formatIngestLag(stats.ingest_lag_high_watermark_ms)}
) : (
connecting…
)}
{openPanel && (

{PANEL_TITLES[openPanel]}

{openPanel === 'settings' && }
{panel.error && openPanel !== 'docs' && (
{panel.error}
)} {openPanel === 'settings' && } {openPanel === 'docs' && }
)}
) } /// Replaces the old Presets side-panel tab: a compact dropdown of profiles plus "+" (create a copy of /// the active profile, prompting for its name) and "×" (delete the active profile — disabled for /// `Default`, which can never be deleted). function PresetsDropdown({ panel }: { panel: ReturnType }) { const activeProfile = panel.settings?.active_profile ?? null async function handleCreate() { const name = window.prompt('New profile name (copies the current profile):')?.trim() if (!name) return await panel.addProfile(name) await panel.switchProfile(name) } async function handleDelete() { if (!activeProfile || activeProfile === DEFAULT_PROFILE_NAME) return await panel.removeProfile(activeProfile) } return (
) } function SettingsPanel({ panel }: { panel: ReturnType }) { const [maxLogsInput, setMaxLogsInput] = 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)) } return (

Theme

each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time. To add your own, drop a `.css` file in `themes/` and rebuild.
{panel.themes.map((theme) => ( ))}

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

setNtfyServerInput(e.target.value)} onBlur={handleNtfyServerBlur} className="min-w-0 flex-[2] 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" /> setNtfyTopicInput(e.target.value)} onBlur={handleNtfyTopicBlur} 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" />
{ntfyTestError &&
{ntfyTestError}
} {ntfyTestSuccess &&
Test notification sent.
}
) }