From b1fbf4630e5fb67b92d3e8401a84996011f021a1 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:22:10 -0400 Subject: [PATCH] refactor(decnet_web/Config): move WorkersPanel out Verbatim move of the worker-status pollster (~390 LOC) plus its RealismBadge sidekick into its own file. Owns its own polling + stop/start/start-all mutations; toast push comes in via prop so the parent stays the one source of toast tone. - New Config/WorkersPanel.tsx - WorkersPanel.test.tsx (MSW) covers worker-row rendering, the BUS OFFLINE banner, and the error panel on /workers 500. - Config.tsx loses the inline WorkersPanel + RealismBadge plus the now-unused icon imports (Square, RefreshCw, Play). --- decnet_web/src/components/Config.tsx | 396 +----------------- .../components/Config/WorkersPanel.test.tsx | 66 +++ .../src/components/Config/WorkersPanel.tsx | 396 ++++++++++++++++++ 3 files changed, 464 insertions(+), 394 deletions(-) create mode 100644 decnet_web/src/components/Config/WorkersPanel.test.tsx create mode 100644 decnet_web/src/components/Config/WorkersPanel.tsx diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index 04166505..1e8bf94e 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useState } from 'react'; import api from '../utils/api'; -import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity, Square, RefreshCw, Play } from '../icons'; +import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity } from '../icons'; import { useToast } from './Toasts/useToast'; import RuleStateControls from './RuleStateControls'; import './Dashboard.css'; import './Config.css'; import type { ConfigData, ConfigTab } from './Config/types'; +import { WorkersPanel } from './Config/WorkersPanel'; const Config: React.FC = () => { const [config, setConfig] = useState(null); @@ -579,398 +580,5 @@ 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']; -} - - -// Renders the LLM status of a realism-emitting worker (today: orchestrator). -// Sourced from the heartbeat ``extra.realism`` payload published by -// :func:`decnet.orchestrator.worker._realism_health_snapshot`. -const RealismBadge: React.FC<{ - realism: { - llm_enabled?: boolean; - llm_backend?: string | null; - llm_model?: string | null; - llm_breaker_state?: 'closed' | 'open' | 'half_open' | null; - }; -}> = ({ realism }) => { - if (!realism.llm_enabled) { - return ( - - LLM OFF - - ); - } - const breaker = realism.llm_breaker_state ?? 'closed'; - const breakerColor = - breaker === 'open' ? '#ff5555' - : breaker === 'half_open' ? 'var(--warn)' - : 'var(--matrix)'; - const tooltip = [ - `Backend: ${realism.llm_backend ?? '?'}`, - realism.llm_model ? `Model: ${realism.llm_model}` : null, - `Circuit breaker: ${breaker}`, - ].filter(Boolean).join('\n'); - return ( - - - LLM {(realism.llm_backend ?? 'on').toUpperCase()} - - ); -}; - -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; - const realism = (w.extra && (w.extra as any).realism) as - | { - llm_enabled?: boolean; - llm_backend?: string | null; - llm_model?: string | null; - llm_breaker_state?: 'closed' | 'open' | 'half_open' | null; - } - | undefined; - return ( - - - - - - - - ); - })} - -
NAMESTATUSLAST SEENACTIONS
- {w.name.toUpperCase()} - {realism && } - - {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; diff --git a/decnet_web/src/components/Config/WorkersPanel.test.tsx b/decnet_web/src/components/Config/WorkersPanel.test.tsx new file mode 100644 index 00000000..5e6f0005 --- /dev/null +++ b/decnet_web/src/components/Config/WorkersPanel.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse, server, apiUrl } from '../../test/server'; +import { renderWithRouter } from '../../test/renderWithRouter'; +import { WorkersPanel } from './WorkersPanel'; + +const noop = () => {}; + +describe('WorkersPanel', () => { + it('renders the worker rows from /workers', async () => { + server.use( + http.get(apiUrl('/workers'), () => + HttpResponse.json({ + workers: [ + { + name: 'orchestrator', + status: 'ok', + last_heartbeat_ts: 0, + seconds_since: 12, + extra: {}, + installed: true, + }, + { + name: 'profiler', + status: 'stale', + last_heartbeat_ts: 0, + seconds_since: 600, + extra: {}, + installed: true, + }, + ], + bus_connected: true, + }), + ), + ); + renderWithRouter(); + await waitFor(() => expect(screen.getByText('ORCHESTRATOR')).toBeInTheDocument()); + expect(screen.getByText('PROFILER')).toBeInTheDocument(); + // "OK" appears twice — in the header copy ("OK < 90s") and in the + // status cell. Just confirm both are present. + expect(screen.getAllByText('OK').length).toBeGreaterThan(0); + expect(screen.getByText('STALE')).toBeInTheDocument(); + }); + + it('renders the BUS OFFLINE banner when bus_connected is false', async () => { + server.use( + http.get(apiUrl('/workers'), () => + HttpResponse.json({ workers: [], bus_connected: false }), + ), + ); + renderWithRouter(); + await waitFor(() => + expect(screen.getByText(/BUS OFFLINE/)).toBeInTheDocument(), + ); + }); + + it('renders an error panel when /workers fails', async () => { + server.use( + http.get(apiUrl('/workers'), () => + HttpResponse.json({ detail: 'boom' }, { status: 500 }), + ), + ); + renderWithRouter(); + await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument()); + }); +}); diff --git a/decnet_web/src/components/Config/WorkersPanel.tsx b/decnet_web/src/components/Config/WorkersPanel.tsx new file mode 100644 index 00000000..3f174178 --- /dev/null +++ b/decnet_web/src/components/Config/WorkersPanel.tsx @@ -0,0 +1,396 @@ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, Play, RefreshCw, Square } from '../../icons'; +import api from '../../utils/api'; +import { useToast } from '../Toasts/useToast'; + +// 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 Props { + pushToast: ReturnType['push']; +} + +// Renders the LLM status of a realism-emitting worker (today: orchestrator). +// Sourced from the heartbeat ``extra.realism`` payload published by +// :func:`decnet.orchestrator.worker._realism_health_snapshot`. +const RealismBadge: React.FC<{ + realism: { + llm_enabled?: boolean; + llm_backend?: string | null; + llm_model?: string | null; + llm_breaker_state?: 'closed' | 'open' | 'half_open' | null; + }; +}> = ({ realism }) => { + if (!realism.llm_enabled) { + return ( + + LLM OFF + + ); + } + const breaker = realism.llm_breaker_state ?? 'closed'; + const breakerColor = + breaker === 'open' ? '#ff5555' + : breaker === 'half_open' ? 'var(--warn)' + : 'var(--matrix)'; + const tooltip = [ + `Backend: ${realism.llm_backend ?? '?'}`, + realism.llm_model ? `Model: ${realism.llm_model}` : null, + `Circuit breaker: ${breaker}`, + ].filter(Boolean).join('\n'); + return ( + + + LLM {(realism.llm_backend ?? 'on').toUpperCase()} + + ); +}; + +export 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; + const realism = (w.extra && (w.extra as any).realism) as + | { + llm_enabled?: boolean; + llm_backend?: string | null; + llm_model?: string | null; + llm_breaker_state?: 'closed' | 'open' | 'half_open' | null; + } + | undefined; + return ( + + + + + + + + ); + })} + +
NAMESTATUSLAST SEENACTIONS
+ {w.name.toUpperCase()} + {realism && } + + {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 ( + + ); + })()} +
+
+ ); +};