Files
ottyl/webapp/src/components/NavMenu.tsx
T

347 lines
14 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 { 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<Exclude<PanelName, null>, 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<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 flex items-center gap-2 text-lg font-semibold text-text">
<img src={ottylIcon} alt="" className="h-5 w-5" />
ottyl
</div>
<PresetsDropdown panel={panel} />
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
<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>{stats.approx_bytes_total.toLocaleString()} bytes</div>
<div title="Highest ingestion-to-engine-processing lag seen in the last 5s">
lag {formatIngestLag(stats.ingest_lag_high_watermark_ms)}
</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" />}
</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 === 'docs' && <DocsPanel target={docsPanelTarget} />}
</div>
)}
</div>
)
}
/// 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<typeof useSettingsPanel> }) {
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 (
<div className="flex items-center gap-1">
<select
value={activeProfile ?? ''}
onChange={(e) => panel.switchProfile(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
>
{activeProfile === null && <option value="">No active profile</option>}
{panel.profiles.map((profile) => (
<option key={profile.name} value={profile.name}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={handleCreate}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2"
aria-label="New profile"
title="New profile (copies the current one)"
>
+
</button>
<button
type="button"
onClick={handleDelete}
disabled={!activeProfile || activeProfile === DEFAULT_PROFILE_NAME}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Delete current profile"
title={activeProfile === DEFAULT_PROFILE_NAME ? "The Default profile can't be deleted" : 'Delete current profile'}
>
×
</button>
<HelpIcon sectionId="presets" label="Help: Presets" />
</div>
)
}
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [maxLogsInput, setMaxLogsInput] = 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))
}
return (
<div className="flex flex-col gap-5 text-sm text-text-secondary">
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Theme</h3>
<span className="text-[11px] text-text-faint">
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.
</span>
</div>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => panel.setTheme('system')}
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.theme === 'system'
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
System
</button>
{panel.themes.map((theme) => (
<button
key={theme}
type="button"
onClick={() => panel.setTheme(theme)}
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.theme === theme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{themeLabel(theme)}
</button>
))}
</div>
</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">
<div className="flex items-center gap-1.5">
<input
type="text"
placeholder="https://ntfy.sh"
value={ntfyServerInput}
onChange={(e) => 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"
/>
<span className="shrink-0 text-xs text-text-faint" aria-hidden="true">/</span>
<input
type="text"
placeholder="topic"
value={ntfyTopicInput}
onChange={(e) => 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"
/>
</div>
{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>
</div>
</section>
</div>
)
}