From 7894b9e073d584ca12d9edfdefd930483c246db4 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 01:03:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(web-ui):=20Remote=20Updates=20dashboard=20?= =?UTF-8?q?page=20=E2=80=94=20push=20code=20to=20workers=20from=20the=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- decnet_web/src/App.tsx | 2 + decnet_web/src/components/Layout.tsx | 3 +- decnet_web/src/components/RemoteUpdates.tsx | 330 ++++++++++++++++++++ 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 decnet_web/src/components/RemoteUpdates.tsx diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 937ce94..54a21e3 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -9,6 +9,7 @@ import Attackers from './components/Attackers'; import AttackerDetail from './components/AttackerDetail'; import Config from './components/Config'; import Bounty from './components/Bounty'; +import RemoteUpdates from './components/RemoteUpdates'; function isTokenValid(token: string): boolean { try { @@ -64,6 +65,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 20aa850..2df1451 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; 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'; interface LayoutProps { @@ -46,6 +46,7 @@ const Layout: React.FC = ({ children, onLogout, onSearch }) => { } label="Live Logs" open={sidebarOpen} /> } label="Bounty" open={sidebarOpen} /> } label="Attackers" open={sidebarOpen} /> + } label="Remote Updates" open={sidebarOpen} /> } label="Config" open={sidebarOpen} /> diff --git a/decnet_web/src/components/RemoteUpdates.tsx b/decnet_web/src/components/RemoteUpdates.tsx new file mode 100644 index 0000000..7f692f9 --- /dev/null +++ b/decnet_web/src/components/RemoteUpdates.tsx @@ -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>; + 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([]); + const [loading, setLoading] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + const [busyRow, setBusyRow] = useState(null); + const [fleetBusy, setFleetBusy] = useState(false); + const [showFleetModal, setShowFleetModal] = useState(false); + const [includeSelf, setIncludeSelf] = useState(false); + const [toasts, setToasts] = useState([]); + + 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
QUERYING WORKER UPDATER FLEET...
; + + if (!isAdmin) { + return ( +
+
+ Admin role required for Remote Updates. +
+
+ ); + } + + return ( +
+
+
+ +

REMOTE UPDATES — WORKER FLEET

+
+ +
+ + {showFleetModal && ( +
+

Push current tree to every enrolled worker

+

+ 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. +

+ +
+ + +
+
+ )} + + {hosts.length === 0 ? ( +
+ + No workers with an updater bundle are enrolled. Run{' '} + decnet swarm enroll --host <name> --updater to add one. +
+ ) : ( +
+ {hosts.map((h) => { + const busy = busyRow === h.host_uuid; + return ( +
+
+
+ {h.reachable ? + : } + {h.host_name} + {h.address} +
+
+ + + +
+
+ {h.reachable ? ( +
+ + + +
+ ) : ( +
+ UNREACHABLE — {h.detail || 'no response'} +
+ )} +
+ ); + })} +
+ )} + +
+ {toasts.map((t) => ( +
+ {t.kind === 'success' ? : } + {t.text} +
+ ))} +
+
+ ); +}; + +const Info: React.FC<{ label: string; value: string; tone: 'accent' | 'dim' }> = ({ label, value, tone }) => ( +
+ {label} + + {value} + +
+); + +export default RemoteUpdates;