feat(web-ui): Remote Updates dashboard page — push code to workers from the UI
React component for /swarm-updates: per-host table polled every 10s, row actions for Push Update / Update Updater / Rollback, a fleet-wide 'Push to All' modal with the include_self toggle, and toast feedback per result. Admin-only (both server-gated and UI-gated). Unreachable hosts surface as an explicit state; actions are disabled on them. Rollback is disabled when the worker has no previous release slot (previous_sha null from /hosts).
This commit is contained in:
@@ -9,6 +9,7 @@ import Attackers from './components/Attackers';
|
|||||||
import AttackerDetail from './components/AttackerDetail';
|
import AttackerDetail from './components/AttackerDetail';
|
||||||
import Config from './components/Config';
|
import Config from './components/Config';
|
||||||
import Bounty from './components/Bounty';
|
import Bounty from './components/Bounty';
|
||||||
|
import RemoteUpdates from './components/RemoteUpdates';
|
||||||
|
|
||||||
function isTokenValid(token: string): boolean {
|
function isTokenValid(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +65,7 @@ function App() {
|
|||||||
<Route path="/attackers" element={<Attackers />} />
|
<Route path="/attackers" element={<Attackers />} />
|
||||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
|
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive } from 'lucide-react';
|
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package } from 'lucide-react';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
@@ -46,6 +46,7 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
|
|||||||
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
|
||||||
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
|
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
|
||||||
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
|
||||||
|
<NavItem to="/swarm-updates" icon={<Package size={20} />} label="Remote Updates" open={sidebarOpen} />
|
||||||
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
330
decnet_web/src/components/RemoteUpdates.tsx
Normal file
330
decnet_web/src/components/RemoteUpdates.tsx
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import './Dashboard.css';
|
||||||
|
import {
|
||||||
|
Upload, RefreshCw, RotateCcw, Package, AlertTriangle, CheckCircle,
|
||||||
|
Wifi, WifiOff, Server,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface HostRelease {
|
||||||
|
host_uuid: string;
|
||||||
|
host_name: string;
|
||||||
|
address: string;
|
||||||
|
reachable: boolean;
|
||||||
|
agent_status?: string | null;
|
||||||
|
current_sha?: string | null;
|
||||||
|
previous_sha?: string | null;
|
||||||
|
releases: Array<Record<string, any>>;
|
||||||
|
detail?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushResult {
|
||||||
|
host_uuid: string;
|
||||||
|
host_name: string;
|
||||||
|
status: 'updated' | 'rolled-back' | 'failed' | 'self-updated' | 'self-failed';
|
||||||
|
http_status?: number | null;
|
||||||
|
sha?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
stderr?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
kind: 'success' | 'warn' | 'error';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortSha = (s: string | null | undefined): string => (s ? s.slice(0, 7) : '—');
|
||||||
|
|
||||||
|
const RemoteUpdates: React.FC = () => {
|
||||||
|
const [hosts, setHosts] = useState<HostRelease[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [busyRow, setBusyRow] = useState<string | null>(null);
|
||||||
|
const [fleetBusy, setFleetBusy] = useState(false);
|
||||||
|
const [showFleetModal, setShowFleetModal] = useState(false);
|
||||||
|
const [includeSelf, setIncludeSelf] = useState(false);
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const pushToast = (kind: Toast['kind'], text: string) => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setToasts((t) => [...t, { id, kind, text }]);
|
||||||
|
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 7000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHosts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/swarm-updates/hosts');
|
||||||
|
setHosts(res.data.hosts || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status !== 403) console.error('Failed to fetch host releases', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRole = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/config');
|
||||||
|
setIsAdmin(res.data.role === 'admin');
|
||||||
|
} catch {
|
||||||
|
setIsAdmin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRole();
|
||||||
|
fetchHosts();
|
||||||
|
const interval = setInterval(fetchHosts, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const describeResult = (r: PushResult): Toast => {
|
||||||
|
const sha = shortSha(r.sha);
|
||||||
|
switch (r.status) {
|
||||||
|
case 'updated':
|
||||||
|
return { id: 0, kind: 'success', text: `${r.host_name} → updated (sha ${sha})` };
|
||||||
|
case 'self-updated':
|
||||||
|
return { id: 0, kind: 'success', text: `${r.host_name} → updater upgraded (sha ${sha})` };
|
||||||
|
case 'rolled-back':
|
||||||
|
return { id: 0, kind: 'warn', text: `${r.host_name} → rolled back: ${r.detail || r.stderr || 'probe failed'}` };
|
||||||
|
case 'failed':
|
||||||
|
return { id: 0, kind: 'error', text: `${r.host_name} → failed: ${r.detail || 'transport error'}` };
|
||||||
|
case 'self-failed':
|
||||||
|
return { id: 0, kind: 'error', text: `${r.host_name} → updater push failed: ${r.detail || 'unknown'}` };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePush = async (host: HostRelease, kind: 'agent' | 'self') => {
|
||||||
|
setBusyRow(host.host_uuid);
|
||||||
|
const endpoint = kind === 'agent' ? '/swarm-updates/push' : '/swarm-updates/push-self';
|
||||||
|
try {
|
||||||
|
const res = await api.post(endpoint, { host_uuids: [host.host_uuid] }, { timeout: 240000 });
|
||||||
|
(res.data.results as PushResult[]).forEach((r) => {
|
||||||
|
const t = describeResult(r);
|
||||||
|
pushToast(t.kind, t.text);
|
||||||
|
});
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
pushToast('error', `${host.host_name} → request failed: ${err.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setBusyRow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = async (host: HostRelease) => {
|
||||||
|
if (!window.confirm(`Roll back ${host.host_name} to its previous release?`)) return;
|
||||||
|
setBusyRow(host.host_uuid);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/swarm-updates/rollback', { host_uuid: host.host_uuid }, { timeout: 60000 });
|
||||||
|
const r = res.data as PushResult & { status: 'rolled-back' | 'failed' };
|
||||||
|
if (r.status === 'rolled-back') {
|
||||||
|
pushToast('success', `${host.host_name} → rolled back`);
|
||||||
|
} else {
|
||||||
|
pushToast('error', `${host.host_name} → rollback failed: ${r.detail || 'unknown'}`);
|
||||||
|
}
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
pushToast('error', `${host.host_name} → rollback failed: ${err.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setBusyRow(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFleetPush = async () => {
|
||||||
|
setFleetBusy(true);
|
||||||
|
setShowFleetModal(false);
|
||||||
|
try {
|
||||||
|
const res = await api.post(
|
||||||
|
'/swarm-updates/push',
|
||||||
|
{ all: true, include_self: includeSelf },
|
||||||
|
{ timeout: 600000 },
|
||||||
|
);
|
||||||
|
(res.data.results as PushResult[]).forEach((r) => {
|
||||||
|
const t = describeResult(r);
|
||||||
|
pushToast(t.kind, t.text);
|
||||||
|
});
|
||||||
|
await fetchHosts();
|
||||||
|
} catch (err: any) {
|
||||||
|
pushToast('error', `Fleet push failed: ${err.response?.data?.detail || err.message}`);
|
||||||
|
} finally {
|
||||||
|
setFleetBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="loader">QUERYING WORKER UPDATER FLEET...</div>;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div style={{ padding: '24px', color: 'var(--dim-color)' }}>
|
||||||
|
<AlertTriangle size={20} style={{ verticalAlign: 'middle' }} /> Admin role required for Remote Updates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div
|
||||||
|
className="section-header"
|
||||||
|
style={{
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
border: '1px solid var(--border-color)', backgroundColor: 'var(--secondary-color)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<Package size={20} />
|
||||||
|
<h2 style={{ margin: 0 }}>REMOTE UPDATES — WORKER FLEET</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFleetModal(true)}
|
||||||
|
disabled={fleetBusy || hosts.length === 0}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
border: '1px solid var(--accent-color)', color: 'var(--accent-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fleetBusy ? <RefreshCw size={14} className="spin" /> : <Upload size={14} />}
|
||||||
|
{fleetBusy ? 'PUSHING...' : 'PUSH TO ALL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFleetModal && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '24px', padding: '24px',
|
||||||
|
backgroundColor: 'var(--secondary-color)', border: '1px solid var(--accent-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Push current tree to every enrolled worker</h3>
|
||||||
|
<p style={{ color: 'var(--dim-color)', fontSize: '0.85rem' }}>
|
||||||
|
A tarball of the master's working tree will be uploaded to each worker's updater,
|
||||||
|
installed, and the agent will be restarted. Failed probes auto-roll-back.
|
||||||
|
</p>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', margin: '16px 0' }}>
|
||||||
|
<input type="checkbox" checked={includeSelf} onChange={(e) => setIncludeSelf(e.target.checked)} />
|
||||||
|
Also upgrade the updater itself (<code>--include-self</code>)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||||
|
<button onClick={() => setShowFleetModal(false)} style={{ border: '1px solid var(--border-color)', color: 'var(--dim-color)' }}>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFleetPush}
|
||||||
|
style={{ background: 'var(--accent-color)', color: '#000', border: 'none' }}
|
||||||
|
>
|
||||||
|
CONFIRM FLEET PUSH
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hosts.length === 0 ? (
|
||||||
|
<div style={{ padding: '24px', color: 'var(--dim-color)' }}>
|
||||||
|
<Server size={16} style={{ verticalAlign: 'middle', marginRight: '8px' }} />
|
||||||
|
No workers with an updater bundle are enrolled. Run{' '}
|
||||||
|
<code>decnet swarm enroll --host <name> --updater</code> to add one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '16px' }}>
|
||||||
|
{hosts.map((h) => {
|
||||||
|
const busy = busyRow === h.host_uuid;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={h.host_uuid}
|
||||||
|
className="stat-card"
|
||||||
|
style={{
|
||||||
|
flexDirection: 'column', alignItems: 'stretch',
|
||||||
|
padding: '20px', gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-color)', paddingBottom: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{h.reachable ? <Wifi size={16} style={{ color: 'var(--accent-color)' }} />
|
||||||
|
: <WifiOff size={16} style={{ color: 'var(--danger-color, #f88)' }} />}
|
||||||
|
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>{h.host_name}</span>
|
||||||
|
<span className="dim" style={{ fontSize: '0.8rem' }}>{h.address}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePush(h, 'agent')}
|
||||||
|
disabled={busy || !h.reachable}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
|
||||||
|
>
|
||||||
|
{busy ? <RefreshCw size={12} className="spin" /> : <Upload size={12} />}
|
||||||
|
PUSH
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePush(h, 'self')}
|
||||||
|
disabled={busy || !h.reachable}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--highlight-color)', color: 'var(--highlight-color)' }}
|
||||||
|
>
|
||||||
|
{busy ? <RefreshCw size={12} className="spin" /> : <Package size={12} />}
|
||||||
|
UPDATER
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRollback(h)}
|
||||||
|
disabled={busy || !h.reachable || !h.previous_sha}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)', color: 'var(--dim-color)' }}
|
||||||
|
title={h.previous_sha ? 'Roll back to previous release' : 'No previous release on worker'}
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} /> ROLLBACK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{h.reachable ? (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '12px' }}>
|
||||||
|
<Info label="CURRENT" value={shortSha(h.current_sha)} tone="accent" />
|
||||||
|
<Info label="PREVIOUS" value={shortSha(h.previous_sha)} tone="dim" />
|
||||||
|
<Info label="AGENT" value={h.agent_status || 'unknown'} tone={h.agent_status === 'ok' ? 'accent' : 'dim'} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--dim-color)', fontSize: '0.85rem' }}>
|
||||||
|
UNREACHABLE — {h.detail || 'no response'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: 'fixed', bottom: '24px', right: '24px', display: 'flex', flexDirection: 'column', gap: '8px', zIndex: 1000 }}>
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: 'var(--secondary-color)',
|
||||||
|
border: `1px solid ${t.kind === 'success' ? 'var(--accent-color)' : t.kind === 'warn' ? 'var(--highlight-color)' : 'var(--danger-color, #f88)'}`,
|
||||||
|
color: t.kind === 'success' ? 'var(--accent-color)' : t.kind === 'warn' ? 'var(--highlight-color)' : 'var(--danger-color, #f88)',
|
||||||
|
fontSize: '0.85rem', maxWidth: '420px', boxShadow: '0 2px 8px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.kind === 'success' ? <CheckCircle size={14} /> : <AlertTriangle size={14} />}
|
||||||
|
{t.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Info: React.FC<{ label: string; value: string; tone: 'accent' | 'dim' }> = ({ label, value, tone }) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<span className="dim" style={{ fontSize: '0.75rem' }}>{label}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: tone === 'accent' ? 'var(--accent-color)' : 'var(--text-color)',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RemoteUpdates;
|
||||||
Reference in New Issue
Block a user