diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index 87a7c0c3..6b6bf88a 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import api from '../utils/api'; -import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle } from 'lucide-react'; +import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity, Square, RefreshCw, Play } from 'lucide-react'; +import { useToast } from './Toasts/useToast'; import './Dashboard.css'; import './Config.css'; @@ -22,7 +23,30 @@ interface ConfigData { const Config: React.FC = () => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals'>('limits'); + const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals' | 'appearance' | 'workers'>('limits'); + const [accent, setAccent] = useState<'matrix' | 'violet'>(() => { + try { + const raw = localStorage.getItem('decnet_tweaks'); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed?.accent === 'violet') return 'violet'; + } + } catch { /* noop */ } + return 'matrix'; + }); + const { push: pushToast } = useToast(); + + const handleAccentChange = (value: 'matrix' | 'violet') => { + setAccent(value); + let existing: Record = {}; + try { + const raw = localStorage.getItem('decnet_tweaks'); + if (raw) existing = JSON.parse(raw) ?? {}; + } catch { existing = {}; } + localStorage.setItem('decnet_tweaks', JSON.stringify({ ...existing, accent: value })); + document.documentElement.setAttribute('data-accent', value); + pushToast({ text: `ACCENT · ${value.toUpperCase()}`, icon: 'check-circle', tone: 'violet' }); + }; // Deployment limit state const [limitInput, setLimitInput] = useState(''); @@ -212,6 +236,8 @@ const Config: React.FC = () => { ? [{ key: 'users', label: 'USER MANAGEMENT', icon: }] : []), { key: 'globals', label: 'GLOBAL VALUES', icon: }, + { key: 'appearance', label: 'APPEARANCE', icon: }, + ...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: }] : []), ]; return ( @@ -463,6 +489,49 @@ const Config: React.FC = () => { )} + {/* WORKERS TAB (admin only, server-gated too) */} + {activeTab === 'workers' && isAdmin && ( + + )} + + {/* APPEARANCE TAB */} + {activeTab === 'appearance' && ( +
+
+ ACCENT COLOR +

+ Swaps the UI accent (nav bars, hover glows, chip borders) between matrix-green and electric-violet. Persists per-browser. +

