feat(web): wire Workers panel START + START ALL buttons
Per-row START button enabled iff `installed && status !== 'ok'`;
tooltip explains why it's disabled ("Unit not installed" /
"Already running"). Transient `starting` state shows `...` on the
button and auto-clears after 15s so the UI never gets stuck if the
heartbeat is slow.
START ALL WORKERS button in the header calls /workers/start-all and
renders the three counts in the toast:
`STARTED · N · ALREADY RUNNING · M · FAILED · K (first failure: …)`.
Tone flips to alert when K > 0.
This commit is contained in:
@@ -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<ConfigData | null>(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<string, unknown> = {};
|
||||
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: <Users size={14} /> }]
|
||||
: []),
|
||||
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
|
||||
{ key: 'appearance', label: 'APPEARANCE', icon: <Palette size={14} /> },
|
||||
...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: <Activity size={14} /> }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -463,6 +489,49 @@ const Config: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WORKERS TAB (admin only, server-gated too) */}
|
||||
{activeTab === 'workers' && isAdmin && (
|
||||
<WorkersPanel pushToast={pushToast} />
|
||||
)}
|
||||
|
||||
{/* APPEARANCE TAB */}
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="config-panel">
|
||||
<div className="config-field">
|
||||
<span className="config-label">ACCENT COLOR</span>
|
||||
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
|
||||
Swaps the UI accent (nav bars, hover glows, chip borders) between matrix-green and electric-violet. Persists per-browser.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{(['matrix', 'violet'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleAccentChange(value)}
|
||||
className="save-btn"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.75rem',
|
||||
letterSpacing: '1.5px',
|
||||
borderColor: accent === value
|
||||
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
||||
: 'var(--border)',
|
||||
color: accent === value
|
||||
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
||||
: 'var(--matrix)',
|
||||
opacity: accent === value ? 1 : 0.6,
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
{accent === value ? '● ' : '○ '}
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */}
|
||||
{activeTab === 'globals' && config.developer_mode && (
|
||||
<div className="config-panel" style={{ borderColor: '#ff4141' }}>
|
||||
@@ -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<string, unknown>;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
interface WorkersPanelProps {
|
||||
pushToast: ReturnType<typeof useToast>['push'];
|
||||
}
|
||||
|
||||
const WorkersPanel: React.FC<WorkersPanelProps> = ({ pushToast }) => {
|
||||
const [workers, setWorkers] = useState<WorkerStatusRow[] | null>(null);
|
||||
const [busConnected, setBusConnected] = useState<boolean | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [stopping, setStopping] = useState<Record<string, boolean>>({});
|
||||
const [starting, setStarting] = useState<Record<string, boolean>>({});
|
||||
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<number | null>(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 (
|
||||
<div className="config-panel">
|
||||
<div style={{ padding: '20px', opacity: 0.7 }}>
|
||||
<AlertTriangle size={14} style={{ marginRight: 8, verticalAlign: 'middle' }} />
|
||||
{err}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (workers === null) {
|
||||
return (
|
||||
<div className="config-panel">
|
||||
<div style={{ padding: '20px', opacity: 0.5 }}>LOADING…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const busOffline = busConnected === false;
|
||||
|
||||
return (
|
||||
<div className="config-panel">
|
||||
{busOffline && (
|
||||
<div
|
||||
style={{
|
||||
margin: '16px 20px 0',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid #ffaa00',
|
||||
background: 'rgba(255, 170, 0, 0.08)',
|
||||
color: '#ffaa00',
|
||||
fontSize: '0.72rem',
|
||||
letterSpacing: 1,
|
||||
lineHeight: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 700 }}>BUS OFFLINE — heartbeats cannot be received.</div>
|
||||
<div style={{ opacity: 0.85, marginTop: 2 }}>
|
||||
Start with <code>decnet bus</code> (restart the API if it was up first).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px 8px',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '1.5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: 0.6 }}>
|
||||
HEARTBEATS EVERY 30s · <span style={{ color: 'var(--matrix)' }}>OK</span> < 90s · STALE AFTER
|
||||
{lastRefresh != null && (
|
||||
<span style={{ marginLeft: 10, opacity: 0.7 }}>
|
||||
· REFRESHED {new Date(lastRefresh).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
className="action-btn"
|
||||
disabled={startingAll}
|
||||
onClick={handleStartAll}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.68rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
cursor: startingAll ? 'wait' : 'pointer',
|
||||
opacity: startingAll ? 0.6 : 1,
|
||||
}}
|
||||
title="Start every installed worker unit via systemd (best-effort)"
|
||||
>
|
||||
<Play size={11} />
|
||||
{startingAll ? 'STARTING…' : 'START ALL WORKERS'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.68rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
cursor: refreshing ? 'wait' : 'pointer',
|
||||
opacity: refreshing ? 0.6 : 1,
|
||||
}}
|
||||
title="Fetch current worker status"
|
||||
>
|
||||
<RefreshCw
|
||||
size={11}
|
||||
style={{
|
||||
animation: refreshing ? 'spin 0.8s linear infinite' : undefined,
|
||||
}}
|
||||
/>
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="logs-table" style={{ margin: 0, opacity: busOffline ? 0.45 : 1 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}></th>
|
||||
<th>NAME</th>
|
||||
<th>STATUS</th>
|
||||
<th>LAST SEEN</th>
|
||||
<th style={{ textAlign: 'right' }}>ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map((w) => {
|
||||
const isStopping = !!stopping[w.name];
|
||||
const canStop = w.status === 'ok' && !isStopping && !busOffline;
|
||||
return (
|
||||
<tr key={w.name}>
|
||||
<td><span className={dotClass(w.status)} /></td>
|
||||
<td style={{ fontWeight: 700, letterSpacing: 1 }}>{w.name.toUpperCase()}</td>
|
||||
<td style={{
|
||||
color: w.status === 'ok' ? 'var(--matrix)'
|
||||
: w.status === 'stale' ? '#ffaa00'
|
||||
: 'rgba(255,255,255,0.4)',
|
||||
letterSpacing: 1,
|
||||
}}>
|
||||
{w.status.toUpperCase()}
|
||||
</td>
|
||||
<td style={{ fontVariantNumeric: 'tabular-nums' }}>{formatLastSeen(w)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button
|
||||
className="action-btn"
|
||||
disabled={!canStop}
|
||||
onClick={() => handleStop(w.name)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.68rem',
|
||||
marginRight: 6,
|
||||
minWidth: 78,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
color: canStop ? '#ff4d4d' : '#ff4d4d',
|
||||
borderColor: canStop ? '#ff4d4d' : 'rgba(255, 77, 77, 0.4)',
|
||||
opacity: canStop ? 1 : 0.3,
|
||||
cursor: canStop ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title={
|
||||
busOffline
|
||||
? 'Bus offline — stop requests cannot be delivered'
|
||||
: canStop
|
||||
? 'Publish stop intent on the bus'
|
||||
: 'Only OK workers can be stopped'
|
||||
}
|
||||
>
|
||||
<Square size={11} />
|
||||
{isStopping ? '...' : 'STOP'}
|
||||
</button>
|
||||
{(() => {
|
||||
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 (
|
||||
<button
|
||||
className="action-btn"
|
||||
disabled={!canStart}
|
||||
onClick={() => handleStart(w.name)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.68rem',
|
||||
minWidth: 78,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
opacity: canStart ? 1 : 0.3,
|
||||
cursor: canStart ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
title={tooltip}
|
||||
>
|
||||
<Play size={11} />
|
||||
{isStarting ? '...' : 'START'}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Config;
|
||||
|
||||
Reference in New Issue
Block a user