merge testing->tomerge/main #7
@@ -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() {
|
||||
<Route path="/attackers" element={<Attackers />} />
|
||||
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/swarm-updates" element={<RemoteUpdates />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -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<LayoutProps> = ({ children, onLogout, onSearch }) => {
|
||||
<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="/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} />
|
||||
</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