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
600 lines
23 KiB
TypeScript
600 lines
23 KiB
TypeScript
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>
|
||
)
|
||
}
|