Merge branch 'feature/mesh-intelligence'

This commit is contained in:
Matt Johnson (via Claude) 2026-06-10 15:37:33 +00:00
commit 7de460804f
19 changed files with 1472 additions and 1472 deletions

View file

@ -131,7 +131,7 @@ export default function ChannelPicker(props: ChannelPickerProps) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label> <label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1"> <div className="border border-[#1e2a3a] p-2 space-y-1">
{enabledChannels.map((ch) => ( {enabledChannels.map((ch) => (
<label <label
key={ch.index} key={ch.index}

View file

@ -181,7 +181,7 @@ export default function GeoMap({
}, [selectedNodeId, edges]) }, [selectedNodeId, edges])
return ( return (
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden"> <div className="relative bg-bg-card border border-border overflow-hidden">
<MapContainer <MapContainer
center={defaultCenter} center={defaultCenter}
zoom={7} zoom={7}

View file

@ -97,7 +97,7 @@ export default function Layout({ children }: LayoutProps) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-[3px] h-8 bg-accent flex-shrink-0" /> <div className="w-[3px] h-8 bg-accent flex-shrink-0" />
<div> <div>
<div className="font-sans font-bold text-white text-lg">MeshAI</div> <div className="font-sans font-bold text-white text-[15px] leading-tight tracking-tight">MeshAI</div>
<div className="text-xs font-mono text-[#333]"> <div className="text-xs font-mono text-[#333]">
v{status?.version || '...'} v{status?.version || '...'}
</div> </div>

View file

@ -150,7 +150,7 @@ export default function NodeDetail({
<div className="p-4 border-b border-border grid grid-cols-2 gap-3"> <div className="p-4 border-b border-border grid grid-cols-2 gap-3">
<div> <div>
<div className="text-xs text-slate-500 mb-0.5">Role</div> <div className="text-xs text-slate-500 mb-0.5">Role</div>
<div className={`text-sm font-medium ${isInfra ? 'text-cyan-400' : 'text-slate-300'}`}> <div className={`text-sm font-medium ${isInfra ? 'text-accent' : 'text-slate-300'}`}>
{node.role} {node.role}
</div> </div>
</div> </div>
@ -187,7 +187,7 @@ export default function NodeDetail({
href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`} href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300" className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300"
> >
<ExternalLink size={10} /> <ExternalLink size={10} />
Google Maps Google Maps
@ -196,7 +196,7 @@ export default function NodeDetail({
href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`} href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300" className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300"
> >
<ExternalLink size={10} /> <ExternalLink size={10} />
OSM OSM

View file

@ -175,7 +175,7 @@ export default function NodePicker({
{isOpen && !loading && ( {isOpen && !loading && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl"> <div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] shadow-xl">
{filteredNodes.length === 0 ? ( {filteredNodes.length === 0 ? (
<div className="p-3 text-sm text-slate-500 text-center"> <div className="p-3 text-sm text-slate-500 text-center">
No nodes found No nodes found

View file

@ -148,7 +148,7 @@ export default function NodeTable({
} }
return ( return (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden"> <div className="bg-bg-card border border-border overflow-hidden">
{/* Filter bar */} {/* Filter bar */}
<div className="p-3 border-b border-border flex items-center gap-3"> <div className="p-3 border-b border-border flex items-center gap-3">
{/* Search */} {/* Search */}
@ -254,7 +254,7 @@ export default function NodeTable({
<span <span
className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${ className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
isInfra isInfra
? 'bg-cyan-500/20 text-cyan-400' ? 'bg-cyan-500/20 text-accent'
: 'bg-slate-500/20 text-slate-400' : 'bg-slate-500/20 text-slate-400'
}`} }`}
> >

View file

@ -42,10 +42,10 @@ function getSeverityStyles(severity: string) {
} }
default: default:
return { return {
bg: 'bg-blue-500/10', bg: 'bg-sky-400/10',
border: 'border-blue-500', border: 'border-sky-400',
icon: Info, icon: Info,
iconColor: 'text-blue-500', iconColor: 'text-sky-400',
} }
} }
} }
@ -70,7 +70,7 @@ function ToastItem({
return ( return (
<div <div
className={`${styles.bg} border ${styles.border} rounded-lg shadow-lg overflow-hidden animate-slide-in cursor-pointer`} className={`${styles.bg} border ${styles.border} shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
onClick={onNavigate} onClick={onNavigate}
role="alert" role="alert"
> >

View file

@ -242,7 +242,7 @@ export default function TopologyGraph({
}, [option]) }, [option])
return ( return (
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden"> <div className="relative bg-bg-card border border-border overflow-hidden">
<ReactECharts <ReactECharts
ref={chartRef} ref={chartRef}
option={option} option={option}
@ -302,11 +302,11 @@ export default function TopologyGraph({
<div className="text-xs text-slate-400 font-medium mb-2">Node Type</div> <div className="text-xs text-slate-400 font-medium mb-2">Node Type</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" /> <div className="w-3 h-3 rounded-full bg-sky-400" />
<span className="text-xs text-slate-500">Infrastructure</span> <span className="text-xs text-slate-500">Infrastructure</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-900 border-2 border-blue-500" /> <div className="w-3 h-3 rounded-full bg-gray-900 border-2 border-sky-400" />
<span className="text-xs text-slate-500">Client</span> <span className="text-xs text-slate-500">Client</span>
</div> </div>
</div> </div>

View file

@ -1,8 +1,8 @@
// v0.6-3c Adapter Config editor. // v0.6-3c Adapter Config editor.
// //
// Renders one card per adapter. Each card shows: // Renders one card per adapter. Each card shows:
// - display_name + include_in_llm_context toggle at the top // - display_name + include_in_llm_context toggle at the top
// - expandable list of (config key, type-aware widget, reset button) // - expandable list of (config key, type-aware widget, reset button)
// //
// Auto-saves on blur (text/number inputs) or change (bool toggle + select). // Auto-saves on blur (text/number inputs) or change (bool toggle + select).
// Cache invalidation is server-side -- every PUT triggers it. The handler // Cache invalidation is server-side -- every PUT triggers it. The handler
@ -10,24 +10,24 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { import {
ChevronDown, ChevronRight, RotateCcw, Loader2, Check, AlertCircle, ChevronDown, ChevronRight, RotateCcw, Loader2, Check, AlertCircle,
Sliders, Sliders,
} from 'lucide-react' } from 'lucide-react'
interface ConfigRow { interface ConfigRow {
adapter: string adapter: string
key: string key: string
value: unknown value: unknown
default: unknown default: unknown
type: 'int' | 'float' | 'str' | 'bool' | 'json' type: 'int' | 'float' | 'str' | 'bool' | 'json'
description: string description: string
updated_at: number updated_at: number
} }
interface MetaRow { interface MetaRow {
display_name: string display_name: string
include_in_llm_context: boolean include_in_llm_context: boolean
description: string description: string
} }
type GroupedConfig = Record<string, ConfigRow[]> type GroupedConfig = Record<string, ConfigRow[]>
@ -39,231 +39,231 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
const SAVED_BADGE_MS = 1500 const SAVED_BADGE_MS = 1500
export default function AdapterConfig() { export default function AdapterConfig() {
const [config, setConfig] = useState<GroupedConfig>({}) const [config, setConfig] = useState<GroupedConfig>({})
const [meta, setMeta] = useState<MetaMap>({}) const [meta, setMeta] = useState<MetaMap>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<string, boolean>>({}) const [expanded, setExpanded] = useState<Record<string, boolean>>({})
// Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`. // Per-key save status: keyed on `${adapter}.${key}` or `meta:${adapter}`.
const [saveStatus, setSaveStatus] = useState<Record<string, SaveStatus>>({}) const [saveStatus, setSaveStatus] = useState<Record<string, SaveStatus>>({})
const [saveError, setSaveError] = useState<Record<string, string>>({}) const [saveError, setSaveError] = useState<Record<string, string>>({})
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [cfgRes, metaRes] = await Promise.all([ const [cfgRes, metaRes] = await Promise.all([
fetch('/api/adapter-config'), fetch('/api/adapter-config'),
fetch('/api/adapter-meta'), fetch('/api/adapter-meta'),
]) ])
if (!cfgRes.ok) throw new Error(`GET /adapter-config: ${cfgRes.status}`) if (!cfgRes.ok) throw new Error(`GET /adapter-config: ${cfgRes.status}`)
if (!metaRes.ok) throw new Error(`GET /adapter-meta: ${metaRes.status}`) if (!metaRes.ok) throw new Error(`GET /adapter-meta: ${metaRes.status}`)
setConfig(await cfgRes.json()) setConfig(await cfgRes.json())
setMeta(await metaRes.json()) setMeta(await metaRes.json())
} catch (e) { } catch (e) {
setError(String(e)) setError(String(e))
} finally { } finally {
setLoading(false) setLoading(false)
}
}, [])
useEffect(() => { refresh() }, [refresh])
const markStatus = useCallback((id: string, status: SaveStatus, errMsg?: string) => {
setSaveStatus((s) => ({ ...s, [id]: status }))
if (errMsg) setSaveError((s) => ({ ...s, [id]: errMsg }))
if (status === 'saved') {
setTimeout(() => {
setSaveStatus((s) => (s[id] === 'saved' ? { ...s, [id]: 'idle' } : s))
}, SAVED_BADGE_MS)
}
}, [])
// ---------- key-level mutations ----------------------------------------
const putValue = useCallback(async (adapter: string, key: string, value: unknown) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const detail = body.detail || res.statusText
markStatus(id, 'error', String(detail))
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const resetValue = useCallback(async (adapter: string, key: string) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}/reset`, {
method: 'POST',
})
if (!res.ok) {
markStatus(id, 'error', `reset failed (${res.status})`)
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const putMeta = useCallback(async (adapter: string, body: Partial<MetaRow>) => {
const id = `meta:${adapter}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-meta/${adapter}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
markStatus(id, 'error', String(b.detail || res.statusText))
return
}
const updated: MetaRow = await res.json()
setMeta((m) => ({ ...m, [adapter]: updated }))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
// ---------- render ------------------------------------------------------
if (loading) {
return (
<div className="p-6 flex items-center gap-2 text-slate-400">
<Loader2 className="w-5 h-5 animate-spin" /> Loading adapter config
</div>
)
} }
}, [])
if (error) { useEffect(() => { refresh() }, [refresh])
return (
<div className="p-6 text-red-400"> const markStatus = useCallback((id: string, status: SaveStatus, errMsg?: string) => {
<AlertCircle className="w-5 h-5 inline mr-2" /> setSaveStatus((s) => ({ ...s, [id]: status }))
Failed to load: {error} if (errMsg) setSaveError((s) => ({ ...s, [id]: errMsg }))
</div> if (status === 'saved') {
) setTimeout(() => {
setSaveStatus((s) => (s[id] === 'saved' ? { ...s, [id]: 'idle' } : s))
}, SAVED_BADGE_MS)
} }
}, [])
// Union of all adapters: any meta row + any config-having adapter. // ---------- key-level mutations ----------------------------------------
const allAdapters = Array.from(new Set([
...Object.keys(meta),
...Object.keys(config),
])).sort()
const putValue = useCallback(async (adapter: string, key: string, value: unknown) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
const detail = body.detail || res.statusText
markStatus(id, 'error', String(detail))
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const resetValue = useCallback(async (adapter: string, key: string) => {
const id = `${adapter}.${key}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-config/${adapter}/${key}/reset`, {
method: 'POST',
})
if (!res.ok) {
markStatus(id, 'error', `reset failed (${res.status})`)
return
}
const updated: ConfigRow = await res.json()
setConfig((c) => ({
...c,
[adapter]: (c[adapter] || []).map((row) => row.key === key ? updated : row),
}))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
const putMeta = useCallback(async (adapter: string, body: Partial<MetaRow>) => {
const id = `meta:${adapter}`
markStatus(id, 'saving')
try {
const res = await fetch(`/api/adapter-meta/${adapter}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const b = await res.json().catch(() => ({}))
markStatus(id, 'error', String(b.detail || res.statusText))
return
}
const updated: MetaRow = await res.json()
setMeta((m) => ({ ...m, [adapter]: updated }))
markStatus(id, 'saved')
} catch (e) {
markStatus(id, 'error', String(e))
}
}, [markStatus])
// ---------- render ------------------------------------------------------
if (loading) {
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 flex items-center gap-2 text-[#444]">
<div className="flex items-center gap-2 text-slate-200"> <Loader2 className="w-5 h-5 animate-spin" /> Loading adapter config
<Sliders className="w-5 h-5" /> </div>
<h1 className="text-xl font-semibold">Adapter Config</h1>
<span className="text-xs text-slate-500 ml-2">
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
</span>
</div>
<p className="text-xs text-slate-400 max-w-3xl">
Per-adapter tunables (thresholds, freshness windows, toggles, curation lists).
Changes take effect on the next handler call -- no container restart needed.
Sentence templates, emoji, and translation maps live in code by design see the CODE rule under <a href="/reference#adapter-config" className="text-accent hover:underline">Adapter Config &amp; the CODE Rule</a> in Reference. The <strong>LLM context</strong> toggle on each card gates whether that adapter's data lands in the system prompt when you DM the bot; broadcasts are unaffected.
</p>
{allAdapters.map((adapter) => {
const m = meta[adapter] || {
display_name: adapter,
include_in_llm_context: true,
description: '',
}
const rows = config[adapter] || []
const isExpanded = expanded[adapter] ?? false
const metaId = `meta:${adapter}`
const metaStatus = saveStatus[metaId] || 'idle'
return (
<div key={adapter} className="bg-slate-800/60 border border-slate-700 rounded-lg">
{/* Card header */}
<div className="p-4 flex items-start gap-4">
<button
onClick={() => setExpanded((e) => ({ ...e, [adapter]: !e[adapter] }))}
className="text-slate-400 hover:text-white"
aria-label="toggle expand"
>
{isExpanded
? <ChevronDown className="w-5 h-5" />
: <ChevronRight className="w-5 h-5" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-100">{m.display_name}</h2>
<code className="text-xs text-slate-500">{adapter}</code>
{rows.length > 0 && (
<span className="text-xs text-slate-400 ml-1">({rows.length} settings)</span>
)}
{rows.length === 0 && (
<span className="text-xs text-slate-500 ml-1 italic">(meta only)</span>
)}
</div>
{m.description && (
<p className="text-xs text-slate-400 mt-1">{m.description}</p>
)}
</div>
{/* include_in_llm_context toggle */}
<label className="flex items-center gap-2 text-xs text-slate-300 select-none">
<input
type="checkbox"
checked={m.include_in_llm_context}
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
className="w-4 h-4 accent-cyan-500"
/>
LLM context
<SaveBadge status={metaStatus} error={saveError[metaId]} />
</label>
</div>
{/* Expanded body */}
{isExpanded && rows.length > 0 && (
<div className="border-t border-slate-700 divide-y divide-slate-700/60">
{rows.map((row) => (
<KeyRow
key={row.key}
row={row}
status={saveStatus[`${adapter}.${row.key}`] || 'idle'}
error={saveError[`${adapter}.${row.key}`]}
onCommit={(v) => putValue(adapter, row.key, v)}
onReset={() => resetValue(adapter, row.key)}
/>
))}
</div>
)}
</div>
)
})}
</div>
) )
}
if (error) {
return (
<div className="p-6 text-red-400">
<AlertCircle className="w-5 h-5 inline mr-2" />
Failed to load: {error}
</div>
)
}
// Union of all adapters: any meta row + any config-having adapter.
const allAdapters = Array.from(new Set([
...Object.keys(meta),
...Object.keys(config),
])).sort()
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2 text-white">
<Sliders className="w-5 h-5" />
<h1 className="text-xl font-semibold">Adapter Config</h1>
<span className="text-xs text-[#333] ml-2">
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
</span>
</div>
<p className="text-xs text-[#444] max-w-3xl">
Per-adapter tunables (thresholds, freshness windows, toggles, curation lists).
Changes take effect on the next handler call -- no container restart needed.
Sentence templates, emoji, and translation maps live in code by design see the CODE rule under <a href="/reference#adapter-config" className="text-accent hover:underline">Adapter Config &amp; the CODE Rule</a> in Reference. The <strong>LLM context</strong> toggle on each card gates whether that adapter's data lands in the system prompt when you DM the bot; broadcasts are unaffected.
</p>
{allAdapters.map((adapter) => {
const m = meta[adapter] || {
display_name: adapter,
include_in_llm_context: true,
description: '',
}
const rows = config[adapter] || []
const isExpanded = expanded[adapter] ?? false
const metaId = `meta:${adapter}`
const metaStatus = saveStatus[metaId] || 'idle'
return (
<div key={adapter} className="bg-bg-card border border-border">
{/* Card header */}
<div className="p-4 flex items-start gap-4">
<button
onClick={() => setExpanded((e) => ({ ...e, [adapter]: !e[adapter] }))}
className="text-[#444] hover:text-white"
aria-label="toggle expand"
>
{isExpanded
? <ChevronDown className="w-5 h-5" />
: <ChevronRight className="w-5 h-5" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-white">{m.display_name}</h2>
<code className="text-xs text-[#333]">{adapter}</code>
{rows.length > 0 && (
<span className="text-xs text-[#444] ml-1">({rows.length} settings)</span>
)}
{rows.length === 0 && (
<span className="text-xs text-[#333] ml-1 italic">(meta only)</span>
)}
</div>
{m.description && (
<p className="text-xs text-[#444] mt-1">{m.description}</p>
)}
</div>
{/* include_in_llm_context toggle */}
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
<input
type="checkbox"
checked={m.include_in_llm_context}
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
className="w-4 h-4 accent-[#f59e0b]"
/>
LLM context
<SaveBadge status={metaStatus} error={saveError[metaId]} />
</label>
</div>
{/* Expanded body */}
{isExpanded && rows.length > 0 && (
<div className="border-t border-border divide-y divide-border">
{rows.map((row) => (
<KeyRow
key={row.key}
row={row}
status={saveStatus[`${adapter}.${row.key}`] || 'idle'}
error={saveError[`${adapter}.${row.key}`]}
onCommit={(v) => putValue(adapter, row.key, v)}
onReset={() => resetValue(adapter, row.key)}
/>
))}
</div>
)}
</div>
)
})}
</div>
)
} }
@ -271,88 +271,88 @@ export default function AdapterConfig() {
interface KeyRowProps { interface KeyRowProps {
row: ConfigRow row: ConfigRow
status: SaveStatus status: SaveStatus
error?: string error?: string
onCommit: (v: unknown) => void onCommit: (v: unknown) => void
onReset: () => void onReset: () => void
} }
function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) { function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
// Use a local draft so number/text inputs don't fight the parent state // Use a local draft so number/text inputs don't fight the parent state
// mid-edit. Commit on blur. // mid-edit. Commit on blur.
const [draft, setDraft] = useState<string>(stringifyForInput(row)) const [draft, setDraft] = useState<string>(stringifyForInput(row))
// If the parent value changes (e.g. via reset), refresh the local draft. // If the parent value changes (e.g. via reset), refresh the local draft.
useEffect(() => { useEffect(() => {
setDraft(stringifyForInput(row)) setDraft(stringifyForInput(row))
}, [row.value, row.type]) }, [row.value, row.type])
const isDirty = draft !== stringifyForInput(row) const isDirty = draft !== stringifyForInput(row)
const isDefault = JSON.stringify(row.value) === JSON.stringify(row.default) const isDefault = JSON.stringify(row.value) === JSON.stringify(row.default)
const commit = () => { const commit = () => {
const parsed = parseFromInput(draft, row.type) const parsed = parseFromInput(draft, row.type)
if (parsed.error) return // error shown inline; do not PUT if (parsed.error) return // error shown inline; do not PUT
if (!parsed.changed(row.value)) return if (!parsed.changed(row.value)) return
onCommit(parsed.value) onCommit(parsed.value)
} }
return ( return (
<div className="px-6 py-3 flex items-start gap-4"> <div className="px-6 py-3 flex items-start gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-sm font-mono text-cyan-300">{row.key}</code> <code className="text-sm font-mono text-accent">{row.key}</code>
<span className="text-xs text-slate-500">[{row.type}]</span> <span className="text-xs text-[#333]">[{row.type}]</span>
{!isDefault && ( {!isDefault && (
<span className="text-xs text-amber-400">edited</span> <span className="text-xs text-accent">edited</span>
)} )}
</div>
{row.description && (
<p className="text-xs text-slate-400 mt-1">{row.description}</p>
)}
</div>
<div className="flex items-center gap-2 min-w-[280px] justify-end">
{row.type === 'bool' ? (
<input
type="checkbox"
checked={row.value === true}
onChange={(e) => onCommit(e.target.checked)}
className="w-5 h-5 accent-cyan-500"
/>
) : row.type === 'json' ? (
<textarea
className="w-72 h-20 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs font-mono text-slate-100"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
) : (
<input
type={row.type === 'int' || row.type === 'float' ? 'number' : 'text'}
step={row.type === 'float' ? 'any' : '1'}
className="w-48 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
/>
)}
<SaveBadge status={status} error={error} dirty={isDirty} />
<button
onClick={onReset}
disabled={isDefault}
className="text-slate-400 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Reset to default"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div> </div>
) {row.description && (
<p className="text-xs text-[#444] mt-1">{row.description}</p>
)}
</div>
<div className="flex items-center gap-2 min-w-[280px] justify-end">
{row.type === 'bool' ? (
<input
type="checkbox"
checked={row.value === true}
onChange={(e) => onCommit(e.target.checked)}
className="w-5 h-5 accent-[#f59e0b]"
/>
) : row.type === 'json' ? (
<textarea
className="w-72 h-20 bg-[#0d0d0d] border border-border px-2 py-1 text-xs font-mono text-white"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
) : (
<input
type={row.type === 'int' || row.type === 'float' ? 'number' : 'text'}
step={row.type === 'float' ? 'any' : '1'}
className="w-48 bg-[#0d0d0d] border border-border px-2 py-1 text-sm text-white"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
/>
)}
<SaveBadge status={status} error={error} dirty={isDirty} />
<button
onClick={onReset}
disabled={isDefault}
className="text-[#444] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
title="Reset to default"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
</div>
)
} }
@ -360,15 +360,15 @@ function KeyRow({ row, status, error, onCommit, onReset }: KeyRowProps) {
function SaveBadge({ status, error, dirty }: { status: SaveStatus; error?: string; dirty?: boolean }) { function SaveBadge({ status, error, dirty }: { status: SaveStatus; error?: string; dirty?: boolean }) {
if (status === 'saving') return <Loader2 className="w-4 h-4 text-cyan-400 animate-spin" /> if (status === 'saving') return <Loader2 className="w-4 h-4 text-accent animate-spin" />
if (status === 'saved') return <Check className="w-4 h-4 text-emerald-400" /> if (status === 'saved') return <Check className="w-4 h-4 text-green-500" />
if (status === 'error') return ( if (status === 'error') return (
<span title={error} className="text-red-400 cursor-help"> <span title={error} className="text-red-400 cursor-help">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
</span> </span>
) )
if (dirty) return <span className="w-2 h-2 bg-amber-400 rounded-full" title="unsaved" /> if (dirty) return <span className="w-2 h-2 bg-accent rounded-full" title="unsaved" />
return <span className="w-4 h-4" /> return <span className="w-4 h-4" />
} }
@ -376,41 +376,41 @@ function SaveBadge({ status, error, dirty }: { status: SaveStatus; error?: strin
function stringifyForInput(row: ConfigRow): string { function stringifyForInput(row: ConfigRow): string {
if (row.type === 'bool') return String(row.value === true) if (row.type === 'bool') return String(row.value === true)
if (row.type === 'json') return JSON.stringify(row.value, null, 2) if (row.type === 'json') return JSON.stringify(row.value, null, 2)
if (row.value === null || row.value === undefined) return '' if (row.value === null || row.value === undefined) return ''
return String(row.value) return String(row.value)
} }
function parseFromInput(s: string, type: ConfigRow['type']): function parseFromInput(s: string, type: ConfigRow['type']):
| { error: string; value: null; changed: () => boolean } | { error: string; value: null; changed: () => boolean }
| { error: null; value: unknown; changed: (prev: unknown) => boolean } | { error: null; value: unknown; changed: (prev: unknown) => boolean }
{ {
if (type === 'int') { if (type === 'int') {
const n = Number(s) const n = Number(s)
if (!Number.isFinite(n) || !Number.isInteger(n)) { if (!Number.isFinite(n) || !Number.isInteger(n)) {
return { error: 'expected integer', value: null, changed: () => false } return { error: 'expected integer', value: null, changed: () => false }
}
return { error: null, value: n, changed: (prev) => prev !== n }
} }
if (type === 'float') { return { error: null, value: n, changed: (prev) => prev !== n }
const n = Number(s) }
if (!Number.isFinite(n)) { if (type === 'float') {
return { error: 'expected number', value: null, changed: () => false } const n = Number(s)
} if (!Number.isFinite(n)) {
return { error: null, value: n, changed: (prev) => prev !== n } return { error: 'expected number', value: null, changed: () => false }
} }
if (type === 'str') { return { error: null, value: n, changed: (prev) => prev !== n }
return { error: null, value: s, changed: (prev) => prev !== s } }
if (type === 'str') {
return { error: null, value: s, changed: (prev) => prev !== s }
}
if (type === 'json') {
try {
const v = JSON.parse(s)
return { error: null, value: v, changed: (prev) => JSON.stringify(prev) !== JSON.stringify(v) }
} catch {
return { error: 'invalid JSON', value: null, changed: () => false }
} }
if (type === 'json') { }
try { // bool branch handled inline -- never reaches parseFromInput.
const v = JSON.parse(s) return { error: null, value: s, changed: () => true }
return { error: null, value: v, changed: (prev) => JSON.stringify(prev) !== JSON.stringify(v) }
} catch {
return { error: 'invalid JSON', value: null, changed: () => false }
}
}
// bool branch handled inline -- never reaches parseFromInput.
return { error: null, value: s, changed: () => true }
} }

View file

@ -76,10 +76,10 @@ function getSeverityStyles(severity: string) {
case 'routine': case 'routine':
default: default:
return { return {
bg: 'bg-blue-500/10', bg: 'bg-sky-400/10',
border: 'border-blue-500', border: 'border-sky-400',
badge: 'bg-blue-500/20 text-blue-400', badge: 'bg-sky-400/20 text-sky-400',
iconColor: 'text-blue-500', iconColor: 'text-sky-400',
} }
} }
} }
@ -129,7 +129,7 @@ function ActiveAlertCard({
const Icon = getAlertIcon(alert.type) const Icon = getAlertIcon(alert.type)
return ( return (
<div className={`p-4 rounded-lg ${styles.bg} border-l-4 ${styles.border}`}> <div className={`p-4 ${styles.bg} border-l-4 ${styles.border}`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Icon size={20} className={styles.iconColor} /> <Icon size={20} className={styles.iconColor} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@ -197,7 +197,7 @@ function AlertHistoryTable({
const severities = ["all", "immediate", "priority", "routine"] const severities = ["all", "immediate", "priority", "routine"]
return ( return (
<div className="bg-bg-card border border-border rounded-lg"> <div className="bg-bg-card border border-border">
{/* Filters */} {/* Filters */}
<div className="p-4 border-b border-border flex items-center gap-4"> <div className="p-4 border-b border-border flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -207,7 +207,7 @@ function AlertHistoryTable({
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)} onChange={(e) => onTypeFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500" className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-sky-400"
> >
{alertTypes.map((t) => ( {alertTypes.map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
@ -218,7 +218,7 @@ function AlertHistoryTable({
<select <select
value={severityFilter} value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)} onChange={(e) => onSeverityFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500" className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-sky-400"
> >
{severities.map((s) => ( {severities.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
@ -352,10 +352,10 @@ function SubscriptionCard({ subscription, nodes }: { subscription: Subscription;
const Icon = getTypeIcon() const Icon = getTypeIcon()
return ( return (
<div className="p-4 rounded-lg bg-bg-hover border border-border"> <div className="p-4 bg-bg-hover border border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center"> <div className="w-10 h-10 bg-sky-400/10 flex items-center justify-center">
<Icon size={18} className="text-blue-400" /> <Icon size={18} className="text-sky-400" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="text-sm text-slate-200 font-medium"> <div className="text-sm text-slate-200 font-medium">
@ -490,7 +490,7 @@ export default function Alerts() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Active Alerts */} {/* Active Alerts */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<AlertTriangle size={14} /> <AlertTriangle size={14} />
Active Alerts ({visibleAlerts.length}) Active Alerts ({visibleAlerts.length})
@ -538,7 +538,7 @@ export default function Alerts() {
</div> </div>
{/* Subscriptions */} {/* Subscriptions */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Users size={14} /> <Users size={14} />
Mesh Subscriptions ({subscriptions.length}) Mesh Subscriptions ({subscriptions.length})
@ -553,7 +553,7 @@ export default function Alerts() {
<div className="text-slate-500 py-4"> <div className="text-slate-500 py-4">
<p>No active subscriptions.</p> <p>No active subscriptions.</p>
<p className="text-xs mt-2"> <p className="text-xs mt-2">
Manage subscriptions via <code className="text-blue-400">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-blue-400 hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-blue-400 hover:underline">Reminder System</a> in Reference. Manage subscriptions via <code className="text-sky-400">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-sky-400 hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-sky-400 hover:underline">Reminder System</a> in Reference.
</p> </p>
</div> </div>
)} )}

View file

@ -340,7 +340,7 @@ function InfoButton({ info, link, linkText = 'Learn more' }: { info: string; lin
? ?
</button> </button>
{open && ( {open && (
<div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed"> <div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] shadow-xl text-xs text-slate-300 leading-relaxed">
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
@ -628,7 +628,7 @@ function AlertRuleToggle({ label, description, checked, onChange, threshold, onT
thresholdSuffix?: string thresholdSuffix?: string
}) { }) {
return ( return (
<div className="border border-[#1e2a3a] rounded-lg p-3 space-y-2"> <div className="border border-[#1e2a3a] p-3 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<span className="text-sm text-slate-300">{label}</span> <span className="text-sm text-slate-300">{label}</span>
@ -1299,7 +1299,7 @@ function MeshSourceCard({ source, onChange, onDelete }: {
} }
return ( return (
<div className="border border-[#1e2a3a] rounded-lg overflow-hidden"> <div className="border border-[#1e2a3a] overflow-hidden">
<div <div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer" className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
@ -1402,7 +1402,7 @@ function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onCh
))} ))}
<button <button
onClick={addSource} onClick={addSource}
className="w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors" className="w-full py-2 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
> >
<Plus size={16} /> Add Source <Plus size={16} /> Add Source
</button> </button>
@ -1498,7 +1498,7 @@ function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceCon
<InfoButton info="Regions group mesh nodes by geographic area. Each region has an anchor point (lat/lon) and nodes within the region radius are automatically assigned. Regions enable localized reports, alerts, and health scoring." /> <InfoButton info="Regions group mesh nodes by geographic area. Each region has an anchor point (lat/lon) and nodes within the region radius are automatically assigned. Regions enable localized reports, alerts, and health scoring." />
</label> </label>
{data.regions.map((region, i) => ( {data.regions.map((region, i) => (
<div key={i} className="border border-[#1e2a3a] rounded-lg overflow-hidden"> <div key={i} className="border border-[#1e2a3a] overflow-hidden">
<div <div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer" className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
onClick={() => setExpandedRegion(expandedRegion === i ? null : i)} onClick={() => setExpandedRegion(expandedRegion === i ? null : i)}
@ -1580,7 +1580,7 @@ function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceCon
onChange({ ...data, regions: [...data.regions, newRegion] }) onChange({ ...data, regions: [...data.regions, newRegion] })
setExpandedRegion(data.regions.length) setExpandedRegion(data.regions.length)
}} }}
className="w-full py-2 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors" className="w-full py-2 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
> >
<Plus size={16} /> Add Region <Plus size={16} /> Add Region
</button> </button>
@ -1974,7 +1974,7 @@ export default function Config() {
</div> </div>
{restartRequired && ( {restartRequired && (
<div className="flex items-center justify-between p-3 mb-4 bg-amber-500/10 border border-amber-500/30 rounded-lg"> <div className="flex items-center justify-between p-3 mb-4 bg-amber-500/10 border border-amber-500/30">
<div className="flex items-center gap-2 text-amber-400"> <div className="flex items-center gap-2 text-amber-400">
<AlertTriangle size={16} /> <AlertTriangle size={16} />
<span className="text-sm">Restart required for changes to take effect</span> <span className="text-sm">Restart required for changes to take effect</span>
@ -1989,21 +1989,21 @@ export default function Config() {
)} )}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 mb-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400"> <div className="flex items-center gap-2 p-3 mb-4 bg-red-500/10 border border-red-500/30 text-red-400">
<X size={16} /> <X size={16} />
<span className="text-sm">{error}</span> <span className="text-sm">{error}</span>
</div> </div>
)} )}
{success && ( {success && (
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400"> <div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 text-green-400">
<Check size={16} /> <Check size={16} />
<span className="text-sm">{success}</span> <span className="text-sm">{success}</span>
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto pr-2"> <div className="flex-1 overflow-y-auto pr-2">
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="bg-bg-card border border-border p-6">
{renderSection()} {renderSection()}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -92,7 +92,7 @@ export default function GaugeSites() {
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Droplets className="w-5 h-5 text-cyan-400" /> <Droplets className="w-5 h-5 text-accent" />
<h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1> <h1 className="text-xl font-semibold text-slate-100">Gauge Sites</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} sites</span> <span className="text-xs text-slate-500 ml-2">{rows.length} sites</span>
<button onClick={beginAdd} <button onClick={beginAdd}
@ -106,7 +106,7 @@ export default function GaugeSites() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />} {adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding feedSource={feedSource} />}
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto"> <div className="bg-slate-800/60 border border-slate-700 overflow-x-auto">
<table className="w-full text-sm text-slate-200"> <table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase"> <thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<tr> <tr>
@ -139,7 +139,7 @@ export default function GaugeSites() {
<td className="px-3 py-2 text-right">{r.flood_major_ft ?? '-'}</td> <td className="px-3 py-2 text-right">{r.flood_major_ft ?? '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td> <td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button> <button onClick={() => beginEdit(r)} className="text-accent hover:text-accent text-xs mr-3">Edit</button>
<button onClick={() => remove(r.site_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button> <button onClick={() => remove(r.site_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td> </td>
</tr> </tr>
@ -249,7 +249,7 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding, feedSource }: {
value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} /> value={draft.flood_major_ft ?? ''} onChange={e => upd('flood_major_ft', e.target.value === '' ? null : parseFloat(e.target.value))} />
</label> </label>
<label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2"> <label className="text-xs text-slate-300 col-span-2 flex items-center gap-2 mt-2">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500" /> <input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-[#f59e0b]" />
Enabled Enabled
</label> </label>
<div className="col-span-2 flex items-center justify-end gap-2 mt-2"> <div className="col-span-2 flex items-center justify-end gap-2 mt-2">

View file

@ -76,7 +76,7 @@ export default function Mesh() {
</div> </div>
{/* View toggle */} {/* View toggle */}
<div className="flex items-center bg-bg-card border border-border rounded-lg p-1"> <div className="flex items-center bg-bg-card border border-border p-1">
<button <button
onClick={() => setViewMode('topo')} onClick={() => setViewMode('topo')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${ className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${

View file

@ -349,7 +349,7 @@ function InfoButton({ info }: { info: string }) {
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed"> <div className="absolute left-0 top-6 z-50 w-72 p-3 bg-[#1a2332] border border-[#2a3a4a] shadow-xl text-xs text-slate-300 leading-relaxed">
{info} {info}
</div> </div>
</> </>
@ -584,7 +584,7 @@ function SeveritySelector({ value, onChange }: {
{isOpen && ( {isOpen && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} /> <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl overflow-hidden"> <div className="absolute left-0 right-0 top-full mt-1 z-50 bg-[#0a0e17] border border-[#1e2a3a] shadow-xl overflow-hidden">
{SEVERITY_OPTIONS.map((opt) => ( {SEVERITY_OPTIONS.map((opt) => (
<button <button
key={opt.value} key={opt.value}
@ -915,7 +915,7 @@ function NotificationRuleCard({
} }
return ( return (
<div className={`border rounded-lg overflow-hidden ${rule.enabled ? 'border-[#1e2a3a]' : 'border-slate-700 opacity-60'}`}> <div className={`border overflow-hidden ${rule.enabled ? 'border-[#1e2a3a]' : 'border-slate-700 opacity-60'}`}>
{/* Header */} {/* Header */}
<div <div
className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer" className="flex items-center justify-between p-3 bg-[#0a0e17] cursor-pointer"
@ -929,7 +929,7 @@ function NotificationRuleCard({
title={rule.enabled ? 'Enabled' : 'Disabled'} title={rule.enabled ? 'Enabled' : 'Disabled'}
/> />
{rule.trigger_type === 'schedule' ? ( {rule.trigger_type === 'schedule' ? (
<Clock size={14} className="text-blue-400 flex-shrink-0" /> <Clock size={14} className="text-sky-400 flex-shrink-0" />
) : ( ) : (
<Zap size={14} className="text-yellow-400 flex-shrink-0" /> <Zap size={14} className="text-yellow-400 flex-shrink-0" />
)} )}
@ -976,7 +976,7 @@ function NotificationRuleCard({
<button <button
onClick={(e) => { e.stopPropagation(); handleTest() }} onClick={(e) => { e.stopPropagation(); handleTest() }}
disabled={testing || !rule.name} disabled={testing || !rule.name}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 rounded disabled:opacity-50" className="p-1.5 text-sky-400 hover:text-sky-300 hover:bg-sky-400/10 rounded disabled:opacity-50"
title="Test rule" title="Test rule"
> >
<Send size={14} /> <Send size={14} />
@ -1034,7 +1034,7 @@ function NotificationRuleCard({
<button <button
type="button" type="button"
onClick={() => onChange({ ...rule, trigger_type: 'condition' })} onClick={() => onChange({ ...rule, trigger_type: 'condition' })}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 border transition-colors ${
rule.trigger_type !== 'schedule' rule.trigger_type !== 'schedule'
? 'bg-accent/10 border-accent text-accent' ? 'bg-accent/10 border-accent text-accent'
: 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200' : 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200'
@ -1046,7 +1046,7 @@ function NotificationRuleCard({
<button <button
type="button" type="button"
onClick={() => onChange({ ...rule, trigger_type: 'schedule' })} onClick={() => onChange({ ...rule, trigger_type: 'schedule' })}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors ${ className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 border transition-colors ${
rule.trigger_type === 'schedule' rule.trigger_type === 'schedule'
? 'bg-accent/10 border-accent text-accent' ? 'bg-accent/10 border-accent text-accent'
: 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200' : 'bg-[#0a0e17] border-[#1e2a3a] text-slate-400 hover:text-slate-200'
@ -1065,7 +1065,7 @@ function NotificationRuleCard({
{/* WHEN section - Condition trigger */} {/* WHEN section - Condition trigger */}
{rule.trigger_type !== 'schedule' && ( {rule.trigger_type !== 'schedule' && (
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300"> <div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<AlertTriangle size={14} /> <AlertTriangle size={14} />
WHEN (Condition) WHEN (Condition)
@ -1106,7 +1106,7 @@ function NotificationRuleCard({
{/* WHEN section - Schedule trigger */} {/* WHEN section - Schedule trigger */}
{rule.trigger_type === 'schedule' && ( {rule.trigger_type === 'schedule' && (
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300"> <div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Calendar size={14} /> <Calendar size={14} />
WHEN (Schedule) WHEN (Schedule)
@ -1197,7 +1197,7 @@ function NotificationRuleCard({
)} )}
{/* REGIONS section — scope rule to specific regions; empty = all regions */} {/* REGIONS section — scope rule to specific regions; empty = all regions */}
<div className="space-y-2 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-2 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300"> <div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<MapPin size={14} /> <MapPin size={14} />
REGIONS REGIONS
@ -1235,7 +1235,7 @@ function NotificationRuleCard({
</div> </div>
{/* SEND VIA section */} {/* SEND VIA section */}
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300"> <div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Send size={14} /> <Send size={14} />
SEND VIA SEND VIA
@ -1262,7 +1262,7 @@ function NotificationRuleCard({
{/* No delivery warning */} {/* No delivery warning */}
{!rule.delivery_type && ( {!rule.delivery_type && (
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg"> <div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20">
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" /> <AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-amber-300"> <div className="text-sm text-amber-300">
Rule will log matches but not deliver until a delivery method is configured. Rule will log matches but not deliver until a delivery method is configured.
@ -1402,7 +1402,7 @@ function NotificationRuleCard({
{rule.trigger_type !== 'schedule' && ( {rule.trigger_type !== 'schedule' && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label> <label className="text-xs text-slate-500 uppercase tracking-wide">Example Message</label>
<div className="p-3 bg-[#1e2a3a]/50 rounded-lg border border-[#1e2a3a]"> <div className="p-3 bg-[#1e2a3a]/50 border border-[#1e2a3a]">
<p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p> <p className="text-sm text-slate-300 font-mono">{getExampleMessage()}</p>
</div> </div>
<p className="text-xs text-slate-600">This is an example of what this rule would send.</p> <p className="text-xs text-slate-600">This is an example of what this rule would send.</p>
@ -1532,7 +1532,7 @@ function GroupedCategoryPicker({
} }
return ( return (
<div className="max-h-96 overflow-y-auto border border-[#1e2a3a] rounded-lg p-2 space-y-2"> <div className="max-h-96 overflow-y-auto border border-[#1e2a3a] p-2 space-y-2">
{TOGGLE_FAMILY_META.map(f => renderGroup(f.key, f.label, f.Icon, byFamily.get(f.key) || []))} {TOGGLE_FAMILY_META.map(f => renderGroup(f.key, f.label, f.Icon, byFamily.get(f.key) || []))}
{renderGroup('other', 'Other', null, other)} {renderGroup('other', 'Other', null, other)}
</div> </div>
@ -1561,7 +1561,7 @@ function MasterToggles({ toggles, onChange }: {
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0) const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
const regionCount = (t.regions || []).length const regionCount = (t.regions || []).length
return ( return (
<div key={key} className="border border-[#1e2a3a] rounded-lg p-3"> <div key={key} className="border border-[#1e2a3a] p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button type="button" onClick={() => setExpanded(isOpen ? null : key)} <button type="button" onClick={() => setExpanded(isOpen ? null : key)}
className="flex items-center gap-2 text-sm text-slate-200"> className="flex items-center gap-2 text-sm text-slate-200">
@ -1826,7 +1826,7 @@ export default function Notifications() {
{/* Test Dialog */} {/* Test Dialog */}
{testDialog.open && ( {testDialog.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[85vh] overflow-auto"> <div className="bg-[#1a2332] border border-[#2a3a4a] shadow-xl max-w-2xl w-full mx-4 max-h-[85vh] overflow-auto">
<div className="p-4 border-b border-[#2a3a4a] flex items-center justify-between sticky top-0 bg-[#1a2332]"> <div className="p-4 border-b border-[#2a3a4a] flex items-center justify-between sticky top-0 bg-[#1a2332]">
<h3 className="text-lg font-semibold">Test Notification Rule</h3> <h3 className="text-lg font-semibold">Test Notification Rule</h3>
<button onClick={closeTestDialog} className="text-slate-500 hover:text-slate-300"> <button onClick={closeTestDialog} className="text-slate-500 hover:text-slate-300">
@ -2033,19 +2033,19 @@ export default function Notifications() {
{/* Status messages */} {/* Status messages */}
{error && ( {error && (
<div className="p-3 rounded-lg text-sm bg-red-500/10 text-red-400 border border-red-500/20"> <div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
{error} {error}
</div> </div>
)} )}
{success && ( {success && (
<div className="p-3 rounded-lg text-sm bg-green-500/10 text-green-400 border border-green-500/20"> <div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" /> <Check size={14} className="inline mr-2" />
{success} {success}
</div> </div>
)} )}
{/* Main content */} {/* Main content */}
<div className="bg-bg-card border border-border rounded-lg p-6 space-y-6"> <div className="bg-bg-card border border-border p-6 space-y-6">
<Toggle <Toggle
label="Enable Notifications" label="Enable Notifications"
checked={config.enabled} checked={config.enabled}
@ -2056,7 +2056,7 @@ export default function Notifications() {
{config.enabled && ( {config.enabled && (
<> {/* Cold-start grace -- v0.5.8b */} <> {/* Cold-start grace -- v0.5.8b */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label> <label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label>
</div> </div>
@ -2072,7 +2072,7 @@ export default function Notifications() {
</div> </div>
{/* Band Conditions -- v0.5.11 */} {/* Band Conditions -- v0.5.11 */}
<div className="space-y-3 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]"> <div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Band Conditions (HF propagation)</label> <label className="text-xs text-slate-500 uppercase tracking-wide">Band Conditions (HF propagation)</label>
</div> </div>
@ -2164,21 +2164,21 @@ export default function Notifications() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={addRule} onClick={addRule}
className="flex-1 py-3 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors" className="flex-1 py-3 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center justify-center gap-2 transition-colors"
> >
<Plus size={16} /> Add Rule <Plus size={16} /> Add Rule
</button> </button>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowTemplates(!showTemplates)} onClick={() => setShowTemplates(!showTemplates)}
className="py-3 px-4 border border-dashed border-[#1e2a3a] rounded-lg text-slate-500 hover:text-slate-300 hover:border-accent flex items-center gap-2 transition-colors" className="py-3 px-4 border border-dashed border-[#1e2a3a] text-slate-500 hover:text-slate-300 hover:border-accent flex items-center gap-2 transition-colors"
> >
<Layers size={16} /> Add from Template <Layers size={16} /> Add from Template
</button> </button>
{showTemplates && ( {showTemplates && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowTemplates(false)} /> <div className="fixed inset-0 z-40" onClick={() => setShowTemplates(false)} />
<div className="absolute right-0 top-full mt-2 z-50 w-80 bg-[#1a2332] border border-[#2a3a4a] rounded-lg shadow-xl overflow-hidden"> <div className="absolute right-0 top-full mt-2 z-50 w-80 bg-[#1a2332] border border-[#2a3a4a] shadow-xl overflow-hidden">
<div className="p-2 border-b border-[#2a3a4a] text-xs text-slate-500 uppercase">Rule Templates</div> <div className="p-2 border-b border-[#2a3a4a] text-xs text-slate-500 uppercase">Rule Templates</div>
{RULE_TEMPLATES.map((t) => ( {RULE_TEMPLATES.map((t) => (
<button <button

View file

@ -64,7 +64,7 @@ export default function TownAnchors() {
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-cyan-400" /> <MapPin className="w-5 h-5 text-accent" />
<h1 className="text-xl font-semibold text-slate-100">Town Anchors</h1> <h1 className="text-xl font-semibold text-slate-100">Town Anchors</h1>
<span className="text-xs text-slate-500 ml-2">{rows.length} towns</span> <span className="text-xs text-slate-500 ml-2">{rows.length} towns</span>
<button onClick={beginAdd} <button onClick={beginAdd}
@ -82,7 +82,7 @@ export default function TownAnchors() {
{adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />} {adding && <RowEditor draft={draft} setDraft={setDraft} onSave={save} onCancel={cancel} adding />}
<div className="bg-slate-800/60 border border-slate-700 rounded-lg overflow-x-auto"> <div className="bg-slate-800/60 border border-slate-700 overflow-x-auto">
<table className="w-full text-sm text-slate-200"> <table className="w-full text-sm text-slate-200">
<thead className="bg-slate-900 text-xs text-slate-400 uppercase"> <thead className="bg-slate-900 text-xs text-slate-400 uppercase">
<tr> <tr>
@ -107,7 +107,7 @@ export default function TownAnchors() {
<td className="px-3 py-2 text-center text-xs">{r.state || '-'}</td> <td className="px-3 py-2 text-center text-xs">{r.state || '-'}</td>
<td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td> <td className="px-3 py-2 text-center">{r.enabled ? <Check className="w-4 h-4 text-emerald-400 inline" /> : <X className="w-4 h-4 text-slate-500 inline" />}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<button onClick={() => beginEdit(r)} className="text-cyan-400 hover:text-cyan-300 text-xs mr-3">Edit</button> <button onClick={() => beginEdit(r)} className="text-accent hover:text-accent text-xs mr-3">Edit</button>
<button onClick={() => remove(r.anchor_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button> <button onClick={() => remove(r.anchor_id)} className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4 inline" /></button>
</td> </td>
</tr> </tr>
@ -136,7 +136,7 @@ function RowEditor({ draft, setDraft, onSave, onCancel, adding }: {
value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} /> value={draft.state ?? ''} onChange={e => upd('state', e.target.value)} />
</label> </label>
<label className="text-xs text-slate-400 flex items-center gap-2"> <label className="text-xs text-slate-400 flex items-center gap-2">
<input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-cyan-500 mt-4" /> <input type="checkbox" checked={draft.enabled} onChange={e => upd('enabled', e.target.checked)} className="accent-[#f59e0b] mt-4" />
Enabled Enabled
</label> </label>
<label className="text-xs text-slate-400">Lat <label className="text-xs text-slate-400">Lat

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-B6zGwxmY.js"></script> <script type="module" crossorigin src="/assets/index-7P5nbdAV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DqWqopB2.css"> <link rel="stylesheet" crossorigin href="/assets/index-EzV2LMjq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>