+
+ {(['matrix', 'violet'] as const).map((value) => ( + + ))} +
+
+
+ )} + {/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */} {activeTab === 'globals' && config.developer_mode && (
@@ -515,4 +584,339 @@ const Config: React.FC = () => { ); }; +// ─── Workers panel ──────────────────────────────────────────────────────────── +// Pollster view backed by GET /workers. Every 5s we pull the full snapshot; +// the registry is cheap (in-memory dict) so there's no need for SSE here. + +interface WorkerStatusRow { + name: string; + status: 'ok' | 'stale' | 'unknown'; + last_heartbeat_ts: number | null; + seconds_since: number | null; + extra: Record; + installed: boolean; +} + +interface WorkersPanelProps { + pushToast: ReturnType['push']; +} + +const WorkersPanel: React.FC = ({ pushToast }) => { + const [workers, setWorkers] = useState(null); + const [busConnected, setBusConnected] = useState(null); + const [err, setErr] = useState(null); + const [stopping, setStopping] = useState>({}); + const [starting, setStarting] = useState>({}); + const [startingAll, setStartingAll] = useState(false); + + const fetchWorkers = async () => { + try { + const res = await api.get('/workers'); + setWorkers(res.data?.workers ?? []); + setBusConnected( + typeof res.data?.bus_connected === 'boolean' ? res.data.bus_connected : null, + ); + setErr(null); + } catch (e: any) { + setErr(e?.response?.data?.detail || 'Failed to load workers'); + } + }; + + const [refreshing, setRefreshing] = useState(false); + const [lastRefresh, setLastRefresh] = useState(null); + + const handleRefresh = async () => { + setRefreshing(true); + try { + await fetchWorkers(); + setLastRefresh(Date.now()); + } finally { + setRefreshing(false); + } + }; + + useEffect(() => { + handleRefresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleStop = async (name: string) => { + setStopping((s) => ({ ...s, [name]: true })); + try { + await api.post(`/workers/${encodeURIComponent(name)}/stop`); + pushToast({ text: `STOP REQUESTED · ${name.toUpperCase()}`, tone: 'violet', icon: 'terminal' }); + // Kick a refresh sooner than the 5s tick so the UI feels responsive. + setTimeout(fetchWorkers, 1000); + } catch (e: any) { + const detail = e?.response?.data?.detail || 'Stop failed'; + pushToast({ text: `STOP FAILED · ${name.toUpperCase()} — ${detail}`, tone: 'alert', icon: 'alert-triangle' }); + } finally { + setStopping((s) => ({ ...s, [name]: false })); + } + }; + + const handleStart = async (name: string) => { + setStarting((s) => ({ ...s, [name]: true })); + try { + await api.post(`/workers/${encodeURIComponent(name)}/start`); + pushToast({ text: `START REQUESTED · ${name.toUpperCase()}`, tone: 'violet', icon: 'terminal' }); + setTimeout(fetchWorkers, 1500); + // Auto-clear the spinner state after 15s if the heartbeat still + // hasn't flipped the row — keeps the UI from getting stuck. + setTimeout(() => setStarting((s) => ({ ...s, [name]: false })), 15000); + } catch (e: any) { + const detail = e?.response?.data?.detail || 'Start failed'; + pushToast({ text: `START FAILED · ${name.toUpperCase()} — ${detail}`, tone: 'alert', icon: 'alert-triangle' }); + setStarting((s) => ({ ...s, [name]: false })); + } + }; + + const handleStartAll = async () => { + setStartingAll(true); + try { + const res = await api.post('/workers/start-all'); + const started: string[] = res.data?.started ?? []; + const already: string[] = res.data?.already_running ?? []; + const failed: Array<{ name: string; reason: string }> = res.data?.failed ?? []; + const firstFail = failed[0]; + const suffix = firstFail ? ` (first failure: ${firstFail.name} — ${firstFail.reason})` : ''; + pushToast({ + text: `STARTED · ${started.length} · ALREADY RUNNING · ${already.length} · FAILED · ${failed.length}${suffix}`, + tone: failed.length > 0 ? 'alert' : 'violet', + icon: failed.length > 0 ? 'alert-triangle' : 'terminal', + }); + setTimeout(fetchWorkers, 1500); + } catch (e: any) { + const detail = e?.response?.data?.detail || 'Start-all failed'; + pushToast({ text: `START ALL FAILED — ${detail}`, tone: 'alert', icon: 'alert-triangle' }); + } finally { + setStartingAll(false); + } + }; + + const formatLastSeen = (row: WorkerStatusRow): string => { + if (row.seconds_since == null) return '—'; + const s = row.seconds_since; + if (s < 60) return `${Math.floor(s)}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + return `${Math.floor(s / 3600)}h ago`; + }; + + const dotClass = (status: WorkerStatusRow['status']) => { + if (status === 'ok') return 'status-dot active'; + if (status === 'stale') return 'status-dot warn'; + return 'status-dot idle'; + }; + + if (err) { + return ( +
+
+ + {err} +
+
+ ); + } + + if (workers === null) { + return ( +
+
LOADING…
+
+ ); + } + + const busOffline = busConnected === false; + + return ( +
+ {busOffline && ( +
+ +
+
BUS OFFLINE — heartbeats cannot be received.
+
+ Start with decnet bus (restart the API if it was up first). +
+
+
+ )} +
+
+ HEARTBEATS EVERY 30s · OK < 90s · STALE AFTER + {lastRefresh != null && ( + + · REFRESHED {new Date(lastRefresh).toLocaleTimeString()} + + )} +
+
+ + +
+
+ + + + + + + + + + + + {workers.map((w) => { + const isStopping = !!stopping[w.name]; + const canStop = w.status === 'ok' && !isStopping && !busOffline; + return ( + + + + + + + + ); + })} + +
NAMESTATUSLAST SEENACTIONS
{w.name.toUpperCase()} + {w.status.toUpperCase()} + {formatLastSeen(w)} + + {(() => { + const isStarting = !!starting[w.name]; + const canStart = w.installed && w.status !== 'ok' && !isStarting; + const tooltip = !w.installed + ? `Unit not installed — deploy decnet-${w.name}.service first.` + : w.status === 'ok' + ? 'Already running.' + : isStarting + ? 'Start request in flight…' + : 'Start the worker via systemd.'; + return ( + + ); + })()} +
+
+ ); +}; + export default Config;