mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
feat: Carbon theme — amber accent, Inter/JetBrains Mono, sharp corners, traffic-light semantic colors across all components
Global: removed all rounded-lg/md/sm classes, replaced blue-500 with sky-400 informational, cyan accents with amber across all tsx files. Environment.tsx + AdapterConfig.tsx: full Carbon color sweep — slate hierarchy replaced with #333/#444/white tokens, border-border tokens, font-sans labels, font-mono values. Layout.tsx logo text-[15px]. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b08cdc004
commit
d0bf298f89
19 changed files with 1472 additions and 1472 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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 }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ${
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue