merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

View File

@@ -8,6 +8,7 @@
"name": "decnet_web",
"version": "0.0.0",
"dependencies": {
"asciinema-player": "^3.8.0",
"axios": "^1.14.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
@@ -222,6 +223,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -892,6 +902,36 @@
"dev": true,
"license": "MIT"
},
"node_modules/@solid-primitives/refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@solid-primitives/refs/-/refs-1.1.3.tgz",
"integrity": "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==",
"license": "MIT",
"dependencies": {
"@solid-primitives/utils": "^6.4.0"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@solid-primitives/transition-group": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@solid-primitives/transition-group/-/transition-group-1.1.2.tgz",
"integrity": "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@solid-primitives/utils": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@solid-primitives/utils/-/utils-6.4.0.tgz",
"integrity": "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1412,6 +1452,17 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asciinema-player": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.15.1.tgz",
"integrity": "sha512-agVYeNlPxthLyAb92l9AS7ypW0uhesqOuQzyR58Q4Sj+MvesQztZBgx86lHqNJkB8rQ6EP0LeA9czGytQUBpYw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.21.0",
"solid-js": "^1.3.0",
"solid-transition-group": "^0.2.3"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1642,7 +1693,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -3337,6 +3387,27 @@
"semver": "bin/semver.js"
}
},
"node_modules/seroval": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz",
"integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/seroval-plugins": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz",
"integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3366,6 +3437,34 @@
"node": ">=8"
}
},
"node_modules/solid-js": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
"integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.5.0",
"seroval-plugins": "~1.5.0"
}
},
"node_modules/solid-transition-group": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/solid-transition-group/-/solid-transition-group-0.2.3.tgz",
"integrity": "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg==",
"license": "MIT",
"dependencies": {
"@solid-primitives/refs": "^1.0.5",
"@solid-primitives/transition-group": "^1.0.2"
},
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.6.0"
},
"peerDependencies": {
"solid-js": "^1.6.12"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"asciinema-player": "^3.8.0",
"axios": "^1.14.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",

View File

@@ -1,37 +1,184 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { lazy, Suspense, useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
import Login from './components/Login';
import Layout from './components/Layout';
import Dashboard from './components/Dashboard';
import DeckyFleet from './components/DeckyFleet';
import LiveLogs from './components/LiveLogs';
import Attackers from './components/Attackers';
import Config from './components/Config';
import Bounty from './components/Bounty';
import CommandPalette from './components/CommandPalette/CommandPalette';
import ShortcutsHelp from './components/ShortcutsHelp/ShortcutsHelp';
import { ToastProvider } from './components/Toasts/ToastProvider';
import { useToast } from './components/Toasts/useToast';
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
// Page components are code-split per route. Each lands as its own
// chunk and only downloads when the user navigates to that path —
// initial page-load stays slim. Dashboard stays eager because it's
// the landing page: lazy-loading it would Suspense-flicker on every
// login for zero gain.
const DeckyFleet = lazy(() => import('./components/DeckyFleet'));
const LiveLogs = lazy(() => import('./components/LiveLogs'));
const Webhooks = lazy(() => import('./components/Webhooks'));
const Attackers = lazy(() => import('./components/Attackers'));
const AttackerDetail = lazy(() => import('./components/AttackerDetail'));
const Identities = lazy(() => import('./components/Identities'));
const IdentityDetail = lazy(() => import('./components/IdentityDetail'));
const Campaigns = lazy(() => import('./components/Campaigns'));
const CampaignDetail = lazy(() => import('./components/CampaignDetail'));
const Orchestrator = lazy(() => import('./components/Orchestrator'));
const PersonaGeneration = lazy(() => import('./components/PersonaGeneration'));
const SyntheticFiles = lazy(() => import('./components/SyntheticFiles/SyntheticFiles'));
const RealismConfig = lazy(() => import('./components/RealismConfig/RealismConfig'));
const CanaryTokens = lazy(() => import('./components/CanaryTokens'));
const TopologyPersonaGeneration = lazy(() =>
import('./components/PersonaGeneration').then((m) => ({ default: m.TopologyPersonaGeneration })),
);
const Config = lazy(() => import('./components/Config'));
const Bounty = lazy(() => import('./components/Bounty'));
const Credentials = lazy(() => import('./components/Credentials'));
const RemoteUpdates = lazy(() => import('./components/RemoteUpdates'));
const SwarmHosts = lazy(() => import('./components/SwarmHosts'));
const MazeNET = lazy(() => import('./components/MazeNET/MazeNET'));
const TopologyList = lazy(() => import('./components/TopologyList/TopologyList'));
/* Minimal fallback rendered while a lazy-loaded route chunk is in
* flight. Matches the house "dim mono" voice — no spinner library,
* no new CSS. Visible for a few frames on first navigation to a
* route; cached thereafter. */
const RouteFallback: React.FC = () => (
<div
style={{
padding: '48px',
textAlign: 'center',
opacity: 0.5,
fontSize: '0.82rem',
letterSpacing: '1.5px',
fontFamily: 'var(--font-mono)',
}}
>
LOADING
</div>
);
/* Unified MazeNET entrypoint: no ?topology → topology selector,
* ?topology=<id> → editor bound to that topology. */
function MazeNETRoute() {
const qs = typeof window !== 'undefined' ? window.location.search : '';
const hasId = new URLSearchParams(qs).get('topology');
return hasId ? <MazeNET /> : <TopologyList />;
}
function isTokenValid(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return typeof payload.exp === 'number' && payload.exp * 1000 > Date.now();
} catch {
return false;
}
}
function getValidToken(): string | null {
const stored = localStorage.getItem('token');
if (stored && isTokenValid(stored)) return stored;
if (stored) localStorage.removeItem('token');
return null;
}
const ACTION_LABELS: Record<string, string> = {
'deploy': 'DEPLOY · OPENING WIZARD',
'pause-logs': 'STREAM · TOGGLE QUEUED',
'mutate-all': 'MUTATE ALL · QUEUED',
'export-bounty': 'EXPORT BOUNTY · QUEUED',
};
interface AuthedShellProps {
onLogout: () => void;
onSearch: (q: string) => void;
searchQuery: string;
}
const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQuery }) => {
const navigate = useNavigate();
const { push } = useToast();
const [cmdOpen, setCmdOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen });
const handleAction = (id: string) => {
if (id === 'shortcuts-help') { setHelpOpen(true); return; }
if (id === 'deploy') navigate('/fleet');
window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id } }));
push({ text: ACTION_LABELS[id] ?? `${id.toUpperCase()} · QUEUED`, tone: 'violet', icon: 'terminal' });
};
return (
<>
<Layout onLogout={onLogout} onSearch={onSearch} onOpenCmd={() => setCmdOpen(true)}>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
<Route path="/fleet" element={<DeckyFleet searchQuery={searchQuery} />} />
<Route path="/topologies" element={<Navigate to="/mazenet" replace />} />
<Route path="/mazenet" element={<MazeNETRoute />} />
<Route path="/live-logs" element={<LiveLogs />} />
<Route path="/webhooks" element={<Webhooks />} />
<Route path="/bounty" element={<Bounty />} />
<Route path="/credentials" element={<Credentials />} />
<Route path="/attackers" element={<Attackers />} />
<Route path="/attackers/:id" element={<AttackerDetail />} />
<Route path="/identities" element={<Identities />} />
<Route path="/identities/:id" element={<IdentityDetail />} />
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/campaigns/:id" element={<CampaignDetail />} />
<Route path="/orchestrator" element={<Orchestrator />} />
<Route path="/persona-generation" element={<PersonaGeneration />} />
<Route path="/synthetic-files" element={<SyntheticFiles />} />
<Route path="/realism-config" element={<RealismConfig />} />
<Route path="/canary-tokens" element={<CanaryTokens />} />
<Route path="/topologies/:id/personas" element={<TopologyPersonaGeneration />} />
<Route path="/config" element={<Config />} />
<Route path="/swarm-updates" element={<RemoteUpdates />} />
<Route path="/swarm/hosts" element={<SwarmHosts />} />
<Route path="/swarm/enroll" element={<Navigate to="/swarm/hosts" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</Layout>
<CommandPalette
open={cmdOpen}
onClose={() => setCmdOpen(false)}
onNav={navigate}
onAction={handleAction}
/>
<ShortcutsHelp open={helpOpen} onClose={() => setHelpOpen(false)} />
</>
);
};
function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [token, setToken] = useState<string | null>(getValidToken);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
const savedToken = localStorage.getItem('token');
if (savedToken) {
setToken(savedToken);
}
const onAuthLogout = () => setToken(null);
window.addEventListener('auth:logout', onAuthLogout);
return () => window.removeEventListener('auth:logout', onAuthLogout);
}, []);
const handleLogin = (newToken: string) => {
setToken(newToken);
};
useEffect(() => {
let accent = 'matrix';
try {
const raw = localStorage.getItem('decnet_tweaks');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed?.accent === 'matrix' || parsed?.accent === 'violet') accent = parsed.accent;
}
} catch { /* fall through to default */ }
document.documentElement.setAttribute('data-accent', accent);
}, []);
const handleLogout = () => {
localStorage.removeItem('token');
setToken(null);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleLogin = (newToken: string) => setToken(newToken);
const handleLogout = () => { localStorage.removeItem('token'); setToken(null); };
const handleSearch = (query: string) => setSearchQuery(query);
if (!token) {
return <Login onLogin={handleLogin} />;
@@ -39,17 +186,9 @@ function App() {
return (
<Router>
<Layout onLogout={handleLogout} onSearch={handleSearch}>
<Routes>
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
<Route path="/fleet" element={<DeckyFleet />} />
<Route path="/live-logs" element={<LiveLogs />} />
<Route path="/bounty" element={<Bounty />} />
<Route path="/attackers" element={<Attackers />} />
<Route path="/config" element={<Config />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
<ToastProvider>
<AuthedShell onLogout={handleLogout} onSearch={handleSearch} searchQuery={searchQuery} />
</ToastProvider>
</Router>
);
}

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Download, AlertTriangle } from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
interface ArtifactDrawerProps {
decky: string;
storedAs: string;
fields: Record<string, any>;
onClose: () => void;
}
// Bulky nested structures are shipped as one base64-encoded JSON blob in
// `meta_json_b64` (see templates/ssh/emit_capture.py). All summary fields
// arrive as top-level SD params already present in `fields`.
function decodeMeta(fields: Record<string, any>): Record<string, any> | null {
const b64 = fields.meta_json_b64;
if (typeof b64 !== 'string' || !b64) return null;
try {
const json = atob(b64);
return JSON.parse(json);
} catch (err) {
console.error('artifact: failed to decode meta_json_b64', err);
return null;
}
}
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}></span>}</div>
</div>
);
const ArtifactDrawer: React.FC<ArtifactDrawerProps> = ({ decky, storedAs, fields, onClose }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const meta = decodeMeta(fields);
const handleDownload = async () => {
setDownloading(true);
setError(null);
try {
const res = await api.get(
`/artifacts/${encodeURIComponent(decky)}/${encodeURIComponent(storedAs)}`,
{ responseType: 'blob' },
);
const blobUrl = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = blobUrl;
a.download = storedAs;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to download artifacts.' :
status === 404 ? 'Artifact not found on disk (may have been purged).' :
status === 400 ? 'Server rejected the request (invalid parameters).' :
'Download failed — see console.'
);
console.error('artifact download failed', err);
} finally {
setDownloading(false);
}
};
const concurrent = meta?.concurrent_sessions;
const ssSnapshot = meta?.ss_snapshot;
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'flex-end',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(620px, 100%)', height: '100%',
backgroundColor: 'var(--bg-color, #0d1117)',
borderLeft: '1px solid var(--border-color, #30363d)',
padding: '24px', overflowY: 'auto',
color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
CAPTURED ARTIFACT · {decky}
</div>
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', wordBreak: 'break-all' }}>
{storedAs}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: '1px solid rgba(255, 170, 0, 0.3)',
backgroundColor: 'rgba(255, 170, 0, 0.05)',
fontSize: '0.75rem', color: '#ffaa00',
}}>
<AlertTriangle size={14} />
Attacker-controlled content. Download at your own risk.
</div>
<button
onClick={handleDownload}
disabled={downloading}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px', marginBottom: '20px',
border: '1px solid var(--text-color)',
background: 'transparent', color: 'var(--text-color)',
cursor: downloading ? 'wait' : 'pointer',
opacity: downloading ? 0.5 : 1,
}}
>
<Download size={14} /> {downloading ? 'DOWNLOADING…' : 'DOWNLOAD RAW'}
</button>
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
)}
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
ORIGIN
</h3>
<Row label="Orig path" value={fields.orig_path} />
<Row label="SHA-256" value={fields.sha256} />
<Row label="Size" value={fields.size ? `${fields.size} bytes` : null} />
<Row label="Mtime" value={fields.mtime} />
</section>
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
ATTRIBUTION · {fields.attribution ?? 'unknown'}
</h3>
<Row label="SSH user" value={fields.ssh_user} />
<Row label="Src IP" value={fields.src_ip} />
<Row label="Src port" value={fields.src_port} />
<Row label="SSH pid" value={fields.ssh_pid} />
<Row label="Writer pid" value={fields.writer_pid} />
<Row label="Writer comm" value={fields.writer_comm} />
<Row label="Writer uid" value={fields.writer_uid} />
<Row label="Writer cmdline" value={meta?.writer_cmdline} />
<Row label="Writer loginuid" value={meta?.writer_loginuid} />
</section>
{Array.isArray(concurrent) && concurrent.length > 0 && (
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
CONCURRENT SESSIONS ({concurrent.length})
</h3>
<pre style={{ fontSize: '0.75rem', background: 'rgba(255,255,255,0.03)', padding: '8px', overflowX: 'auto' }}>
{JSON.stringify(concurrent, null, 2)}
</pre>
</section>
)}
{Array.isArray(ssSnapshot) && ssSnapshot.length > 0 && (
<section>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
SS SNAPSHOT ({ssSnapshot.length})
</h3>
<pre style={{ fontSize: '0.75rem', background: 'rgba(255,255,255,0.03)', padding: '8px', overflowX: 'auto' }}>
{JSON.stringify(ssSnapshot, null, 2)}
</pre>
</section>
)}
</div>
</div>
);
};
export default ArtifactDrawer;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
.attackers-root {
display: flex;
flex-direction: column;
gap: 20px;
}
.attackers-root .controls-row {
display: flex;
gap: 12px;
align-items: stretch;
}
.attackers-root .controls-row .search-container { flex: 1; max-width: none; }
.attackers-root .sort-select {
background: var(--panel);
border: 1px solid var(--border);
color: var(--matrix);
padding: 8px 14px;
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 2px;
cursor: pointer;
}
.attackers-root .sort-select:focus { outline: none; border-color: var(--accent); }
.attackers-root .service-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
padding: 2px 10px;
letter-spacing: 1px;
border: 1px solid var(--violet);
color: var(--violet);
background: var(--violet-tint-10);
cursor: pointer;
}
/* Card grid — scoped to avoid colliding with global .attacker-card */
.attackers-root .ak-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 14px;
padding: 16px;
}
.attackers-root .ak-card {
background: var(--panel);
border: 1px solid var(--border);
padding: 16px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
display: flex;
flex-direction: column;
gap: 10px;
}
.attackers-root .ak-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.attackers-root .ak-card .ak-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.attackers-root .ak-card .ak-ip {
font-size: 1.05rem;
font-weight: 700;
color: var(--matrix);
font-variant-numeric: tabular-nums;
letter-spacing: 1px;
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.attackers-root .ak-card .ak-cc {
font-size: 0.62rem;
letter-spacing: 2px;
padding: 1px 6px;
border: 1px solid var(--border);
color: var(--matrix);
opacity: 0.75;
font-weight: 600;
}
.attackers-root .ak-meta {
display: flex;
gap: 14px;
font-size: 0.72rem;
opacity: 0.7;
}
.attackers-root .ak-card .ak-asn {
font-size: 0.62rem;
letter-spacing: 1px;
padding: 1px 6px;
border: 1px solid var(--border);
color: var(--matrix);
opacity: 0.75;
font-weight: 600;
cursor: help;
}
.attackers-root .ak-stats {
display: flex;
gap: 14px;
font-size: 0.78rem;
}
.attackers-root .ak-stats .n { font-weight: 700; }
.attackers-root .ak-stats .n.violet { color: var(--violet); }
.attackers-root .ak-stats .n.matrix { color: var(--matrix); }
.attackers-root .ak-stats .lbl { opacity: 0.55; font-size: 0.65rem; margin-right: 4px; letter-spacing: 1px; }
.attackers-root .ak-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.attackers-root .ak-path {
font-size: 0.72rem;
opacity: 0.65;
word-break: break-all;
}
.attackers-root .ak-path .lbl { opacity: 0.5; margin-right: 6px; letter-spacing: 1px; font-size: 0.62rem; }
.attackers-root .ak-lastcmd {
font-size: 0.7rem;
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attackers-root .ak-lastcmd .cmd { color: var(--matrix); }
/* Activity chip */
.attackers-root .activity-chip {
font-size: 0.62rem;
letter-spacing: 1.5px;
padding: 2px 8px;
border-radius: 3px;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.attackers-root .activity-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.attackers-root .activity-chip.active {
border: 1px solid var(--matrix);
color: var(--matrix);
background: var(--matrix-tint-10);
}
.attackers-root .activity-chip.active .dot {
background: var(--matrix);
box-shadow: 0 0 6px var(--matrix);
animation: decnet-pulse 1s infinite alternate;
}
.attackers-root .activity-chip.passive {
border: 1px solid rgba(238, 130, 238, 0.5);
color: var(--violet);
background: var(--violet-tint-10);
}
.attackers-root .activity-chip.passive .dot { background: var(--violet); }
.attackers-root .activity-chip.inactive {
border: 1px solid var(--border);
color: rgba(0, 255, 65, 0.5);
background: transparent;
}
.attackers-root .activity-chip.inactive .dot { background: var(--border); }
/* Empty / loading */
.attackers-root .empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 50px 20px;
opacity: 0.45;
}
.attackers-root .empty-state .type-label { font-size: 0.7rem; letter-spacing: 2px; }
/* Pagination — same as Bounty */
.attackers-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; }
.attackers-root .pager button {
padding: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--matrix);
display: flex;
cursor: pointer;
}
.attackers-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.attackers-root .pager button:hover:not(:disabled) { border-color: var(--accent); }

View File

@@ -1,17 +1,271 @@
import React from 'react';
import { Activity } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Search, ChevronLeft, ChevronRight, Users } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import './Dashboard.css';
import './Attackers.css';
interface AttackerEntry {
uuid: string;
ip: string;
first_seen: string;
last_seen: string;
event_count: number;
service_count: number;
decky_count: number;
services: string[];
deckies: string[];
traversal_path: string | null;
is_traversal: boolean;
bounty_count: number;
credential_count: number;
fingerprints: any[];
commands: any[];
country_code: string | null;
country_source: string | null;
asn: number | null;
as_name: string | null;
asn_source: string | null;
updated_at: string;
}
// Activity thresholds — tune here to adjust tier resolution.
const ACTIVE_MIN_EVENTS = 50;
const ACTIVE_MAX_AGE_MIN = 60;
const PASSIVE_MIN_EVENTS = 5;
const PASSIVE_MAX_AGE_HR = 24;
type ActivityTier = 'active' | 'passive' | 'inactive';
function deriveActivity(a: AttackerEntry): ActivityTier {
const ageMin = (Date.now() - new Date(a.last_seen).getTime()) / 60000;
if (a.event_count >= ACTIVE_MIN_EVENTS && ageMin <= ACTIVE_MAX_AGE_MIN) return 'active';
if (a.event_count >= PASSIVE_MIN_EVENTS && ageMin <= PASSIVE_MAX_AGE_HR * 60) return 'passive';
return 'inactive';
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
const Attackers: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const sortBy = searchParams.get('sort_by') || 'recent';
const serviceFilter = searchParams.get('service') || '';
const page = parseInt(searchParams.get('page') || '1');
const [attackers, setAttackers] = useState<AttackerEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const limit = 50;
const fetchAttackers = async () => {
setLoading(true);
try {
const offset = (page - 1) * limit;
let url = `/attackers?limit=${limit}&offset=${offset}&sort_by=${sortBy}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
const res = await api.get(url);
setAttackers(res.data.data);
setTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch attackers', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchAttackers(); }, [query, sortBy, serviceFilter, page]);
useEffect(() => { setSearchInput(query); }, [query]);
const _params = (overrides: Record<string, string> = {}) => {
const base: Record<string, string> = { q: query, sort_by: sortBy, service: serviceFilter, page: '1' };
return Object.fromEntries(Object.entries({ ...base, ...overrides }).filter(([, v]) => v !== ''));
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams(_params({ q: searchInput }));
};
const setPage = (p: number) => setSearchParams(_params({ page: p.toString() }));
const setSort = (s: string) => setSearchParams(_params({ sort_by: s }));
const clearService = () => setSearchParams(_params({ service: '' }));
const totalPages = Math.max(1, Math.ceil(total / limit));
const activityCounts = attackers.reduce(
(acc, a) => { acc[deriveActivity(a)]++; return acc; },
{ active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>,
);
return (
<div className="logs-section">
<div className="section-header">
<Activity size={20} />
<h2>ATTACKER PROFILES</h2>
<div className="attackers-root">
<div className="page-header">
<div className="page-title-group">
<h1>ATTACKERS</h1>
<span className="page-sub">
{total.toLocaleString()} UNIQUE SOURCES · {activityCounts.active} ACTIVE · {activityCounts.passive} PASSIVE · {activityCounts.inactive} INACTIVE
</span>
</div>
</div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>NO ACTIVE THREATS PROFILED YET.</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Attackers view placeholder)</p>
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Search by IP..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<select className="sort-select" value={sortBy} onChange={(e) => setSort(e.target.value)}>
<option value="recent">RECENT</option>
<option value="active">MOST ACTIVE</option>
<option value="traversals">TRAVERSALS</option>
</select>
</form>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<span>SOURCE INTEL</span>
{serviceFilter && (
<button
type="button"
className="service-filter-chip"
onClick={clearService}
style={{ marginLeft: 12 }}
>
{serviceFilter.toUpperCase()} ×
</button>
)}
</div>
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
{loading ? (
<EmptyState icon={Users} title="SCANNING THREAT PROFILES…" />
) : attackers.length === 0 ? (
<EmptyState
icon={Users}
title="NO ACTIVE THREATS PROFILED YET"
hint="waiting on attacker traffic to correlate"
/>
) : (
<div className="ak-grid">
{attackers.map(a => {
const activity = deriveActivity(a);
const lastCmd = a.commands.length > 0 ? a.commands[a.commands.length - 1] : null;
return (
<div
key={a.uuid}
className="ak-card"
onClick={() => navigate(`/attackers/${a.uuid}`)}
>
<div className="ak-top">
<span className="ak-ip">
{a.ip}
{a.country_code && (
<span
className="ak-cc"
title={`Origin: ${a.country_code}${a.country_source ? ` (${a.country_source})` : ''}`}
>
{a.country_code}
</span>
)}
</span>
<span className={`activity-chip ${activity}`}>
<span className="dot" />
{activity.toUpperCase()}
</span>
</div>
<div className="ak-meta">
<span>First: {new Date(a.first_seen).toLocaleDateString()}</span>
<span>Last: {timeAgo(a.last_seen)}</span>
{a.asn != null && (
<span
className="ak-asn"
title={a.as_name ? `${a.as_name}${a.asn_source ? ` (${a.asn_source})` : ''}` : undefined}
>
AS{a.asn}
</span>
)}
{a.is_traversal && <span className="chip violet" style={{ fontSize: '0.6rem' }}>TRAVERSAL</span>}
</div>
<div className="ak-stats">
<span><span className="lbl">EVENTS</span><span className="n matrix">{a.event_count}</span></span>
<span><span className="lbl">BOUNTIES</span><span className="n violet">{a.bounty_count}</span></span>
<span><span className="lbl">CREDS</span><span className="n violet">{a.credential_count}</span></span>
</div>
{a.services.length > 0 && (
<div className="ak-chips">
{a.services.map(svc => (
<span
key={svc}
className="chip dim-chip"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setSearchParams(_params({ service: svc })); }}
>
{svc.toUpperCase()}
</span>
))}
</div>
)}
{a.traversal_path ? (
<div className="ak-path"><span className="lbl">PATH</span>{a.traversal_path}</div>
) : a.deckies.length > 0 ? (
<div className="ak-path"><span className="lbl">DECKIES</span>{a.deckies.join(', ')}</div>
) : null}
<div className="ak-stats">
<span><span className="lbl">CMDS</span><span className="n matrix">{a.commands.length}</span></span>
<span><span className="lbl">FPS</span><span className="n matrix">{a.fingerprints.length}</span></span>
</div>
{lastCmd && (
<div className="ak-lastcmd">
<span className="lbl" style={{ opacity: 0.5, marginRight: 6, fontSize: '0.62rem', letterSpacing: 1 }}>LAST</span>
<span className="cmd">{lastCmd.command}</span>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,238 @@
.bounty-root {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Buttons scoped under root (mirrors DeckyFleet/LiveLogs pattern) */
.bounty-root .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
}
.bounty-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.bounty-root .btn.violet { border-color: var(--violet); color: var(--violet); }
.bounty-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.bounty-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.bounty-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); }
.bounty-root .btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* Header controls */
.bounty-root .controls-row {
display: flex;
gap: 12px;
align-items: stretch;
}
.bounty-root .controls-row .search-container { flex: 1; max-width: none; }
/* Segmented type filter */
.bounty-root .seg-group {
display: flex;
border: 1px solid var(--border);
background: var(--panel);
}
.bounty-root .seg-group button {
padding: 8px 14px;
font-size: 0.68rem;
letter-spacing: 1.5px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: rgba(0, 255, 65, 0.6);
cursor: pointer;
font-family: inherit;
}
.bounty-root .seg-group button:last-child { border-right: none; }
.bounty-root .seg-group button.active {
background: var(--violet-tint-10);
color: var(--violet);
}
.bounty-root .seg-group button:hover:not(.active) { color: var(--matrix); }
/* Table row interactivity */
.bounty-root .logs-table tr.clickable { cursor: pointer; }
.bounty-root .logs-table tr.clickable:hover { background: rgba(238, 130, 238, 0.04); }
.bounty-root .logs-table td .attacker-link {
text-decoration: underline dotted;
cursor: pointer;
}
.bounty-root .logs-table td .data-preview {
font-size: 0.74rem;
opacity: 0.7;
max-width: 400px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bounty-root .logs-table td .cred-inline {
display: flex;
gap: 14px;
font-size: 0.8rem;
}
.bounty-root .logs-table td .cred-inline .k-small {
opacity: 0.6;
font-size: 0.65rem;
margin-right: 4px;
}
.bounty-root .logs-table td .cred-inline .matrix-text { color: var(--matrix); }
/* Empty state */
.bounty-root .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 50px 20px;
opacity: 0.45;
}
.bounty-root .empty-state .type-label {
font-size: 0.7rem;
letter-spacing: 2px;
}
/* Pagination */
.bounty-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; }
.bounty-root .pager button {
padding: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--matrix);
display: flex;
cursor: pointer;
}
.bounty-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.bounty-root .pager button:hover:not(:disabled) { border-color: var(--accent); }
/* ── Drawer ────────────────────────────────────────────── */
.bounty-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: flex-end;
z-index: 1000;
animation: bd-fade 0.15s ease;
}
@keyframes bd-fade { from { opacity: 0; } to { opacity: 1; } }
.bounty-drawer {
width: min(620px, 100%);
height: 100%;
background: var(--bg);
border-left: 1px solid var(--violet);
box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1);
overflow-y: auto;
display: flex;
flex-direction: column;
animation: bd-slide 0.2s ease;
}
@keyframes bd-slide { from { transform: translateX(30px); opacity: 0.6; } to { transform: none; opacity: 1; } }
.bounty-drawer .bd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.bounty-drawer .bd-head h3 {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
letter-spacing: 3px;
color: var(--violet);
margin: 0;
}
.bounty-drawer .close-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
display: flex;
padding: 4px;
cursor: pointer;
}
.bounty-drawer .close-btn:hover { border-color: var(--accent); }
.bounty-drawer .bd-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.bounty-drawer .kvs {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px 12px;
font-size: 0.8rem;
}
.bounty-drawer .kvs .k {
opacity: 0.55;
font-size: 0.7rem;
letter-spacing: 1.5px;
}
.bounty-drawer .kvs .v { word-break: break-all; }
.bounty-drawer .kvs .attacker-link {
text-decoration: underline dotted;
cursor: pointer;
color: var(--matrix);
}
.bounty-drawer .violet-accent { color: var(--violet); }
.bounty-drawer .type-label {
font-size: 0.68rem;
letter-spacing: 2px;
opacity: 0.6;
margin-bottom: 8px;
}
.bounty-drawer .code-block {
background: var(--panel);
border: 1px solid var(--border);
border-left: 2px solid var(--violet);
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--matrix);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
overflow-x: auto;
}
.bounty-drawer .code-block .ck { color: rgba(238, 130, 238, 0.9); }
.bounty-drawer .code-block .cs { color: var(--matrix); }
.bounty-drawer .bd-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Reuse .btn under drawer */
.bounty-drawer .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.8;
}
.bounty-drawer .btn.ghost:hover { opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); }

View File

@@ -1,8 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Archive, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
Target,
} from '../icons';
import api from '../utils/api';
import BountyInspector from './BountyInspector';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import './Dashboard.css';
import './Bounty.css';
interface BountyEntry {
id: number;
@@ -14,7 +21,34 @@ interface BountyEntry {
payload: any;
}
const FINGERPRINT_LABELS: Record<string, string> = {
fingerprint_type: 'TYPE', ja3: 'JA3', ja3s: 'JA3S', ja4: 'JA4', ja4s: 'JA4S', ja4l: 'JA4L',
sni: 'SNI', alpn: 'ALPN', dst_port: 'PORT', mechanisms: 'MECHANISM', raw_ciphers: 'CIPHERS',
hash: 'HASH', target_ip: 'TARGET', target_port: 'PORT', ssh_banner: 'BANNER',
kex_algorithms: 'KEX', encryption_s2c: 'ENC (S→C)', mac_s2c: 'MAC (S→C)',
compression_s2c: 'COMP (S→C)', raw: 'RAW', ttl: 'TTL', window_size: 'WINDOW', df_bit: 'DF',
mss: 'MSS', window_scale: 'WSCALE', sack_ok: 'SACK', timestamp: 'TS', options_order: 'OPTS ORDER',
};
const FingerprintPreview: React.FC<{ payload: any }> = ({ payload }) => {
if (!payload || typeof payload !== 'object') {
return <span className="data-preview">{JSON.stringify(payload)}</span>;
}
const keys = Object.keys(payload);
const priority = ['fingerprint_type', 'ja3', 'ja4', 'hash', 'sni', 'target_ip', 'ssh_banner'];
const show = priority.filter(k => payload[k] !== undefined && payload[k] !== null).slice(0, 2);
if (!show.length) {
return <span className="data-preview">{keys.slice(0, 3).join(', ')}</span>;
}
return (
<span className="data-preview">
{show.map(k => `${FINGERPRINT_LABELS[k] || k.toUpperCase()}: ${payload[k]}`).join(' · ')}
</span>
);
};
const Bounty: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const typeFilter = searchParams.get('type') || '';
@@ -24,7 +58,10 @@ const Bounty: React.FC = () => {
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selected, setSelected] = useState<BountyEntry | null>(null);
const limit = 50;
const fetchBounties = async () => {
@@ -34,7 +71,6 @@ const Bounty: React.FC = () => {
let url = `/bounty?limit=${limit}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (typeFilter) url += `&bounty_type=${typeFilter}`;
const res = await api.get(url);
setBounties(res.data.data);
setTotal(res.data.total);
@@ -45,85 +81,81 @@ const Bounty: React.FC = () => {
}
};
useEffect(() => {
fetchBounties();
}, [query, typeFilter, page]);
useEffect(() => { fetchBounties(); }, [query, typeFilter, page]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, type: typeFilter, page: '1' });
};
const setPage = (p: number) => setSearchParams({ q: query, type: typeFilter, page: p.toString() });
const setType = (t: string) => setSearchParams({ q: query, type: t, page: '1' });
const setPage = (p: number) => {
setSearchParams({ q: query, type: typeFilter, page: p.toString() });
};
const totalPages = Math.max(1, Math.ceil(total / limit));
const setType = (t: string) => {
setSearchParams({ q: query, type: t, page: '1' });
};
const credCount = bounties.filter(b => b.bounty_type === 'credential').length;
const payCount = bounties.filter(b => b.bounty_type === 'payload').length;
const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').length;
const totalPages = Math.ceil(total / limit);
const SEGMENTS: [string, string][] = [
['', 'ALL'],
['credential', 'CREDENTIALS'],
['payload', 'PAYLOADS'],
['fingerprint', 'FINGERPRINTS'],
];
return (
<div className="dashboard">
{/* Page Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Archive size={32} className="violet-accent" />
<h1 style={{ fontSize: '1.5rem', letterSpacing: '4px' }}>BOUNTY VAULT</h1>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
<Filter size={16} className="dim" />
<select
value={typeFilter}
onChange={(e) => setType(e.target.value)}
style={{ background: 'transparent', border: 'none', color: 'inherit', fontSize: '0.8rem', outline: 'none' }}
>
<option value="">ALL TYPES</option>
<option value="credential">CREDENTIALS</option>
<option value="payload">PAYLOADS</option>
</select>
<div className="bounty-root">
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Archive size={22} className="violet-accent" />
<h1>BOUNTY VAULT</h1>
</div>
<form onSubmit={handleSearch} style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
<Search size={18} style={{ opacity: 0.5, marginRight: '8px' }} />
<input
type="text"
placeholder="Search bounty..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }}
/>
</form>
<span className="page-sub">
{total.toLocaleString()} ARTIFACTS · {credCount} CREDENTIALS · {payCount} PAYLOADS · {fpCount} FINGERPRINTS
</span>
</div>
</div>
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by IP, decky, payload..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<div className="seg-group" role="tablist">
{SEGMENTS.map(([v, l]) => (
<button
key={v || 'all'}
type="button"
className={typeFilter === v ? 'active' : ''}
onClick={() => setType(v)}
>
{l}
</button>
))}
</div>
</form>
<div className="logs-section">
<div className="section-header" style={{ justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span className="matrix-text" style={{ fontSize: '0.8rem' }}>{total} ARTIFACTS CAPTURED</span>
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{total.toLocaleString()} ARTIFACTS CAPTURED</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {page} of {totalPages || 1}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page <= 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= totalPages ? 0.3 : 1 }}
>
<ChevronRight size={16} />
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
@@ -133,50 +165,71 @@ const Bounty: React.FC = () => {
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>TIME</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>TYPE</th>
<th>DATA</th>
<th></th>
</tr>
</thead>
<tbody>
{bounties.length > 0 ? bounties.map((b) => (
<tr key={b.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(b.timestamp).toLocaleString()}</td>
<td className="violet-accent">{b.decky}</td>
<td>{b.service}</td>
<td className="matrix-text">{b.attacker_ip}</td>
<td>
<span style={{
fontSize: '0.7rem',
padding: '2px 8px',
borderRadius: '4px',
border: `1px solid ${b.bounty_type === 'credential' ? 'var(--text-color)' : 'var(--accent-color)'}`,
backgroundColor: b.bounty_type === 'credential' ? 'rgba(0, 255, 65, 0.1)' : 'rgba(238, 130, 238, 0.1)',
color: b.bounty_type === 'credential' ? 'var(--text-color)' : 'var(--accent-color)'
}}>
{b.bounty_type.toUpperCase()}
</span>
</td>
<td>
<div style={{ fontSize: '0.9rem' }}>
{b.bounty_type === 'credential' ? (
<div style={{ display: 'flex', gap: '12px' }}>
<span><span className="dim" style={{ marginRight: '4px' }}>user:</span>{b.payload.username}</span>
<span><span className="dim" style={{ marginRight: '4px' }}>pass:</span>{b.payload.password}</span>
{bounties.length > 0 ? bounties.map(b => {
const isCred = b.bounty_type === 'credential';
const isFp = b.bounty_type === 'fingerprint';
const Icon = isCred ? Key : Package;
return (
<tr key={b.id} className="clickable" onClick={() => setSelected(b)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(b.timestamp).toLocaleTimeString()}
</td>
<td className="violet-accent">{b.decky}</td>
<td><span className="chip dim-chip">{b.service}</span></td>
<td>
<span
className="matrix-text attacker-link"
onClick={(e) => { e.stopPropagation(); navigate(`/attackers?q=${encodeURIComponent(b.attacker_ip)}`); }}
>
{b.attacker_ip}
</span>
</td>
<td>
<span className={`chip ${isCred ? 'matrix' : 'violet'}`}>
<Icon size={9} style={{ marginRight: 4 }} />
{b.bounty_type.toUpperCase()}
</span>
</td>
<td>
{isCred ? (
<div className="cred-inline">
<span><span className="k-small">user:</span>{b.payload?.username ?? '—'}</span>
<span>
<span className="k-small">pass:</span>
<span className="matrix-text">{b.payload?.password ?? '—'}</span>
</span>
</div>
) : isFp ? (
<FingerprintPreview payload={b.payload} />
) : (
<span className="dim" style={{ fontSize: '0.8rem' }}>{JSON.stringify(b.payload)}</span>
<span className="data-preview">
{b.payload?.query || b.payload?.body || b.payload?.command || JSON.stringify(b.payload)}
</span>
)}
</div>
</td>
</tr>
)) : (
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '60px', opacity: 0.5, letterSpacing: '4px' }}>
{loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'}
<td colSpan={7}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING ARTIFACTS…' : 'THE VAULT IS EMPTY'}
hint={loading ? undefined : 'attacker-dropped artifacts will land here'}
/>
</td>
</tr>
)}
@@ -184,6 +237,17 @@ const Bounty: React.FC = () => {
</table>
</div>
</div>
{selected && (
<BountyInspector
bounty={selected}
onClose={() => setSelected(null)}
onSelectAttacker={(ip) => {
setSelected(null);
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { X, Key, Package, Copy, Send, Ban } from '../icons';
import { useToast } from './Toasts/useToast';
interface BountyEntry {
id: number;
timestamp: string;
decky: string;
service: string;
attacker_ip: string;
bounty_type: string;
payload: any;
}
interface Props {
bounty: BountyEntry;
onClose: () => void;
onSelectAttacker: (ip: string) => void;
}
const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker }) => {
const { push } = useToast();
const isCred = bounty.bounty_type === 'credential';
const Icon = isCred ? Key : Package;
const p = bounty.payload || {};
const copyJson = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(bounty, null, 2));
push({ text: 'JSON COPIED', tone: 'matrix', icon: 'copy' });
} catch {
push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' });
}
};
const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' });
const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' });
return (
<div className="bounty-drawer-backdrop" onClick={onClose}>
<div className="bounty-drawer" onClick={(e) => e.stopPropagation()}>
<div className="bd-head">
<h3>
<Icon size={14} />
<span>ARTIFACT #{bounty.id}</span>
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="bd-body">
<div className="kvs">
<div className="k">TYPE</div>
<div className="v">
<span className={`chip ${isCred ? 'matrix' : 'violet'}`}>{bounty.bounty_type.toUpperCase()}</span>
</div>
<div className="k">TIMESTAMP</div>
<div className="v">{new Date(bounty.timestamp).toLocaleString()}</div>
<div className="k">DECKY</div>
<div className="v violet-accent">{bounty.decky}</div>
<div className="k">SERVICE</div>
<div className="v"><span className="chip dim-chip">{bounty.service}</span></div>
<div className="k">ATTACKER</div>
<div className="v">
<span
className="attacker-link"
onClick={() => onSelectAttacker(bounty.attacker_ip)}
>
{bounty.attacker_ip}
</span>
</div>
</div>
<div>
<div className="type-label">{isCred ? 'CAPTURED CREDENTIAL' : 'CAPTURED PAYLOAD'}</div>
{isCred ? (
<pre className="code-block">
<span className="ck">username:</span> <span className="cs">{p.username}</span>{'\n'}
<span className="ck">password:</span> <span className="cs">{p.password}</span>
</pre>
) : (
<pre className="code-block">{JSON.stringify(p, null, 2)}</pre>
)}
</div>
<div>
<div className="type-label">EXPORT</div>
<div className="bd-actions">
<button className="btn ghost" onClick={copyJson}><Copy size={12} /> COPY JSON</button>
<button className="btn ghost" onClick={stubMisp}><Send size={12} /> SEND TO MISP</button>
<button className="btn ghost" onClick={stubBlocklist}><Ban size={12} /> BLOCKLIST IP</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default BountyInspector;

View File

@@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useCampaignStream } from './useCampaignStream';
import './Dashboard.css';
/*
* CampaignDetail — read-only view of a campaign-clustered operation.
*
* Layer above identity resolution. Member identities link back to
* IdentityDetail; same visual vocabulary as the rest of the app
* (page-header / sections / chips), no inline-style drift.
*/
interface CampaignData {
uuid: string;
schema_version: number;
first_seen_at: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
confidence: number | null;
identity_count: number;
identity_count_live: number;
ja3_hashes: string | null;
hassh_hashes: string | null;
payload_simhashes: string | null;
c2_endpoints: string | null;
merged_into_uuid: string | null;
notes: string | null;
}
interface IdentityRow {
uuid: string;
first_seen_at: string | null;
last_seen_at: string | null;
observation_count: number;
campaign_id: string | null;
merged_into_uuid: string | null;
}
const safeParseJsonList = (raw: string | null): string[] => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const CampaignDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [campaign, setCampaign] = useState<CampaignData | null>(null);
const [identities, setIdentities] = useState<IdentityRow[]>([]);
const [identityTotal, setIdentityTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const fetchCampaign = async () => {
setLoading(true);
try {
const res = await api.get(`/campaigns/${id}`);
setCampaign(res.data);
setError(null);
} catch (err: any) {
if (err.response?.status === 404) {
setError('CAMPAIGN NOT FOUND');
} else {
setError('FAILED TO LOAD CAMPAIGN');
}
} finally {
setLoading(false);
}
};
fetchCampaign();
}, [id]);
useEffect(() => {
if (!id) return;
const fetchIdentities = async () => {
try {
const res = await api.get(`/campaigns/${id}/identities?limit=50&offset=0`);
setIdentities(res.data.data ?? []);
setIdentityTotal(res.data.total ?? 0);
} catch {
setIdentities([]);
setIdentityTotal(0);
}
};
fetchIdentities();
}, [id]);
// Refetch when a campaign event references this uuid.
useCampaignStream({
enabled: !!id,
onEvent: (ev) => {
if (!id) return;
const refs = new Set<string>();
const addUuid = (v: unknown) => {
if (typeof v === 'string') refs.add(v);
};
const payload = ev.payload || {};
addUuid(payload.campaign_uuid);
addUuid(payload.winner_uuid);
addUuid(payload.loser_uuid);
addUuid(payload.resurrected_uuid);
addUuid(payload.former_winner_uuid);
if (refs.has(id)) {
api.get(`/campaigns/${id}`).then((res) => setCampaign(res.data)).catch(() => {});
api.get(`/campaigns/${id}/identities?limit=50&offset=0`)
.then((res) => {
setIdentities(res.data.data ?? []);
setIdentityTotal(res.data.total ?? 0);
})
.catch(() => {});
}
},
});
if (loading) {
return (
<div className="bounty-root">
<EmptyState icon={Crosshair} title="LOADING CAMPAIGN…" />
</div>
);
}
if (error || !campaign) {
return (
<div className="bounty-root">
<button onClick={() => navigate('/campaigns')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO CAMPAIGNS</span>
</button>
<EmptyState icon={Crosshair} title={error || 'CAMPAIGN NOT FOUND'} />
</div>
);
}
const ja3List = safeParseJsonList(campaign.ja3_hashes);
const hasshList = safeParseJsonList(campaign.hassh_hashes);
const payloadList = safeParseJsonList(campaign.payload_simhashes);
const c2List = safeParseJsonList(campaign.c2_endpoints);
return (
<div className="bounty-root">
<button onClick={() => navigate('/campaigns')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO CAMPAIGNS</span>
</button>
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Crosshair size={22} className="violet-accent" />
<h1>CAMPAIGN · {campaign.uuid.slice(0, 12)}</h1>
{campaign.merged_into_uuid && (
<span
className="chip dim-chip"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/campaigns/${campaign.merged_into_uuid}`)}
title="Soft-merged. Click to view canonical winner."
>
MERGED {campaign.merged_into_uuid.slice(0, 8)}
</span>
)}
</div>
<span className="page-sub">
{campaign.identity_count_live} IDENTITIES ·
{' '}{ja3List.length} JA3 · {hasshList.length} HASSH ·
{' '}{payloadList.length} PAYLOAD · {c2List.length} C2
{campaign.confidence !== null && (
<> · CONFIDENCE {campaign.confidence.toFixed(3)}</>
)}
{' '}· SCHEMA v{campaign.schema_version}
</span>
</div>
</div>
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Fingerprint size={14} />
<span>AGGREGATED FINGERPRINTS</span>
</div>
</div>
<div className="logs-table-container" style={{ padding: 12 }}>
{ja3List.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="JA3" items={ja3List} />
)}
{hasshList.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="HASSH" items={hasshList} />
)}
{c2List.length > 0 && (
<FingerprintGroup icon={<Radio size={14} />} label="C2 ENDPOINTS" items={c2List} />
)}
</div>
</div>
)}
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{identityTotal} IDENTITIES IN THIS CAMPAIGN</span>
</div>
</div>
<div className="logs-table-container">
{identities.length === 0 ? (
<EmptyState
icon={Crosshair}
title="NO IDENTITIES LINKED YET"
hint="the campaign clusterer assigns identities asynchronously"
/>
) : (
<table className="logs-table">
<thead>
<tr>
<th>IDENTITY</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th style={{ textAlign: 'right' }}>OBSERVATIONS</th>
</tr>
</thead>
<tbody>
{identities.map((ident) => (
<tr
key={ident.uuid}
className="clickable"
onClick={() => navigate(`/identities/${ident.uuid}`)}
>
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
{ident.uuid.slice(0, 12)}
</td>
<td className="dim">{ident.first_seen_at ?? '—'}</td>
<td className="dim">{ident.last_seen_at ?? '—'}</td>
<td className="matrix-text" style={{ textAlign: 'right' }}>
{ident.observation_count}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{campaign.notes && (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<span>ANALYST NOTES</span>
</div>
</div>
<div className="logs-table-container" style={{ padding: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
{campaign.notes}
</div>
</div>
)}
</div>
);
};
const FingerprintGroup: React.FC<{
icon: React.ReactNode;
label: string;
items: string[];
}> = ({ icon, label, items }) => (
<div className="fp-group">
<div className="fp-group-label">
{icon}
<span>{label}</span>
</div>
<div className="fp-group-items">
{items.map((v) => (
<span key={v} className="chip dim-chip">{v}</span>
))}
</div>
</div>
);
export default CampaignDetail;

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Crosshair, Search,
} from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import { useCampaignStream } from './useCampaignStream';
import './Dashboard.css';
interface CampaignEntry {
uuid: string;
schema_version: number;
first_seen_at: string | null;
last_seen_at: string | null;
updated_at: string;
confidence: number | null;
identity_count: number;
ja3_hashes: string | null;
hassh_hashes: string | null;
payload_simhashes: string | null;
c2_endpoints: string | null;
merged_into_uuid: string | null;
}
const safeListLen = (raw: string | null): number => {
if (!raw) return 0;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.length : 0;
} catch {
return 0;
}
};
const timeAgo = (dateStr: string | null): string => {
if (!dateStr) return '—';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
};
const Campaigns: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = (searchParams.get('q') || '').toLowerCase();
const page = parseInt(searchParams.get('page') || '1');
const [campaigns, setCampaigns] = useState<CampaignEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '');
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const limit = 50;
const fetchCampaigns = async () => {
setLoading(true);
try {
const offset = (page - 1) * limit;
const res = await api.get(`/campaigns?limit=${limit}&offset=${offset}`);
setCampaigns(res.data.data ?? []);
setTotal(res.data.total ?? 0);
} catch (err) {
console.error('Failed to fetch campaigns', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchCampaigns(); }, [page]);
useCampaignStream({
enabled: true,
onEvent: () => { fetchCampaigns(); },
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, page: '1' });
};
const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() });
const totalPages = Math.max(1, Math.ceil(total / limit));
const visible = query
? campaigns.filter((c) => c.uuid.toLowerCase().includes(query))
: campaigns;
const totalIdentities = campaigns.reduce((sum, c) => sum + c.identity_count, 0);
return (
<div className="bounty-root">
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Crosshair size={22} className="violet-accent" />
<h1>CAMPAIGN CLUSTERING</h1>
</div>
<span className="page-sub">
{total.toLocaleString()} CAMPAIGNS · {totalIdentities} IDENTITIES GROUPED
</span>
</div>
</div>
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by UUID..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
</form>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{visible.length.toLocaleString()} CAMPAIGNS SHOWN</span>
</div>
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>UUID</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th>FINGERPRINTS</th>
<th>INFRA</th>
<th>IDENTITIES</th>
<th>CONFIDENCE</th>
<th></th>
</tr>
</thead>
<tbody>
{visible.length > 0 ? visible.map((c) => (
<tr
key={c.uuid}
className="clickable"
onClick={() => navigate(`/campaigns/${c.uuid}`)}
>
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
{c.uuid.slice(0, 12)}
</td>
<td className="dim">{timeAgo(c.first_seen_at)}</td>
<td className="dim">{timeAgo(c.last_seen_at)}</td>
<td>
<span className="chip dim-chip">{safeListLen(c.ja3_hashes)} JA3</span>{' '}
<span className="chip dim-chip">{safeListLen(c.hassh_hashes)} HASSH</span>
</td>
<td>
<span className="chip dim-chip">{safeListLen(c.payload_simhashes)} PAYLOAD</span>{' '}
<span className="chip dim-chip">{safeListLen(c.c2_endpoints)} C2</span>
</td>
<td className="matrix-text">{c.identity_count}</td>
<td className="violet-accent">
{c.confidence !== null ? c.confidence.toFixed(2) : '—'}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
)) : (
<tr>
<td colSpan={8}>
<EmptyState
icon={Crosshair}
title={loading ? 'CLUSTERING CAMPAIGNS…' : 'NO CAMPAIGNS YET'}
hint={loading ? undefined : 'the campaign clusterer groups identities into operations as they correlate'}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Campaigns;

View File

@@ -0,0 +1,315 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Download, AlertTriangle, Trash2, Eye } from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
export interface CanaryTokenRow {
uuid: string;
kind: 'http' | 'dns' | 'aws_passive';
decky_name: string;
blob_uuid: string | null;
instrumenter: string | null;
generator: string | null;
placement_path: string;
callback_token: string;
placed_at: string;
last_triggered_at: string | null;
trigger_count: number;
created_by: string;
state: 'planted' | 'revoked' | 'failed';
last_error: string | null;
}
interface CanaryTrigger {
uuid: string;
token_uuid: string;
occurred_at: string;
src_ip: string;
user_agent: string | null;
request_path: string | null;
dns_qname: string | null;
headers: Record<string, string>;
attacker_id: string | null;
}
interface Props {
token: CanaryTokenRow;
onClose: () => void;
onRevoked: (uuid: string) => void;
}
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}></span>}</div>
</div>
);
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const STATE_COLOR: Record<CanaryTokenRow['state'], string> = {
planted: '#00ff88',
revoked: 'var(--dim-color)',
failed: '#ff5555',
};
const KIND_LABEL: Record<CanaryTokenRow['kind'], string> = {
http: 'HTTP CALLBACK',
dns: 'DNS CALLBACK',
aws_passive: 'AWS PASSIVE',
};
const CanaryTokenDrawer: React.FC<Props> = ({ token, onClose, onRevoked }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [triggers, setTriggers] = useState<CanaryTrigger[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
const [revoking, setRevoking] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.get(`/canary/tokens/${encodeURIComponent(token.uuid)}/triggers?limit=200`)
.then((res) => { if (!cancelled) setTriggers(res.data.triggers || []); })
.catch((err) => {
if (cancelled) return;
const status = err?.response?.status;
setError(
status === 403 ? 'Viewer role required.' :
status === 404 ? 'Token has been deleted.' :
'Failed to load triggers.'
);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [token.uuid]);
const handleDownloadPreview = async () => {
setDownloading(true);
setError(null);
try {
const res = await api.get(
`/canary/tokens/${encodeURIComponent(token.uuid)}/preview`,
{ responseType: 'blob' },
);
const blobUrl = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = blobUrl;
a.download = token.placement_path.split('/').pop() || `canary-${token.callback_token}.bin`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to preview.' :
status === 409 ? 'Token has no preview-able bytes (passive aws_creds, or blob deleted).' :
'Preview failed.'
);
} finally {
setDownloading(false);
}
};
const handleRevoke = async () => {
if (!window.confirm(`Revoke canary token on ${token.decky_name}? This unlinks the file and stops the slug from resolving.`)) return;
setRevoking(true);
setError(null);
try {
await api.delete(`/canary/tokens/${encodeURIComponent(token.uuid)}`);
onRevoked(token.uuid);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to revoke.' :
status === 404 ? 'Token already gone.' :
'Revoke failed.'
);
} finally {
setRevoking(false);
}
};
const previewable = token.kind !== 'aws_passive';
const callbackUrl = token.kind === 'http'
? `<canary-host>/c/${token.callback_token}`
: token.kind === 'dns'
? `${token.callback_token}.<dns-zone>`
: '— (passive bait, no callback)';
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'flex-end',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(640px, 100%)', height: '100%',
backgroundColor: 'var(--bg-color, #0d1117)',
borderLeft: '1px solid var(--border-color, #30363d)',
padding: '24px', overflowY: 'auto',
color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
CANARY TOKEN · {token.decky_name}
</div>
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', wordBreak: 'break-all' }}>
{token.placement_path}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: `1px solid ${STATE_COLOR[token.state]}33`,
backgroundColor: `${STATE_COLOR[token.state]}11`,
fontSize: '0.75rem', color: STATE_COLOR[token.state],
}}>
<AlertTriangle size={14} />
{token.state.toUpperCase()} · {KIND_LABEL[token.kind]} · {token.trigger_count} {token.trigger_count === 1 ? 'hit' : 'hits'}
{token.state === 'failed' && token.last_error && <span style={{ color: '#ff5555' }}>· {token.last_error}</span>}
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px', flexWrap: 'wrap' }}>
{previewable && (
<button
onClick={handleDownloadPreview}
disabled={downloading}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px',
border: '1px solid var(--text-color)',
background: 'transparent', color: 'var(--text-color)',
cursor: downloading ? 'wait' : 'pointer',
opacity: downloading ? 0.5 : 1,
}}
>
<Download size={14} /> {downloading ? 'DOWNLOADING…' : 'PREVIEW BYTES'}
</button>
)}
{token.state === 'planted' && (
<button
onClick={handleRevoke}
disabled={revoking}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px',
border: '1px solid #ff5555',
background: 'transparent', color: '#ff5555',
cursor: revoking ? 'wait' : 'pointer',
opacity: revoking ? 0.5 : 1,
}}
>
<Trash2 size={14} /> {revoking ? 'REVOKING…' : 'REVOKE'}
</button>
)}
</div>
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
)}
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
METADATA
</h3>
<Row label="UUID" value={<code>{token.uuid}</code>} />
<Row label="Decky" value={token.decky_name} />
<Row label="Kind" value={KIND_LABEL[token.kind]} />
<Row label="Source" value={token.generator ? `generator: ${token.generator}` : token.instrumenter ? `instrumenter: ${token.instrumenter}` : '—'} />
<Row label="Slug" value={<code>{token.callback_token}</code>} />
<Row label="Callback" value={<code>{callbackUrl}</code>} />
<Row label="Placed at" value={fmt(token.placed_at)} />
<Row label="Last hit" value={fmt(token.last_triggered_at)} />
<Row label="Trigger count" value={token.trigger_count} />
<Row label="Created by" value={token.created_by} />
</section>
<section>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
<Eye size={14} style={{ verticalAlign: 'middle', marginRight: '6px' }} />
CALLBACK HISTORY ({triggers.length}{triggers.length === 200 ? '+' : ''})
</h3>
{loading && <div style={{ fontSize: '0.8rem', opacity: 0.6 }}>loading</div>}
{!loading && triggers.length === 0 && (
<div style={{ fontSize: '0.8rem', opacity: 0.6 }}>
No callbacks yet. The slug will start firing if the artifact gets exfiltrated and opened.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{triggers.map((t) => (
<div
key={t.uuid}
style={{
padding: '8px 12px',
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.02)',
fontSize: '0.8rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', fontFamily: 'monospace' }}>
<span>{t.src_ip}</span>
<span style={{ color: 'var(--dim-color)' }}>{fmt(t.occurred_at)}</span>
</div>
{t.user_agent && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
UA · {t.user_agent}
</div>
)}
{t.request_path && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
HTTP · {t.request_path}
</div>
)}
{t.dns_qname && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
DNS · {t.dns_qname}
</div>
)}
{t.attacker_id && (
<div style={{ fontSize: '0.7rem', color: '#00ff88', fontFamily: 'monospace', wordBreak: 'break-all' }}>
attacker · {t.attacker_id}
</div>
)}
</div>
))}
</div>
</section>
</div>
</div>
);
};
export default CanaryTokenDrawer;

View File

@@ -0,0 +1,731 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus, Upload, X, AlertTriangle, Search,
} from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
import CanaryTokenDrawer from './CanaryTokenDrawer';
import type { CanaryTokenRow } from './CanaryTokenDrawer';
interface BlobRow {
uuid: string;
sha256: string;
filename: string;
content_type: string;
size_bytes: number;
uploaded_by: string;
uploaded_at: string;
token_count: number;
}
const KNOWN_GENERATORS = [
'git_config', 'env_file', 'ssh_key', 'aws_creds',
'honeydoc', 'honeydoc_docx', 'honeydoc_pdf',
] as const;
type GeneratorName = typeof KNOWN_GENERATORS[number];
const KIND_OPTIONS: Array<{ value: 'http' | 'dns' | 'aws_passive'; label: string }> = [
{ value: 'http', label: 'HTTP callback' },
{ value: 'dns', label: 'DNS callback' },
{ value: 'aws_passive', label: 'AWS passive (no callback)' },
];
function extractError(err: unknown, fallback: string): string {
const e = err as { response?: { status?: number; data?: { detail?: string } } };
if (e?.response?.data?.detail) return e.response.data.detail;
if (e?.response?.status === 403) return 'Insufficient permissions (admin only).';
if (e?.response?.status === 401) return 'Session expired — please log in again.';
return fallback;
}
function fmt(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / 1024 / 1024).toFixed(1)} MiB`;
}
const STATE_COLOR = {
planted: '#00ff88',
revoked: 'var(--dim-color)',
failed: '#ff5555',
};
// ─── CREATE MODAL ──────────────────────────────────────────────────────────
interface DeckyOption {
name: string;
ip?: string;
}
interface CreateModalProps {
blobs: BlobRow[];
deckies: DeckyOption[];
onClose: () => void;
onCreated: (token: CanaryTokenRow) => void;
}
const CreateModal: React.FC<CreateModalProps> = ({ blobs, deckies, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
const [decky, setDecky] = useState(deckies[0]?.name ?? '');
const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http');
const [path, setPath] = useState('/home/admin/.aws/credentials');
const [source, setSource] = useState<'generator' | 'blob'>('generator');
const [generator, setGenerator] = useState<GeneratorName>('aws_creds');
const [blobUuid, setBlobUuid] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
if (!decky.trim()) return setError('Pick a decky.');
if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.');
if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.');
setSubmitting(true);
try {
const body: Record<string, unknown> = {
decky_name: decky.trim(),
kind,
placement_path: path.trim(),
};
if (source === 'generator') body.generator = generator;
else body.blob_uuid = blobUuid;
const res = await api.post('/canary/tokens', body);
onCreated(res.data);
} catch (err) {
setError(extractError(err, 'Create failed.'));
} finally {
setSubmitting(false);
}
};
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'center', alignItems: 'center',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
backgroundColor: 'var(--bg-color, #0d1117)',
border: '1px solid var(--border-color, #30363d)',
padding: '24px', color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>NEW CANARY TOKEN</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<Field label="Decky">
{deckies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No deckies running. Deploy a fleet first.
</div>
) : (
<select
value={decky}
onChange={(e) => setDecky(e.target.value)}
autoFocus
style={INPUT_STYLE}
>
{deckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="Kind">
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
style={INPUT_STYLE}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
<Field label="Placement path (inside the container)">
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/home/admin/.aws/credentials"
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
/>
</Field>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['generator', 'blob'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setSource(s)}
style={{
flex: 1,
padding: '8px',
background: source === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: source === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'generator' ? 'Built-in template' : 'Operator upload'}
</button>
))}
</div>
{source === 'generator' && (
<Field label="Generator">
<select
value={generator}
onChange={(e) => setGenerator(e.target.value as GeneratorName)}
style={INPUT_STYLE}
>
{KNOWN_GENERATORS.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</Field>
)}
{source === 'blob' && (
<Field label="Uploaded artifact">
{blobs.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No blobs uploaded yet. Use "Upload artifact" on the main page first.
</div>
) : (
<select
value={blobUuid}
onChange={(e) => setBlobUuid(e.target.value)}
style={INPUT_STYLE}
>
<option value=""> select </option>
{blobs.map((b) => (
<option key={b.uuid} value={b.uuid}>
{b.filename} ({b.content_type}, {fmtBytes(b.size_bytes)})
</option>
))}
</select>
)}
</Field>
)}
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
>
{submitting ? 'PLANTING…' : 'PLANT TOKEN'}
</button>
</div>
</div>
</div>
);
};
// ─── BLOB UPLOAD MODAL ─────────────────────────────────────────────────────
interface UploadModalProps {
onClose: () => void;
onUploaded: (blob: BlobRow) => void;
}
const UploadModal: React.FC<UploadModalProps> = ({ onClose, onUploaded }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const handleSubmit = async () => {
if (!file) return setError('Pick a file first.');
setUploading(true);
setError(null);
try {
const fd = new FormData();
fd.append('file', file);
const res = await api.post('/canary/blobs', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
onUploaded(res.data);
} catch (err) {
setError(extractError(err, 'Upload failed.'));
} finally {
setUploading(false);
}
};
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'center', alignItems: 'center',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(520px, 100%)',
backgroundColor: 'var(--bg-color, #0d1117)',
border: '1px solid var(--border-color, #30363d)',
padding: '24px', color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>UPLOAD CANARY ARTIFACT</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) setFile(f);
}}
style={{
border: `2px dashed ${dragOver ? 'var(--accent-color, #00ff88)' : 'var(--border-color, #30363d)'}`,
padding: '32px',
textAlign: 'center',
marginBottom: '16px',
cursor: 'pointer',
background: dragOver ? 'rgba(0, 255, 136, 0.05)' : 'transparent',
}}
onClick={() => document.getElementById('canary-blob-input')?.click()}
>
<Upload size={32} style={{ opacity: 0.5, marginBottom: '8px' }} />
<div style={{ fontSize: '0.85rem' }}>
{file ? `${file.name} (${fmtBytes(file.size)})` : 'Drop a file here or click to browse'}
</div>
{!file && (
<div style={{ fontSize: '0.7rem', opacity: 0.6, marginTop: '6px' }}>
DOCX · XLSX · PDF · HTML · PNG/JPEG · plain configs
</div>
)}
<input
id="canary-blob-input"
type="file"
style={{ display: 'none' }}
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: '1px solid rgba(255, 170, 0, 0.3)',
backgroundColor: 'rgba(255, 170, 0, 0.05)',
fontSize: '0.75rem', color: '#ffaa00',
}}>
<AlertTriangle size={14} />
DECNET injects the callback server-side; the original bytes stay on the master.
</div>
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
<button
onClick={handleSubmit}
disabled={!file || uploading}
style={{ ...BTN_PRIMARY, opacity: (!file || uploading) ? 0.5 : 1, cursor: uploading ? 'wait' : 'pointer' }}
>
{uploading ? 'UPLOADING…' : 'UPLOAD'}
</button>
</div>
</div>
</div>
);
};
// ─── MAIN PAGE ─────────────────────────────────────────────────────────────
const CanaryTokens: React.FC = () => {
const [tokens, setTokens] = useState<CanaryTokenRow[]>([]);
const [blobs, setBlobs] = useState<BlobRow[]>([]);
const [deckies, setDeckies] = useState<DeckyOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<'tokens' | 'blobs'>('tokens');
const [filter, setFilter] = useState('');
const [stateFilter, setStateFilter] = useState<'all' | 'planted' | 'revoked' | 'failed'>('all');
const [showCreate, setShowCreate] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [drawerToken, setDrawerToken] = useState<CanaryTokenRow | null>(null);
const loadAll = async () => {
setLoading(true);
setError(null);
try {
const [t, b, d] = await Promise.all([
api.get('/canary/tokens'),
api.get('/canary/blobs').catch(() => ({ data: { blobs: [] } })), // viewers can't list blobs
api.get<DeckyOption[]>('/deckies').catch(() => ({ data: [] })),
]);
setTokens(t.data.tokens || []);
setBlobs(b.data.blobs || []);
setDeckies(Array.isArray(d.data) ? d.data : []);
} catch (err) {
setError(extractError(err, 'Failed to load canary tokens.'));
} finally {
setLoading(false);
}
};
useEffect(() => { loadAll(); }, []);
// Alt+C — open the create modal (per feedback_linux_meta_key).
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.altKey && e.key.toLowerCase() === 'c' && !showCreate && !showUpload && !drawerToken) {
e.preventDefault();
setShowCreate(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [showCreate, showUpload, drawerToken]);
const visibleTokens = useMemo(() => {
return tokens.filter((t) => {
if (stateFilter !== 'all' && t.state !== stateFilter) return false;
if (!filter) return true;
const f = filter.toLowerCase();
return (
t.decky_name.toLowerCase().includes(f) ||
t.placement_path.toLowerCase().includes(f) ||
t.callback_token.toLowerCase().includes(f) ||
(t.generator || '').toLowerCase().includes(f) ||
(t.instrumenter || '').toLowerCase().includes(f)
);
});
}, [tokens, filter, stateFilter]);
const counts = useMemo(() => {
const c = { planted: 0, revoked: 0, failed: 0, hits: 0 };
for (const t of tokens) {
c[t.state] += 1;
c.hits += t.trigger_count;
}
return c;
}, [tokens]);
const handleDeleteBlob = async (uuid: string) => {
if (!window.confirm('Delete this blob? Refused if any token still references it.')) return;
try {
await api.delete(`/canary/blobs/${encodeURIComponent(uuid)}`);
setBlobs((prev) => prev.filter((b) => b.uuid !== uuid));
} catch (err) {
alert(extractError(err, 'Delete failed.'));
}
};
return (
<div className="fleet-root canary-tokens-root" style={{ padding: '24px', color: 'var(--text-color)' }}>
<div className="page-header">
<div className="page-title-group">
<h1>CANARY TOKENS</h1>
<span className="page-sub">
{tokens.length} TOKEN{tokens.length === 1 ? '' : 'S'} · {counts.planted} PLANTED · {counts.hits} TOTAL HIT{counts.hits === 1 ? '' : 'S'} · {blobs.length} UPLOADED BLOB{blobs.length === 1 ? '' : 'S'}
</span>
</div>
<div className="actions">
<button className="btn" onClick={() => setShowUpload(true)}>
<Upload size={12} /> UPLOAD ARTIFACT
</button>
<button className="btn violet" onClick={() => setShowCreate(true)} title="Alt+C">
<Plus size={12} /> NEW TOKEN
</button>
</div>
</div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '24px', flexWrap: 'wrap' }}>
<Stat label="PLANTED" value={counts.planted} color={STATE_COLOR.planted} />
<Stat label="REVOKED" value={counts.revoked} color={STATE_COLOR.revoked} />
<Stat label="FAILED" value={counts.failed} color={STATE_COLOR.failed} />
<Stat label="TOTAL HITS" value={counts.hits} color="#00ff88" />
<Stat label="UPLOADED BLOBS" value={blobs.length} color="var(--text-color)" />
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', borderBottom: '1px solid var(--border-color, #30363d)' }}>
{(['tokens', 'blobs'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
background: 'transparent', border: 'none',
color: tab === t ? 'var(--text-color)' : 'var(--dim-color)',
padding: '8px 16px', cursor: 'pointer',
borderBottom: tab === t ? '2px solid var(--accent-color, #00ff88)' : '2px solid transparent',
fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{t === 'tokens' ? `Tokens (${tokens.length})` : `Blobs (${blobs.length})`}
</button>
))}
</div>
{tab === 'tokens' && (
<>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ position: 'relative', flex: '1 1 300px' }}>
<Search size={14} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter by decky / path / slug / generator…"
style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }}
/>
</div>
<select
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value as typeof stateFilter)}
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
>
<option value="all">all states</option>
<option value="planted">planted</option>
<option value="revoked">revoked</option>
<option value="failed">failed</option>
</select>
</div>
{loading && <div style={{ opacity: 0.6 }}>loading</div>}
{error && <div style={{ color: '#ff5555', marginBottom: '16px' }}>{error}</div>}
{!loading && visibleTokens.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
{tokens.length === 0
? 'No canary tokens yet. Click NEW TOKEN to plant one, or UPLOAD ARTIFACT to start with an operator-supplied document.'
: 'No tokens match the current filter.'}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{visibleTokens.map((t) => (
<button
key={t.uuid}
onClick={() => setDrawerToken(t)}
style={{
display: 'grid',
gridTemplateColumns: '110px 140px 1fr 100px 110px 80px',
alignItems: 'center', gap: '12px',
padding: '10px 14px',
border: '1px solid var(--border-color, #30363d)',
background: 'rgba(255,255,255,0.02)',
color: 'var(--text-color)',
cursor: 'pointer',
textAlign: 'left',
fontSize: '0.8rem',
}}
>
<span style={{
color: STATE_COLOR[t.state], fontFamily: 'monospace',
fontSize: '0.7rem', letterSpacing: '0.05em',
}}>
{t.state.toUpperCase()}
</span>
<span style={{ fontFamily: 'monospace' }}>{t.decky_name}</span>
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.placement_path}
</span>
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>
{t.kind === 'aws_passive' ? 'aws-passive' : t.kind}
</span>
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>
{t.generator || t.instrumenter || '?'}
</span>
<span style={{ textAlign: 'right', fontFamily: 'monospace', color: t.trigger_count > 0 ? '#00ff88' : 'var(--dim-color)' }}>
{t.trigger_count} {t.trigger_count === 1 ? 'hit' : 'hits'}
</span>
</button>
))}
</div>
</>
)}
{tab === 'blobs' && (
<>
{blobs.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
No uploaded artifacts. Click UPLOAD ARTIFACT to add one.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{blobs.map((b) => (
<div
key={b.uuid}
style={{
display: 'grid',
gridTemplateColumns: '1fr 220px 90px 100px 80px',
alignItems: 'center', gap: '12px',
padding: '10px 14px',
border: '1px solid var(--border-color, #30363d)',
background: 'rgba(255,255,255,0.02)',
fontSize: '0.8rem',
}}
>
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.filename}
</span>
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{b.content_type}</span>
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(b.size_bytes)}</span>
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(b.uploaded_at)}</span>
<button
onClick={() => handleDeleteBlob(b.uuid)}
disabled={b.token_count > 0}
title={b.token_count > 0 ? `${b.token_count} token(s) still reference this blob` : 'Delete'}
style={{
background: 'transparent', color: b.token_count > 0 ? 'var(--dim-color)' : '#ff5555',
border: `1px solid ${b.token_count > 0 ? 'var(--dim-color)' : '#ff5555'}`,
padding: '4px 8px', fontSize: '0.7rem',
cursor: b.token_count > 0 ? 'not-allowed' : 'pointer',
opacity: b.token_count > 0 ? 0.4 : 1,
}}
>
{b.token_count > 0 ? `${b.token_count} REFS` : 'DELETE'}
</button>
</div>
))}
</div>
</>
)}
{showCreate && (
<CreateModal
blobs={blobs}
deckies={deckies}
onClose={() => setShowCreate(false)}
onCreated={(t) => {
setTokens((prev) => [t, ...prev]);
setShowCreate(false);
}}
/>
)}
{showUpload && (
<UploadModal
onClose={() => setShowUpload(false)}
onUploaded={(b) => {
setBlobs((prev) => prev.some((x) => x.uuid === b.uuid) ? prev : [b, ...prev]);
setShowUpload(false);
}}
/>
)}
{drawerToken && (
<CanaryTokenDrawer
token={drawerToken}
onClose={() => setDrawerToken(null)}
onRevoked={(uuid) => {
setTokens((prev) => prev.map((t) =>
t.uuid === uuid ? { ...t, state: 'revoked' } : t,
));
setDrawerToken(null);
}}
/>
)}
</div>
);
};
// ─── small style helpers ───────────────────────────────────────────────────
const INPUT_STYLE: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
marginBottom: '12px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid var(--border-color, #30363d)',
color: 'var(--text-color)',
fontSize: '0.85rem',
};
const BTN_PRIMARY: React.CSSProperties = {
padding: '8px 14px',
border: '1px solid var(--accent-color, #00ff88)',
background: 'var(--accent-color, #00ff88)',
color: 'var(--bg-color, #0d1117)',
cursor: 'pointer',
fontSize: '0.8rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontWeight: 'bold',
};
const BTN_GHOST: React.CSSProperties = {
padding: '8px 14px',
border: '1px solid var(--text-color)',
background: 'transparent',
color: 'var(--text-color)',
cursor: 'pointer',
fontSize: '0.8rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em', marginBottom: '4px' }}>
{label.toUpperCase()}
</div>
{children}
</div>
);
const Stat: React.FC<{ label: string; value: number | string; color: string }> = ({ label, value, color }) => (
<div style={{
flex: '1 1 120px',
padding: '12px 16px',
border: '1px solid var(--border-color, #30363d)',
background: 'rgba(255,255,255,0.02)',
}}>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>{label}</div>
<div style={{ fontSize: '1.4rem', fontWeight: 'bold', color, marginTop: '4px' }}>{value}</div>
</div>
);
export default CanaryTokens;

View File

@@ -0,0 +1,116 @@
.cmd-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 120;
display: flex;
justify-content: center;
padding-top: 12vh;
backdrop-filter: blur(1px);
}
.cmd-palette {
width: 620px;
max-width: 96vw;
height: fit-content;
background: var(--panel);
border: 1px solid var(--matrix);
box-shadow: var(--matrix-glow-lg);
display: flex;
flex-direction: column;
animation: cmd-in 0.18s var(--ease);
}
@keyframes cmd-in {
from { transform: translateY(-8px); opacity: 0; }
to { transform: none; opacity: 1; }
}
.cmd-input-wrap {
display: flex;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
gap: 10px;
}
.cmd-input-wrap input {
background: transparent;
border: none;
outline: none;
color: var(--matrix);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
letter-spacing: 1px;
padding: 0;
}
.cmd-input-wrap input:focus { box-shadow: none; }
.cmd-list {
max-height: 380px;
overflow-y: auto;
padding: 6px 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.cmd-list::-webkit-scrollbar {
display: none;
}
.cmd-group-label {
padding: 8px 16px 4px;
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.cmd-item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 16px;
cursor: pointer;
font-size: 0.78rem;
border-left: 3px solid transparent;
}
.cmd-item-icon { opacity: 0.6; flex-shrink: 0; }
.cmd-item.active {
background: var(--matrix-tint-10);
border-left-color: var(--matrix);
padding-left: 13px;
}
.cmd-item.active .cmd-item-icon { opacity: 1; }
.cmd-kbd {
margin-left: auto;
font-size: 0.62rem;
opacity: 0.5;
border: 1px solid var(--border);
padding: 1px 5px;
letter-spacing: 1px;
}
.cmd-empty {
padding: 24px 16px;
text-align: center;
font-size: 0.7rem;
letter-spacing: 1px;
opacity: 0.5;
}
.cmd-hint {
padding: 8px 16px;
border-top: 1px solid var(--border);
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1px;
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, Settings,
SearchX, Keyboard, Webhook,
} from '../../icons';
import EmptyState from '../EmptyState/EmptyState';
import './CommandPalette.css';
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
interface CmdItem {
section: 'GO TO' | 'ACTIONS';
label: string;
icon: IconComponent;
kbd?: string;
kind: 'nav' | 'action';
payload: string;
}
const ITEMS: CmdItem[] = [
{ section: 'GO TO', label: 'Dashboard', icon: LayoutDashboard, kbd: 'G D', kind: 'nav', payload: '/' },
{ section: 'GO TO', label: 'Decoy Fleet', icon: Server, kbd: 'G F', kind: 'nav', payload: '/fleet' },
{ section: 'GO TO', label: 'MazeNET', icon: Network, kbd: 'G M', kind: 'nav', payload: '/mazenet' },
{ section: 'GO TO', label: 'Live Logs', icon: Terminal, kbd: 'G L', kind: 'nav', payload: '/live-logs' },
{ section: 'GO TO', label: 'Webhooks', icon: Webhook, kbd: 'G W', kind: 'nav', payload: '/webhooks' },
{ section: 'GO TO', label: 'Bounty Vault', icon: Archive, kbd: 'G B', kind: 'nav', payload: '/bounty' },
{ section: 'GO TO', label: 'Attackers', icon: Crosshair, kbd: 'G A', kind: 'nav', payload: '/attackers' },
{ section: 'GO TO', label: 'SWARM Hosts', icon: HardDrive, kbd: 'G S', kind: 'nav', payload: '/swarm/hosts' },
{ section: 'GO TO', label: 'Remote Updates', icon: Package, kbd: 'G U', kind: 'nav', payload: '/swarm-updates' },
{ section: 'GO TO', label: 'Config', icon: Settings, kbd: 'G C', kind: 'nav', payload: '/config' },
{ section: 'ACTIONS', label: 'Deploy new decky', icon: PlusCircle, kind: 'action', payload: 'deploy' },
{ section: 'ACTIONS', label: 'Pause live stream', icon: Pause, kind: 'action', payload: 'pause-logs' },
{ section: 'ACTIONS', label: 'Force mutate all deckies', icon: RefreshCw, kind: 'action', payload: 'mutate-all' },
{ section: 'ACTIONS', label: 'Export bounty to JSON', icon: Download, kind: 'action', payload: 'export-bounty' },
{ section: 'ACTIONS', label: 'Show keyboard shortcuts', icon: Keyboard, kbd: '?', kind: 'action', payload: 'shortcuts-help' },
];
interface Props {
open: boolean;
onClose: () => void;
onNav: (path: string) => void;
onAction: (id: string) => void;
}
const CommandPalette: React.FC<Props> = ({ open, onClose, onNav, onAction }) => {
const [query, setQuery] = useState('');
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ITEMS;
return ITEMS.filter(it =>
it.label.toLowerCase().includes(q) || it.section.toLowerCase().includes(q)
);
}, [query]);
useEffect(() => {
if (open) {
setQuery('');
setSel(0);
const t = setTimeout(() => inputRef.current?.focus(), 30);
return () => clearTimeout(t);
}
}, [open]);
useEffect(() => { setSel(0); }, [query]);
useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-cmd-idx="${sel}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [sel]);
if (!open) return null;
const fire = (it: CmdItem) => {
if (it.kind === 'nav') onNav(it.payload);
else onAction(it.payload);
onClose();
};
const handleKey = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
setSel(s => (filtered.length ? (s + 1) % filtered.length : 0));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSel(s => (filtered.length ? (s - 1 + filtered.length) % filtered.length : 0));
}
if (e.key === 'Enter') {
e.preventDefault();
const it = filtered[sel];
if (it) fire(it);
}
};
const groups = filtered.reduce<Record<string, CmdItem[]>>((acc, it) => {
(acc[it.section] ||= []).push(it);
return acc;
}, {});
let idx = -1;
return (
<div className="cmd-backdrop" onClick={onClose}>
<div className="cmd-palette" onClick={e => e.stopPropagation()}>
<div className="cmd-input-wrap">
<Terminal size={16} className="violet-accent" />
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKey}
placeholder="Type a command or search…"
/>
<span className="search-kbd">ESC</span>
</div>
<div className="cmd-list" ref={listRef}>
{Object.entries(groups).map(([section, items]) => (
<div key={section}>
<div className="cmd-group-label">{section}</div>
{items.map(it => {
idx++;
const active = idx === sel;
const Icon = it.icon;
return (
<div
key={it.label}
data-cmd-idx={idx}
className={`cmd-item ${active ? 'active' : ''}`}
onClick={() => fire(it)}
onMouseEnter={() => setSel(filtered.indexOf(it))}
>
<Icon size={14} className="cmd-item-icon" />
<span>{it.label}</span>
{it.kbd && <span className="cmd-kbd">{it.kbd}</span>}
</div>
);
})}
</div>
))}
{filtered.length === 0 && (
<EmptyState icon={SearchX} title="NO COMMAND MATCHES" size="compact" />
)}
</div>
<div className="cmd-hint">
<span> NAVIGATE · SELECT</span>
<span>DECNET CLI</span>
</div>
</div>
</div>
);
};
export default CommandPalette;

View File

@@ -0,0 +1,282 @@
.config-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--secondary-color);
}
.config-tab {
padding: 12px 24px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
letter-spacing: 1.5px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
transition: all 0.3s ease;
}
.config-tab:hover {
opacity: 0.8;
background: rgba(0, 255, 65, 0.03);
box-shadow: none;
color: var(--text-color);
}
.config-tab.active {
opacity: 1;
border-bottom-color: var(--accent-color);
color: var(--text-color);
}
.config-panel {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 32px;
}
.config-field {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
}
.config-field:last-child {
margin-bottom: 0;
}
.config-label {
font-size: 0.7rem;
letter-spacing: 1px;
opacity: 0.6;
}
.config-value {
font-size: 1.1rem;
padding: 8px 0;
}
.config-input-row {
display: flex;
align-items: center;
gap: 12px;
}
.config-input-row input {
width: 120px;
}
.config-input-row input[type="text"] {
width: 160px;
}
.preset-buttons {
display: flex;
gap: 8px;
}
.preset-btn {
padding: 6px 14px;
font-size: 0.75rem;
opacity: 0.7;
}
.preset-btn.active {
opacity: 1;
border-color: var(--accent-color);
color: var(--accent-color);
}
.save-btn {
padding: 8px 20px;
font-weight: bold;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 6px;
}
.save-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* User Management Table */
.users-table-container {
overflow-x: auto;
margin-bottom: 24px;
}
.users-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
text-align: left;
}
.users-table th {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
opacity: 0.5;
font-weight: normal;
font-size: 0.7rem;
letter-spacing: 1px;
}
.users-table td {
padding: 12px 24px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.users-table tr:hover {
background-color: rgba(0, 255, 65, 0.03);
}
.user-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 4px 10px;
font-size: 0.7rem;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn.danger {
border-color: #ff4141;
color: #ff4141;
}
.action-btn.danger:hover {
background: #ff4141;
color: var(--background-color);
box-shadow: 0 0 10px rgba(255, 65, 65, 0.5);
}
/* Add User Form */
.add-user-section {
border-top: 1px solid var(--border-color);
padding-top: 24px;
}
.add-user-form {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.add-user-form .form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.add-user-form label {
font-size: 0.65rem;
letter-spacing: 1px;
opacity: 0.6;
}
.add-user-form input {
width: 180px;
}
.add-user-form select {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 12px;
font-family: inherit;
cursor: pointer;
}
.add-user-form select:focus {
outline: none;
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
}
.role-select {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 4px 8px;
font-family: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.role-badge {
font-size: 0.7rem;
padding: 2px 8px;
border: 1px solid;
display: inline-block;
}
.role-badge.admin {
border-color: var(--accent-color);
color: var(--accent-color);
}
.role-badge.viewer {
border-color: var(--border-color);
color: var(--text-color);
opacity: 0.6;
}
.must-change-badge {
font-size: 0.65rem;
color: #ffaa00;
opacity: 0.8;
}
.config-success {
color: var(--text-color);
font-size: 0.75rem;
padding: 6px 12px;
border: 1px solid var(--text-color);
background: rgba(0, 255, 65, 0.1);
display: inline-block;
}
.config-error {
color: #ff4141;
font-size: 0.75rem;
padding: 6px 12px;
border: 1px solid #ff4141;
background: rgba(255, 65, 65, 0.1);
display: inline-block;
}
.confirm-dialog {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.75rem;
}
.confirm-dialog span {
color: #ff4141;
}
.interval-hint {
font-size: 0.65rem;
opacity: 0.4;
letter-spacing: 0.5px;
}

View File

@@ -1,18 +1,979 @@
import React from 'react';
import { Settings } from 'lucide-react';
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 { useToast } from './Toasts/useToast';
import './Dashboard.css';
import './Config.css';
interface UserEntry {
uuid: string;
username: string;
role: string;
must_change_password: boolean;
}
interface ConfigData {
role: string;
deployment_limit: number;
global_mutation_interval: string;
users?: UserEntry[];
developer_mode?: boolean;
}
const Config: React.FC = () => {
const [config, setConfig] = useState<ConfigData | null>(null);
const [loading, setLoading] = useState(true);
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('');
const [limitSaving, setLimitSaving] = useState(false);
const [limitMsg, setLimitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Global mutation interval state
const [intervalInput, setIntervalInput] = useState('');
const [intervalSaving, setIntervalSaving] = useState(false);
const [intervalMsg, setIntervalMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Add user form state
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState<'admin' | 'viewer'>('viewer');
const [addingUser, setAddingUser] = useState(false);
const [userMsg, setUserMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Confirm delete state
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
// Reset password state
const [resetTarget, setResetTarget] = useState<string | null>(null);
const [resetPassword, setResetPassword] = useState('');
// Reinit state
const [confirmReinit, setConfirmReinit] = useState(false);
const [reiniting, setReiniting] = useState(false);
const [reinitMsg, setReinitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isAdmin = config?.role === 'admin';
const fetchConfig = async () => {
try {
const res = await api.get('/config');
setConfig(res.data);
setLimitInput(String(res.data.deployment_limit));
setIntervalInput(res.data.global_mutation_interval);
} catch (err) {
console.error('Failed to fetch config', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchConfig();
}, []);
// If server didn't send users, force tab away from users
useEffect(() => {
if (config && !config.users && activeTab === 'users') {
setActiveTab('limits');
}
}, [config, activeTab]);
const handleSaveLimit = async () => {
const val = parseInt(limitInput);
if (isNaN(val) || val < 1 || val > 500) {
setLimitMsg({ type: 'error', text: 'VALUE MUST BE 1-500' });
return;
}
setLimitSaving(true);
setLimitMsg(null);
try {
await api.put('/config/deployment-limit', { deployment_limit: val });
setLimitMsg({ type: 'success', text: 'DEPLOYMENT LIMIT UPDATED' });
fetchConfig();
} catch (err: any) {
setLimitMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' });
} finally {
setLimitSaving(false);
}
};
const handleSaveInterval = async () => {
if (!/^[1-9]\d*[mdMyY]$/.test(intervalInput)) {
setIntervalMsg({ type: 'error', text: 'INVALID FORMAT (e.g. 30m, 1d, 6M)' });
return;
}
setIntervalSaving(true);
setIntervalMsg(null);
try {
await api.put('/config/global-mutation-interval', { global_mutation_interval: intervalInput });
setIntervalMsg({ type: 'success', text: 'MUTATION INTERVAL UPDATED' });
fetchConfig();
} catch (err: any) {
setIntervalMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' });
} finally {
setIntervalSaving(false);
}
};
const handleAddUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim() || !newPassword.trim()) return;
setAddingUser(true);
setUserMsg(null);
try {
await api.post('/config/users', {
username: newUsername.trim(),
password: newPassword,
role: newRole,
});
setNewUsername('');
setNewPassword('');
setNewRole('viewer');
setUserMsg({ type: 'success', text: 'USER CREATED' });
fetchConfig();
} catch (err: any) {
setUserMsg({ type: 'error', text: err.response?.data?.detail || 'CREATE FAILED' });
} finally {
setAddingUser(false);
}
};
const handleDeleteUser = async (uuid: string) => {
try {
await api.delete(`/config/users/${uuid}`);
setConfirmDelete(null);
fetchConfig();
} catch (err: any) {
alert(err.response?.data?.detail || 'Delete failed');
}
};
const handleRoleChange = async (uuid: string, role: string) => {
try {
await api.put(`/config/users/${uuid}/role`, { role });
fetchConfig();
} catch (err: any) {
alert(err.response?.data?.detail || 'Role update failed');
}
};
const handleResetPassword = async (uuid: string) => {
if (!resetPassword.trim() || resetPassword.length < 8) {
alert('Password must be at least 8 characters');
return;
}
try {
await api.put(`/config/users/${uuid}/reset-password`, { new_password: resetPassword });
setResetTarget(null);
setResetPassword('');
fetchConfig();
} catch (err: any) {
alert(err.response?.data?.detail || 'Password reset failed');
}
};
const handleReinit = async () => {
setReiniting(true);
setReinitMsg(null);
try {
const res = await api.delete('/config/reinit');
const d = res.data.deleted;
setReinitMsg({ type: 'success', text: `PURGED: ${d.logs} logs, ${d.bounties} bounties, ${d.attackers} attacker profiles` });
setConfirmReinit(false);
} catch (err: any) {
setReinitMsg({ type: 'error', text: err.response?.data?.detail || 'REINIT FAILED' });
} finally {
setReiniting(false);
}
};
if (loading) {
return (
<div className="logs-section">
<div className="loader">LOADING CONFIGURATION...</div>
</div>
);
}
if (!config) {
return (
<div className="logs-section">
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>FAILED TO LOAD CONFIGURATION</p>
</div>
</div>
);
}
const tabs: { key: string; label: string; icon: React.ReactNode }[] = [
{ key: 'limits', label: 'DEPLOYMENT LIMITS', icon: <Sliders size={14} /> },
...(config.users
? [{ 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 (
<div className="logs-section">
<div className="section-header">
<Settings size={20} />
<h2>SYSTEM CONFIGURATION</h2>
<div className="config-page">
<div className="logs-section">
<div className="section-header">
<Shield size={20} />
<h2>SYSTEM CONFIGURATION</h2>
</div>
</div>
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
<p>CONFIGURATION READ-ONLY MODE ACTIVE.</p>
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Config view placeholder)</p>
<div className="config-tabs">
{tabs.map((tab) => (
<button
key={tab.key}
className={`config-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key as any)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* DEPLOYMENT LIMITS TAB */}
{activeTab === 'limits' && (
<div className="config-panel">
<div className="config-field">
<span className="config-label">MAXIMUM DECKIES PER DEPLOYMENT</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="number"
min={1}
max={500}
value={limitInput}
onChange={(e) => setLimitInput(e.target.value)}
/>
<div className="preset-buttons">
{[10, 50, 100, 200].map((v) => (
<button
key={v}
className={`preset-btn ${limitInput === String(v) ? 'active' : ''}`}
onClick={() => setLimitInput(String(v))}
>
{v}
</button>
))}
</div>
<button
className="save-btn"
onClick={handleSaveLimit}
disabled={limitSaving}
>
<Save size={14} />
{limitSaving ? 'SAVING...' : 'SAVE'}
</button>
</div>
{limitMsg && (
<span className={limitMsg.type === 'success' ? 'config-success' : 'config-error'}>
{limitMsg.text}
</span>
)}
</>
) : (
<span className="config-value">{config.deployment_limit}</span>
)}
</div>
</div>
)}
{/* USER MANAGEMENT TAB (only if server sent users) */}
{activeTab === 'users' && config.users && (
<div className="config-panel">
<div className="users-table-container">
<table className="users-table">
<thead>
<tr>
<th>USERNAME</th>
<th>ROLE</th>
<th>STATUS</th>
<th>ACTIONS</th>
</tr>
</thead>
<tbody>
{config.users.map((user) => (
<tr key={user.uuid}>
<td>{user.username}</td>
<td>
<span className={`role-badge ${user.role}`}>{user.role.toUpperCase()}</span>
</td>
<td>
{user.must_change_password && (
<span className="must-change-badge">MUST CHANGE PASSWORD</span>
)}
</td>
<td>
<div className="user-actions">
{/* Role change dropdown */}
<select
className="role-select"
value={user.role}
onChange={(e) => handleRoleChange(user.uuid, e.target.value)}
>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
{/* Reset password */}
{resetTarget === user.uuid ? (
<div className="confirm-dialog">
<input
type="password"
placeholder="New password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
style={{ width: '140px' }}
/>
<button className="action-btn" onClick={() => handleResetPassword(user.uuid)}>
SET
</button>
<button className="action-btn" onClick={() => { setResetTarget(null); setResetPassword(''); }}>
CANCEL
</button>
</div>
) : (
<button
className="action-btn"
onClick={() => setResetTarget(user.uuid)}
>
<Key size={12} />
RESET
</button>
)}
{/* Delete */}
{confirmDelete === user.uuid ? (
<div className="confirm-dialog">
<span>CONFIRM?</span>
<button className="action-btn danger" onClick={() => handleDeleteUser(user.uuid)}>
YES
</button>
<button className="action-btn" onClick={() => setConfirmDelete(null)}>
NO
</button>
</div>
) : (
<button
className="action-btn danger"
onClick={() => setConfirmDelete(user.uuid)}
>
<Trash2 size={12} />
DELETE
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="add-user-section">
<form className="add-user-form" onSubmit={handleAddUser}>
<div className="form-group">
<label>USERNAME</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
minLength={1}
maxLength={64}
/>
</div>
<div className="form-group">
<label>PASSWORD</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
maxLength={72}
/>
</div>
<div className="form-group">
<label>ROLE</label>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value as 'admin' | 'viewer')}
>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit" className="save-btn" disabled={addingUser}>
<UserPlus size={14} />
{addingUser ? 'CREATING...' : 'ADD USER'}
</button>
{userMsg && (
<span className={userMsg.type === 'success' ? 'config-success' : 'config-error'}>
{userMsg.text}
</span>
)}
</form>
</div>
</div>
)}
{/* GLOBAL VALUES TAB */}
{activeTab === 'globals' && (
<div className="config-panel">
<div className="config-field">
<span className="config-label">GLOBAL MUTATION INTERVAL</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="text"
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="30m"
/>
<button
className="save-btn"
onClick={handleSaveInterval}
disabled={intervalSaving}
>
<Save size={14} />
{intervalSaving ? 'SAVING...' : 'SAVE'}
</button>
</div>
<span className="interval-hint">
FORMAT: &lt;number&gt;&lt;unit&gt; m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M)
</span>
{intervalMsg && (
<span className={intervalMsg.type === 'success' ? 'config-success' : 'config-error'}>
{intervalMsg.text}
</span>
)}
</>
) : (
<span className="config-value">{config.global_mutation_interval}</span>
)}
</div>
</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' }}>
<div className="config-field" style={{ marginBottom: 0 }}>
<span className="config-label" style={{ color: '#ff4141' }}>
<AlertTriangle size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
DANGER ZONE DEVELOPER MODE
</span>
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible.
</p>
{!confirmReinit ? (
<button
className="action-btn danger"
onClick={() => setConfirmReinit(true)}
style={{ padding: '8px 16px', fontSize: '0.8rem' }}
>
<Trash2 size={14} />
PURGE ALL DATA
</button>
) : (
<div className="confirm-dialog">
<span>THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE?</span>
<button
className="action-btn danger"
onClick={handleReinit}
disabled={reiniting}
style={{ padding: '6px 16px' }}
>
{reiniting ? 'PURGING...' : 'YES, PURGE'}
</button>
<button
className="action-btn"
onClick={() => setConfirmReinit(false)}
style={{ padding: '6px 16px' }}
>
CANCEL
</button>
</div>
)}
{reinitMsg && (
<span className={reinitMsg.type === 'success' ? 'config-success' : 'config-error'} style={{ marginTop: '8px' }}>
{reinitMsg.text}
</span>
)}
</div>
</div>
)}
</div>
);
};
// ─── 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'];
}
// 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 (
<span
className="chip dim-chip"
style={{ marginLeft: 8 }}
title="LLM enrichment disabled (DECNET_REALISM_LLM unset or --no-llm)"
>
LLM OFF
</span>
);
}
const breaker = realism.llm_breaker_state ?? 'closed';
const breakerColor =
breaker === 'open' ? '#ff5555'
: breaker === 'half_open' ? '#ffaa00'
: 'var(--matrix)';
const tooltip = [
`Backend: ${realism.llm_backend ?? '?'}`,
realism.llm_model ? `Model: ${realism.llm_model}` : null,
`Circuit breaker: ${breaker}`,
].filter(Boolean).join('\n');
return (
<span
className="chip dim-chip"
style={{ marginLeft: 8, display: 'inline-flex', alignItems: 'center', gap: 4 }}
title={tooltip}
>
<span style={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
backgroundColor: breakerColor,
}} />
LLM {(realism.llm_backend ?? 'on').toUpperCase()}
</span>
);
};
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> &lt; 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;
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 (
<tr key={w.name}>
<td><span className={dotClass(w.status)} /></td>
<td style={{ fontWeight: 700, letterSpacing: 1 }}>
{w.name.toUpperCase()}
{realism && <RealismBadge realism={realism} />}
</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>
);
};

View File

@@ -0,0 +1,170 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { X, Lock, Copy, Check } from '../icons';
import { useToast } from './Toasts/useToast';
export interface CredentialReuseRow {
id: string;
secret_sha256: string;
secret_kind: string;
principal: string | null;
principal_key: string;
attacker_uuids: string[];
attacker_ips: string[];
deckies: string[];
services: string[];
target_count: number;
attempt_count: number;
confidence: number;
first_seen: string;
last_seen: string;
updated_at: string;
secret_printable: string | null;
secret_b64: string | null;
}
interface Props {
row: CredentialReuseRow;
onClose: () => void;
}
const CredentialReuseInspector: React.FC<Props> = ({ row, onClose }) => {
const { push } = useToast();
const navigate = useNavigate();
const isPlain = row.secret_kind === 'plaintext';
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
push({ text: `${label} COPIED`, tone: 'matrix', icon: 'copy' });
} catch {
push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' });
}
};
return (
<div
className="credentials-drawer-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="credentials-drawer">
<div className="bd-head">
<h3>
<Lock size={14} />
<span>REUSE #{row.id.slice(0, 8)}</span>
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="bd-body">
<div className="kvs">
<div className="k">SECRET KIND</div>
<div className="v">
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{row.secret_kind.toUpperCase()}
</span>
</div>
<div className="k">PRINCIPAL</div>
<div className="v">{row.principal ?? <span className="dim"></span>}</div>
<div className="k">TARGETS</div>
<div className="v"><span className="attempt-pill">{row.target_count}</span></div>
<div className="k">ATTEMPTS</div>
<div className="v">{row.attempt_count}</div>
<div className="k">CONFIDENCE</div>
<div className="v">{row.confidence.toFixed(2)}</div>
<div className="k">FIRST SEEN</div>
<div className="v">{new Date(row.first_seen).toLocaleString()}</div>
<div className="k">LAST SEEN</div>
<div className="v">{new Date(row.last_seen).toLocaleString()}</div>
</div>
<div>
<div className="type-label">DECKIES × SERVICES</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th></th>
{row.services.map(svc => (
<th key={svc}>{svc.toUpperCase()}</th>
))}
</tr>
</thead>
<tbody>
{row.deckies.map(decky => (
<tr key={decky}>
<td className="violet-accent">{decky}</td>
{row.services.map(svc => (
<td key={svc} style={{ textAlign: 'center' }}>
<Check size={12} className="matrix-text" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<div className="type-label">ATTACKERS</div>
{row.attacker_uuids.length === 0 ? (
<div className="dim" style={{ fontSize: '0.75rem', padding: '6px 0' }}>
PROFILING PENDING credential captures precede attacker
profiling; this row will populate once the profiler runs.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{row.attacker_uuids.map((uuid, i) => (
<div
key={uuid}
onClick={() => navigate(`/attackers/${uuid}`)}
style={{
display: 'flex',
gap: 8,
alignItems: 'baseline',
cursor: 'pointer',
textDecoration: 'underline dotted',
}}
>
<span className="matrix-text">{uuid.slice(0, 8)}</span>
<span className="dim" style={{ fontSize: '0.72rem' }}>
{row.attacker_ips[i] ?? ''}
</span>
</div>
))}
</div>
)}
</div>
<div>
<div className="type-label">{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}</div>
<pre className="code-block">
<span className="ck">printable:</span>{' '}
<span className="cs">{row.secret_printable ?? '—'}</span>{'\n'}
<span className="ck">b64:</span>{' '}
<span className="cs">{row.secret_b64 ?? '—'}</span>
</pre>
</div>
<div>
<div className="type-label">SECRET SHA-256</div>
<div className="hash-row">
<span className="hash-text">{row.secret_sha256}</span>
<button
className="icon-btn"
onClick={() => copy(row.secret_sha256, 'HASH')}
aria-label="Copy hash"
>
<Copy size={12} />
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CredentialReuseInspector;

View File

@@ -0,0 +1,274 @@
.credentials-root {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Buttons scoped under root (mirrors DeckyFleet/LiveLogs pattern) */
.credentials-root .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
}
.credentials-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.credentials-root .btn.violet { border-color: var(--violet); color: var(--violet); }
.credentials-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.credentials-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.credentials-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); }
.credentials-root .btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* Header controls */
.credentials-root .controls-row {
display: flex;
gap: 12px;
align-items: stretch;
}
.credentials-root .controls-row .search-container { flex: 1; max-width: none; }
/* Segmented service filter */
.credentials-root .seg-group {
display: flex;
border: 1px solid var(--border);
background: var(--panel);
flex-wrap: wrap;
}
.credentials-root .seg-group button {
padding: 8px 14px;
font-size: 0.68rem;
letter-spacing: 1.5px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: rgba(0, 255, 65, 0.6);
cursor: pointer;
font-family: inherit;
}
.credentials-root .seg-group button:last-child { border-right: none; }
.credentials-root .seg-group button.active {
background: var(--violet-tint-10);
color: var(--violet);
}
.credentials-root .seg-group button:hover:not(.active) { color: var(--matrix); }
/* Table row interactivity */
.credentials-root .logs-table tr.clickable { cursor: pointer; }
.credentials-root .logs-table tr.clickable:hover { background: rgba(238, 130, 238, 0.04); }
.credentials-root .logs-table td .attacker-link {
text-decoration: underline dotted;
cursor: pointer;
}
.credentials-root .logs-table td .data-preview {
font-size: 0.74rem;
opacity: 0.7;
max-width: 400px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.credentials-root .logs-table td .secret-cell {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--matrix);
max-width: 320px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.credentials-root .logs-table td .secret-cell.hashed {
opacity: 0.7;
color: rgba(238, 130, 238, 0.85);
}
.credentials-root .logs-table td .principal-cell {
font-size: 0.8rem;
}
.credentials-root .logs-table td .attempt-pill {
font-size: 0.7rem;
padding: 2px 8px;
border: 1px solid var(--border);
letter-spacing: 1px;
opacity: 0.85;
}
/* Empty state */
.credentials-root .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 50px 20px;
opacity: 0.45;
}
.credentials-root .empty-state .type-label {
font-size: 0.7rem;
letter-spacing: 2px;
}
/* Pagination */
.credentials-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; }
.credentials-root .pager button {
padding: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--matrix);
display: flex;
cursor: pointer;
}
.credentials-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.credentials-root .pager button:hover:not(:disabled) { border-color: var(--accent); }
/* ── Drawer ────────────────────────────────────────────── */
.credentials-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: flex-end;
z-index: 1000;
animation: cd-fade 0.15s ease;
}
@keyframes cd-fade { from { opacity: 0; } to { opacity: 1; } }
.credentials-drawer {
width: min(620px, 100%);
height: 100%;
background: var(--bg);
border-left: 1px solid var(--violet);
box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1);
overflow-y: auto;
display: flex;
flex-direction: column;
animation: cd-slide 0.2s ease;
}
@keyframes cd-slide { from { transform: translateX(30px); opacity: 0.6; } to { transform: none; opacity: 1; } }
.credentials-drawer .bd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.credentials-drawer .bd-head h3 {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
letter-spacing: 3px;
color: var(--violet);
margin: 0;
}
.credentials-drawer .close-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
display: flex;
padding: 4px;
cursor: pointer;
}
.credentials-drawer .close-btn:hover { border-color: var(--accent); }
.credentials-drawer .bd-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.credentials-drawer .kvs {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px 12px;
font-size: 0.8rem;
}
.credentials-drawer .kvs .k {
opacity: 0.55;
font-size: 0.7rem;
letter-spacing: 1.5px;
}
.credentials-drawer .kvs .v { word-break: break-all; }
.credentials-drawer .kvs .attacker-link {
text-decoration: underline dotted;
cursor: pointer;
color: var(--matrix);
}
.credentials-drawer .violet-accent { color: var(--violet); }
.credentials-drawer .type-label {
font-size: 0.68rem;
letter-spacing: 2px;
opacity: 0.6;
margin-bottom: 8px;
}
.credentials-drawer .code-block {
background: var(--panel);
border: 1px solid var(--border);
border-left: 2px solid var(--violet);
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--matrix);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
overflow-x: auto;
}
.credentials-drawer .code-block .ck { color: rgba(238, 130, 238, 0.9); }
.credentials-drawer .code-block .cs { color: var(--matrix); }
.credentials-drawer .hash-row {
display: flex;
align-items: center;
gap: 8px;
}
.credentials-drawer .hash-row .hash-text {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--matrix);
word-break: break-all;
flex: 1;
}
.credentials-drawer .icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
padding: 4px 6px;
display: inline-flex;
cursor: pointer;
}
.credentials-drawer .icon-btn:hover { border-color: var(--accent); }
.credentials-drawer .bd-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.credentials-drawer .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.8;
}
.credentials-drawer .btn.ghost:hover { opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); }

View File

@@ -0,0 +1,429 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Lock, Search, ChevronLeft, ChevronRight, Filter, ChevronRight as ChevR,
Target, RefreshCw,
} from '../icons';
import api from '../utils/api';
import CredentialsInspector from './CredentialsInspector';
import type { CredentialEntry } from './CredentialsInspector';
import CredentialReuseInspector from './CredentialReuseInspector';
import type { CredentialReuseRow } from './CredentialReuseInspector';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import './Dashboard.css';
import './Credentials.css';
const truncHash = (h: string | null | undefined, n = 12): string =>
h ? `${h.slice(0, n)}` : '—';
const CREDS_LIMIT = 50;
const REUSE_LIMIT = 25;
const REUSE_MAP_CAP = 500;
type Tab = 'creds' | 'reuse';
const reuseKey = (sha: string, kind: string, principal: string | null): string =>
`${sha}|${kind}|${principal ?? ''}`;
const Credentials: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const serviceFilter = searchParams.get('service') || '';
const tab = (searchParams.get('tab') === 'reuse' ? 'reuse' : 'creds') as Tab;
const page = parseInt(searchParams.get('page') || '1');
const [creds, setCreds] = useState<CredentialEntry[]>([]);
const [credsTotal, setCredsTotal] = useState(0);
const [reuseRows, setReuseRows] = useState<CredentialReuseRow[]>([]);
const [reuseTotal, setReuseTotal] = useState(0);
const [reuseMap, setReuseMap] = useState<Map<string, { id: string; target_count: number }>>(new Map());
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(query);
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null);
const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
// ── Fetch credentials (CREDS tab + always for badge totals)
useEffect(() => {
if (tab !== 'creds') return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * CREDS_LIMIT;
let url = `/credentials?limit=${CREDS_LIMIT}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`;
const res = await api.get(url);
if (cancelled) return;
setCreds(res.data.data);
setCredsTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credentials', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, query, serviceFilter, page, refreshTick]);
// ── Fetch reuse rows (REUSE tab)
useEffect(() => {
if (tab !== 'reuse') return;
let cancelled = false;
(async () => {
setLoading(true);
try {
const offset = (page - 1) * REUSE_LIMIT;
const res = await api.get(`/credential-reuse?limit=${REUSE_LIMIT}&offset=${offset}`);
if (cancelled) return;
setReuseRows(res.data.data);
setReuseTotal(res.data.total);
} catch (err) {
console.error('Failed to fetch credential-reuse', err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tab, page, refreshTick]);
// ── Build reuse-map for the badge column on the CREDS tab
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api.get(`/credential-reuse?limit=${REUSE_MAP_CAP}&offset=0`);
if (cancelled) return;
const m = new Map<string, { id: string; target_count: number }>();
(res.data.data as CredentialReuseRow[]).forEach(r => {
m.set(reuseKey(r.secret_sha256, r.secret_kind, r.principal), {
id: r.id,
target_count: r.target_count,
});
});
setReuseMap(m);
} catch {
/* badge column degrades silently to "—" */
}
})();
return () => { cancelled = true; };
}, [refreshTick]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, service: serviceFilter, tab, page: '1' });
};
const setPage = (p: number) =>
setSearchParams({ q: query, service: serviceFilter, tab, page: p.toString() });
const setService = (s: string) =>
setSearchParams({ q: query, service: s, tab, page: '1' });
const setTab = (t: Tab) =>
setSearchParams({ q: query, service: serviceFilter, tab: t, page: '1' });
const limit = tab === 'creds' ? CREDS_LIMIT : REUSE_LIMIT;
const total = tab === 'creds' ? credsTotal : reuseTotal;
const totalPages = Math.max(1, Math.ceil(total / limit));
// Service chips derived from visible creds page
const services = useMemo(() => {
const set = new Set<string>();
creds.forEach(c => set.add(c.service));
return Array.from(set).sort();
}, [creds]);
const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length;
const hashedCount = creds.length - plaintextCount;
const openReuseFromCred = async (key: string) => {
const hit = reuseMap.get(key);
if (!hit) return;
try {
const res = await api.get(`/credential-reuse/${hit.id}`);
setSelectedReuse(res.data as CredentialReuseRow);
} catch (err) {
console.error('Failed to fetch reuse detail', err);
}
};
return (
<div className="credentials-root">
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Lock size={22} className="violet-accent" />
<h1>CREDENTIAL VAULT</h1>
</div>
<span className="page-sub">
{tab === 'creds'
? `${credsTotal.toLocaleString()} CAPTURED · ${plaintextCount} PLAINTEXT · ${hashedCount} CHALLENGED`
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
</span>
</div>
</div>
<div className="seg-group" role="tablist" style={{ marginBottom: 12 }}>
<button
type="button"
className={tab === 'creds' ? 'active' : ''}
onClick={() => setTab('creds')}
>
CREDS
</button>
<button
type="button"
className={tab === 'reuse' ? 'active' : ''}
onClick={() => setTab('reuse')}
>
REUSE
</button>
</div>
{tab === 'creds' && (
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by IP, decky, principal, secret..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<div className="seg-group" role="tablist">
<button
type="button"
className={serviceFilter === '' ? 'active' : ''}
onClick={() => setService('')}
>
ALL
</button>
{services.map(svc => (
<button
key={svc}
type="button"
className={serviceFilter === svc ? 'active' : ''}
onClick={() => setService(svc)}
>
{svc.toUpperCase()}
</button>
))}
</div>
</form>
)}
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>
{tab === 'creds'
? `${credsTotal.toLocaleString()} CREDENTIALS CAPTURED`
: `${reuseTotal.toLocaleString()} REUSE FINDINGS`}
</span>
</div>
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
<button onClick={() => setRefreshTick(t => t + 1)} aria-label="Refresh">
<RefreshCw size={14} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
{tab === 'creds' ? (
<table className="logs-table">
<thead>
<tr>
<th>LAST SEEN</th>
<th>DECKY</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>PRINCIPAL</th>
<th>SECRET</th>
<th>KIND</th>
<th>HITS</th>
<th>REUSE</th>
<th></th>
</tr>
</thead>
<tbody>
{creds.length > 0 ? creds.map(c => {
const isPlain = c.secret_kind === 'plaintext';
const secretText = isPlain
? (c.secret_printable ?? '—')
: truncHash(c.secret_sha256, 16);
const key = reuseKey(c.secret_sha256, c.secret_kind, c.principal);
const reuseHit = reuseMap.get(key);
return (
<tr key={c.id} className="clickable" onClick={() => setSelectedCred(c)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(c.last_seen).toLocaleTimeString()}
</td>
<td className="violet-accent">{c.decky_name}</td>
<td><span className="chip dim-chip">{c.service}</span></td>
<td>
<span
className="matrix-text attacker-link"
onClick={(e) => {
e.stopPropagation();
navigate(`/attackers?q=${encodeURIComponent(c.attacker_ip)}`);
}}
>
{c.attacker_ip}
</span>
</td>
<td className="principal-cell">
{c.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`secret-cell${isPlain ? '' : ' hashed'}`} title={secretText}>
{secretText}
</span>
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{c.secret_kind.toUpperCase()}
</span>
</td>
<td>
<span className="attempt-pill">{c.attempt_count}</span>
</td>
<td>
{reuseHit ? (
<span
className="attempt-pill"
style={{ cursor: 'pointer', color: 'var(--violet)' }}
title="Open reuse finding"
onClick={(e) => {
e.stopPropagation();
openReuseFromCred(key);
}}
>
×{reuseHit.target_count}
</span>
) : (
<span className="dim"></span>
)}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={10}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING CREDENTIALS…' : 'NO CREDENTIALS YET'}
hint={loading ? undefined : 'captured auth attempts will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
) : (
<table className="logs-table">
<thead>
<tr>
<th>LAST SEEN</th>
<th>PRINCIPAL</th>
<th>KIND</th>
<th>TARGETS</th>
<th>ATTEMPTS</th>
<th>DECKIES</th>
<th>SERVICES</th>
<th></th>
</tr>
</thead>
<tbody>
{reuseRows.length > 0 ? reuseRows.map(r => {
const isPlain = r.secret_kind === 'plaintext';
const moreDeckies = Math.max(0, r.deckies.length - 3);
const moreServices = Math.max(0, r.services.length - 3);
return (
<tr key={r.id} className="clickable" onClick={() => setSelectedReuse(r)}>
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
{new Date(r.last_seen).toLocaleTimeString()}
</td>
<td className="principal-cell">
{r.principal ?? <span className="dim"></span>}
</td>
<td>
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{r.secret_kind.toUpperCase()}
</span>
</td>
<td><span className="attempt-pill">{r.target_count}</span></td>
<td><span className="attempt-pill">{r.attempt_count}</span></td>
<td>
{r.deckies.slice(0, 3).map(d => (
<span key={d} className="chip dim-chip" style={{ marginRight: 4 }}>{d}</span>
))}
{moreDeckies > 0 && <span className="dim">+{moreDeckies}</span>}
</td>
<td>
{r.services.slice(0, 3).map(s => (
<span key={s} className="chip dim-chip" style={{ marginRight: 4 }}>{s}</span>
))}
{moreServices > 0 && <span className="dim">+{moreServices}</span>}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
);
}) : (
<tr>
<td colSpan={8}>
<EmptyState
icon={Target}
title={loading ? 'RETRIEVING REUSE…' : 'NO REUSE FINDINGS YET'}
hint={loading ? undefined : 'a credential captured on ≥2 deckies will land here'}
/>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{selectedCred && (
<CredentialsInspector
cred={selectedCred}
onClose={() => setSelectedCred(null)}
onSelectAttacker={(ip) => {
setSelectedCred(null);
navigate(`/attackers?q=${encodeURIComponent(ip)}`);
}}
/>
)}
{selectedReuse && (
<CredentialReuseInspector
row={selectedReuse}
onClose={() => setSelectedReuse(null)}
/>
)}
</div>
);
};
export default Credentials;

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { X, Lock, Copy, Send, Ban } from '../icons';
import { useToast } from './Toasts/useToast';
export interface CredentialEntry {
id: number;
attacker_ip: string;
decky_name: string;
service: string;
principal: string | null;
secret_kind: string;
secret_sha256: string;
secret_b64: string | null;
secret_printable: string | null;
outcome: string | null;
fields: any;
first_seen: string;
last_seen: string;
attempt_count: number;
}
interface Props {
cred: CredentialEntry;
onClose: () => void;
onSelectAttacker: (ip: string) => void;
}
const CredentialsInspector: React.FC<Props> = ({ cred, onClose, onSelectAttacker }) => {
const { push } = useToast();
const isPlain = cred.secret_kind === 'plaintext';
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
push({ text: `${label} COPIED`, tone: 'matrix', icon: 'copy' });
} catch {
push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' });
}
};
const copyJson = () => copy(JSON.stringify(cred, null, 2), 'JSON');
const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' });
const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' });
return (
<div
className="credentials-drawer-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="credentials-drawer">
<div className="bd-head">
<h3>
<Lock size={14} />
<span>CREDENTIAL #{cred.id}</span>
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="bd-body">
<div className="kvs">
<div className="k">SECRET KIND</div>
<div className="v">
<span className={`chip ${isPlain ? 'matrix' : 'violet'}`}>
{cred.secret_kind.toUpperCase()}
</span>
</div>
<div className="k">OUTCOME</div>
<div className="v">
{cred.outcome
? <span className="chip dim-chip">{cred.outcome.toUpperCase()}</span>
: <span className="dim"></span>}
</div>
<div className="k">DECKY</div>
<div className="v violet-accent">{cred.decky_name}</div>
<div className="k">SERVICE</div>
<div className="v"><span className="chip dim-chip">{cred.service}</span></div>
<div className="k">PRINCIPAL</div>
<div className="v">{cred.principal ?? <span className="dim"></span>}</div>
<div className="k">ATTACKER</div>
<div className="v">
<span
className="attacker-link"
onClick={() => onSelectAttacker(cred.attacker_ip)}
>
{cred.attacker_ip}
</span>
</div>
<div className="k">ATTEMPTS</div>
<div className="v">{cred.attempt_count}</div>
<div className="k">FIRST SEEN</div>
<div className="v">{new Date(cred.first_seen).toLocaleString()}</div>
<div className="k">LAST SEEN</div>
<div className="v">{new Date(cred.last_seen).toLocaleString()}</div>
</div>
<div>
<div className="type-label">{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}</div>
<pre className="code-block">
<span className="ck">printable:</span>{' '}
<span className="cs">{cred.secret_printable ?? '—'}</span>{'\n'}
<span className="ck">b64:</span>{' '}
<span className="cs">{cred.secret_b64 ?? '—'}</span>
</pre>
</div>
<div>
<div className="type-label">SECRET SHA-256</div>
<div className="hash-row">
<span className="hash-text">{cred.secret_sha256}</span>
<button
className="icon-btn"
onClick={() => copy(cred.secret_sha256, 'HASH')}
aria-label="Copy hash"
>
<Copy size={12} />
</button>
</div>
</div>
{cred.fields && Object.keys(cred.fields || {}).length > 0 && (
<div>
<div className="type-label">SERVICE FIELDS</div>
<pre className="code-block">{JSON.stringify(cred.fields, null, 2)}</pre>
</div>
)}
<div>
<div className="type-label">EXPORT</div>
<div className="bd-actions">
<button className="btn ghost" onClick={copyJson}><Copy size={12} /> COPY JSON</button>
<button className="btn ghost" onClick={stubMisp}><Send size={12} /> SEND TO MISP</button>
<button className="btn ghost" onClick={stubBlocklist}><Ban size={12} /> BLOCKLIST IP</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CredentialsInspector;

View File

@@ -1,74 +1,383 @@
.dashboard {
display: flex;
flex-direction: column;
gap: 32px;
gap: 24px;
min-height: 100%;
}
/* Page header */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
gap: 24px;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.page-title-group h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
}
.page-sub {
font-size: 0.7rem;
opacity: 0.5;
letter-spacing: 1px;
}
/* Chips */
.chip {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--accent);
color: var(--accent);
background: var(--accent-tint-10);
letter-spacing: 1px;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.chip.violet {
border-color: var(--violet);
color: var(--violet);
background: var(--violet-tint-10);
}
.chip.matrix {
border-color: var(--matrix);
color: var(--matrix);
background: var(--matrix-tint-10);
}
.chip.dim-chip {
border-color: var(--border-color);
color: rgba(0, 255, 65, 0.6);
background: transparent;
}
.chip.alert-chip {
border-color: var(--alert);
color: var(--alert);
background: rgba(255, 65, 65, 0.1);
}
/* Key-value chips in the live-feed event column. Values are unbounded
(attacker-supplied command strings, URLs, base64 payloads), so these
must wrap inside the chip rather than inherit the default nowrap —
otherwise a single `cmd: echo <2KB>` blows out the table width. */
.chip.chip-kv {
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
max-width: 100%;
align-items: flex-start;
line-height: 1.35;
}
.chip.chip-kv > .dim {
flex-shrink: 0;
}
/* Breach banner */
.breach-banner {
background: rgba(255, 65, 65, 0.1);
border: 1px solid var(--alert);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 14px;
font-size: 0.78rem;
letter-spacing: 1.5px;
color: var(--alert);
}
.breach-banner .pulse {
width: 10px;
height: 10px;
background: var(--alert);
border-radius: 50%;
animation: decnet-pulse 0.7s infinite alternate;
flex-shrink: 0;
}
.breach-banner button {
background: transparent;
border: 1px solid var(--alert);
color: var(--alert);
padding: 6px 14px;
font-size: 0.7rem;
letter-spacing: 1.5px;
cursor: pointer;
font-family: inherit;
}
.breach-banner button:hover {
background: rgba(255, 65, 65, 0.15);
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-card {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 24px;
padding: 16px 18px;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.stat-card:hover {
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
border-color: var(--accent);
box-shadow: var(--accent-glow);
transform: translateY(-2px);
}
.stat-icon {
color: var(--accent-color);
filter: drop-shadow(var(--violet-glow));
.stat-card.alert {
border-color: rgba(255, 65, 65, 0.4);
}
.stat-content {
.stat-card .row {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.stat-icon {
color: var(--accent);
display: flex;
align-items: center;
}
.stat-label {
font-size: 0.7rem;
font-size: 0.65rem;
opacity: 0.6;
letter-spacing: 1px;
letter-spacing: 1.5px;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.stat-value .dim {
opacity: 0.5;
}
.stat-delta {
font-size: 0.65rem;
letter-spacing: 1px;
opacity: 0.8;
display: flex;
align-items: center;
gap: 6px;
}
.stat-delta.up {
color: var(--alert);
}
/* Sparkline */
.spark {
display: flex;
align-items: flex-end;
gap: 2px;
height: 22px;
min-width: 80px;
}
.spark span {
flex: 1;
background: var(--accent);
opacity: 0.5;
min-height: 2px;
}
.spark.alert span {
background: var(--alert);
}
/* Section header (logs, panels) */
.logs-section {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
min-height: 0;
}
.section-header {
padding: 16px 24px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.8rem;
letter-spacing: 2px;
font-weight: 700;
}
.section-actions {
display: flex;
gap: 8px;
align-items: center;
font-size: 0.62rem;
opacity: 0.6;
letter-spacing: 1px;
}
.section-header h2 {
font-size: 0.9rem;
letter-spacing: 2px;
}
/* Dashboard grid */
.dash-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
flex: 1;
min-height: 0;
}
.dash-grid > .logs-section .logs-table-container {
flex: 1;
max-height: none;
}
.dash-side {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
}
.dash-side > .logs-section {
flex: 1;
}
/* Attacker/siege rows */
.attacker-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.75rem;
padding: 6px 0;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
cursor: pointer;
}
.attacker-row:hover {
background: rgba(0, 255, 65, 0.03);
}
.attacker-row:last-child {
border-bottom: none;
}
.attacker-bar-wrap {
flex: 1;
height: 4px;
background: rgba(48, 54, 61, 0.5);
position: relative;
}
.attacker-bar {
height: 100%;
background: var(--matrix);
}
.attacker-bar.hot {
background: var(--alert);
}
.panel-body {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-empty {
padding: 20px 16px;
text-align: center;
opacity: 0.4;
font-size: 0.7rem;
letter-spacing: 1px;
}
/* Status dots (hot / warn / active) */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active {
background: var(--matrix);
box-shadow: 0 0 8px var(--matrix);
}
.status-dot.warn {
background: #ffaa00;
box-shadow: 0 0 6px rgba(255, 170, 0, 0.6);
}
.status-dot.hot {
background: var(--alert);
box-shadow: 0 0 8px var(--alert);
animation: decnet-pulse 1s infinite alternate;
}
/* Row-enter animation */
@keyframes row-enter {
from {
background: rgba(0, 255, 65, 0.2);
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: none;
}
}
.row-enter {
animation: row-enter 0.6s var(--ease);
}
/* Logs table (existing) */
.logs-table-container {
overflow-x: auto;
overflow-y: auto;
max-height: 420px;
min-height: 0;
}
.logs-table {
@@ -79,21 +388,35 @@
}
.logs-table th {
padding: 12px 24px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
opacity: 0.5;
font-weight: normal;
position: sticky;
top: 0;
background: var(--secondary-color);
z-index: 1;
}
.logs-table td {
padding: 12px 24px;
padding: 10px 20px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.logs-table tr:hover {
.logs-table tr:not(.empty-row):hover {
background-color: rgba(0, 255, 65, 0.03);
}
.logs-table tr.empty-row td {
border-bottom: none;
height: 360px;
}
.logs-table tr.empty-row .empty-state {
height: 100%;
min-height: 0;
}
.raw-line {
max-width: 400px;
overflow: hidden;
@@ -127,3 +450,118 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Attacker Profiles (Attackers page) */
.attacker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
padding: 16px;
}
.attacker-card {
background: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 16px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.attacker-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.traversal-badge {
font-size: 0.65rem;
padding: 2px 8px;
border: 1px solid var(--accent-color);
background: rgba(238, 130, 238, 0.1);
color: var(--accent-color);
letter-spacing: 2px;
}
.service-badge {
font-size: 0.7rem;
padding: 2px 8px;
border: 1px solid var(--text-color);
background: rgba(0, 255, 65, 0.05);
color: var(--text-color);
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.8rem;
letter-spacing: 2px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.back-button:hover {
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
/* Fingerprint cards */
.fp-card {
border: 1px solid var(--border-color);
background: rgba(0, 0, 0, 0.2);
transition: border-color 0.15s ease;
}
.fp-card:hover {
border-color: var(--accent-color);
}
.fp-card-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--border-color);
}
.fp-card-icon {
color: var(--accent-color);
display: flex;
align-items: center;
}
.fp-card-label {
font-size: 0.7rem;
letter-spacing: 2px;
opacity: 0.7;
}
.fp-card-body {
padding: 12px 16px;
}
/* Identity / Campaign fingerprint groupings — used inside a logs-section */
.fp-group {
margin-bottom: 12px;
}
.fp-group:last-child { margin-bottom: 0; }
.fp-group-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
letter-spacing: 2px;
opacity: 0.7;
margin-bottom: 6px;
}
.fp-group-items {
display: flex;
flex-wrap: wrap;
gap: 6px;
}

View File

@@ -1,6 +1,10 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './Dashboard.css';
import { Shield, Users, Activity, Clock } from 'lucide-react';
import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive, ShieldOff, Server } from '../icons';
import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer';
import EmptyState from './EmptyState/EmptyState';
interface Stats {
total_logs: number;
@@ -19,157 +23,500 @@ interface LogEntry {
raw_line: string;
fields: string | null;
msg: string | null;
severity?: string;
is_bounty?: boolean;
}
interface DashboardProps {
searchQuery: string;
}
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
const SPARK_LEN = 12;
function Sparkline({ data, alert }: { data: number[]; alert?: boolean }) {
const max = Math.max(...data, 1);
return (
<div className={`spark ${alert ? 'alert' : ''}`}>
{data.map((v, i) => (
<span
key={i}
style={{
height: `${(v / max) * 100}%`,
opacity: 0.4 + (v / max) * 0.6,
}}
/>
))}
</div>
);
}
function rollWindow(prev: number[], next: number): number[] {
const out = prev.slice(-SPARK_LEN + 1);
out.push(next);
while (out.length < SPARK_LEN) out.unshift(0);
return out;
}
function computeThreat(hits5m: number): ThreatLevel {
if (hits5m > 100) return 'critical';
if (hits5m > 50) return 'elevated';
return 'nominal';
}
function getSector(): string {
try {
const raw = localStorage.getItem('decnet_tweaks');
if (!raw) return 'PRODUCTION';
const t = JSON.parse(raw);
return (t?.sector || 'PRODUCTION').toString().toUpperCase();
} catch {
return 'PRODUCTION';
}
}
const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
const navigate = useNavigate();
const [stats, setStats] = useState<Stats | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, unknown> } | null>(null);
const [newestLogId, setNewestLogId] = useState<number | null>(null);
const [sparkTotal, setSparkTotal] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
const [sparkAttackers, setSparkAttackers] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
const [sparkBounties, setSparkBounties] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
const lastStatsRef = useRef<{ total: number; uniq: number; bounties: number } | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const connect = () => {
if (eventSourceRef.current) eventSourceRef.current.close();
const eventSource = new EventSource(url);
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}`;
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
} else if (payload.type === 'stats') {
setStats(payload.data);
setLoading(false);
const es = new EventSource(url);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
const incoming: LogEntry[] = payload.data;
if (incoming.length > 0) {
setNewestLogId(incoming[0].id);
}
setLogs(prev => [...incoming, ...prev].slice(0, 100));
} else if (payload.type === 'stats') {
setStats(payload.data);
setLoading(false);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
reconnectTimerRef.current = setTimeout(connect, 3000);
};
};
eventSource.onerror = (err) => {
console.error('SSE connection error, attempting to reconnect...', err);
};
connect();
return () => {
eventSource.close();
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
};
}, [searchQuery]);
// Tick once a second so the 5-min rolling window stays accurate even
// when logs haven't arrived.
const [nowTick, setNowTick] = useState(() => Date.now());
useEffect(() => {
const iv = setInterval(() => setNowTick(Date.now()), 1000);
return () => clearInterval(iv);
}, []);
// Derived metrics from live log buffer
const { hits5m, alertCount, uniqueAttackers5m, bountiesCount, deckiesUnderSiege, topAttackers } = useMemo(() => {
const cutoff = nowTick - 5 * 60_000;
const recent = logs.filter(l => {
const t = Date.parse(l.timestamp);
return !isNaN(t) && t >= cutoff;
});
const alertN = recent.filter(l => l.severity === 'warn' || l.is_bounty).length;
const uniq = new Set(recent.map(l => l.attacker_ip)).size;
const bounties = logs.filter(l => l.is_bounty).length;
const deckyHits = new Map<string, number>();
for (const l of recent) deckyHits.set(l.decky, (deckyHits.get(l.decky) || 0) + 1);
const siege = Array.from(deckyHits.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, hits]) => ({
name,
hits,
status: hits > 30 ? 'hot' : hits > 10 ? 'warn' : 'active',
}));
const attackerHits = new Map<string, number>();
for (const l of logs) attackerHits.set(l.attacker_ip, (attackerHits.get(l.attacker_ip) || 0) + 1);
const maxAttackerHits = Math.max(1, ...attackerHits.values());
const top = Array.from(attackerHits.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([ip, hits]) => ({
ip,
hits,
pct: Math.min(100, (hits / maxAttackerHits) * 100),
hot: hits > maxAttackerHits * 0.6,
}));
return {
hits5m: recent.length,
alertCount: alertN,
uniqueAttackers5m: uniq,
bountiesCount: bounties,
deckiesUnderSiege: siege,
topAttackers: top,
};
}, [logs, nowTick]);
const threat = computeThreat(hits5m);
// Broadcast stats + threat for Layout's listener
useEffect(() => {
if (!stats) return;
window.dispatchEvent(new CustomEvent('decnet:stats', {
detail: { ...stats, threat, hits_5m: hits5m, alert_count: alertCount },
}));
}, [stats, threat, hits5m, alertCount]);
// Roll sparklines on each stats frame
useEffect(() => {
if (!stats) return;
const total = stats.total_logs;
const uniq = stats.unique_attackers;
const last = lastStatsRef.current;
if (last) {
const dTotal = Math.max(0, total - last.total);
const dUniq = Math.max(0, uniq - last.uniq);
const dBounties = Math.max(0, bountiesCount - last.bounties);
setSparkTotal(prev => rollWindow(prev, dTotal));
setSparkAttackers(prev => rollWindow(prev, dUniq));
setSparkBounties(prev => rollWindow(prev, dBounties));
}
lastStatsRef.current = { total, uniq, bounties: bountiesCount };
}, [stats, bountiesCount]);
if (loading && !stats) return <div className="loader">INITIALIZING SENSORS...</div>;
const sector = getSector();
return (
<div className="dashboard">
<div className="stats-grid">
<StatCard
icon={<Activity size={32} />}
label="TOTAL INTERACTIONS"
value={stats?.total_logs || 0}
/>
<StatCard
icon={<Users size={32} />}
label="UNIQUE ATTACKERS"
value={stats?.unique_attackers || 0}
/>
<StatCard
icon={<Shield size={32} />}
label="ACTIVE DECKIES"
value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`}
/>
<div className="page-header">
<div className="page-title-group">
<h1>DASHBOARD</h1>
<span className="page-sub">SECTOR · {sector} · LIVE</span>
</div>
<div className="section-actions">
<span className="chip matrix fx-blink">
<span className="status-dot active" /> LIVE
</span>
</div>
</div>
<div className="logs-section">
<div className="section-header">
<Clock size={20} />
<h2>LIVE INTERACTION LOG</h2>
{threat === 'critical' && (
<div className="breach-banner">
<span className="pulse" />
<span style={{ flex: 1 }}>
ACTIVE BREACH {hits5m} hits in last 5 min · {uniqueAttackers5m} attackers
</span>
<button onClick={() => navigate('/live-logs')}>INSPECT SESSION</button>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER IP</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
let parsedFields: Record<string, string> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {
// Ignore parsing errors
}
}
)}
return (
<tr key={log.id}>
<td className="dim">{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {v}
</span>
))}
<div className="stats-grid">
<div className="stat-card">
<div className="row">
<span className="stat-label">TOTAL INTERACTIONS</span>
<div className="stat-icon"><Activity size={18} /></div>
</div>
<div className="stat-value">{(stats?.total_logs ?? 0).toLocaleString()}</div>
<div className="row">
<div className="stat-delta up">+{hits5m} in last 5m</div>
<Sparkline data={sparkTotal} />
</div>
</div>
<div className="stat-card alert">
<div className="row">
<span className="stat-label">UNIQUE ATTACKERS</span>
<div className="stat-icon"><Crosshair size={18} /></div>
</div>
<div className="stat-value">{(stats?.unique_attackers ?? 0).toLocaleString()}</div>
<div className="row">
<div className="stat-delta up">{uniqueAttackers5m} active in 5m</div>
<Sparkline data={sparkAttackers} alert />
</div>
</div>
<div className="stat-card">
<div className="row">
<span className="stat-label">ACTIVE DECKIES</span>
<div className="stat-icon"><Shield size={18} /></div>
</div>
<div className="stat-value">
{stats?.active_deckies ?? 0}
<span className="dim" style={{ fontSize: '1rem' }}> / {stats?.deployed_deckies ?? 0}</span>
</div>
<div className="row">
<div className="stat-delta">OF TOTAL FLEET</div>
</div>
</div>
<div className="stat-card">
<div className="row">
<span className="stat-label">BOUNTIES CAPTURED</span>
<div className="stat-icon"><Archive size={18} /></div>
</div>
<div className="stat-value">{bountiesCount.toLocaleString()}</div>
<div className="row">
<div className="stat-delta">THIS SESSION</div>
<Sparkline data={sparkBounties} />
</div>
</div>
</div>
<div className="dash-grid">
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Clock size={16} />
<span>LIVE INTERACTION FEED</span>
<span className="chip matrix fx-blink">
<span className="status-dot active" /> LIVE
</span>
</div>
<div className="section-actions">
<span>{logs.length} RECENT</span>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIME</th>
<th></th>
<th>DECKY</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.slice(0, 14).map(log => {
let parsedFields: Record<string, unknown> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch {
// ignore
}
}
let msgHead: string | null = null;
let msgTail: string | null = null;
if (Object.keys(parsedFields).length === 0) {
const parsed = parseEventBody(log.msg);
parsedFields = parsed.fields;
msgHead = parsed.head;
msgTail = parsed.tail;
} else if (log.msg && log.msg !== '-') {
msgTail = log.msg;
}
const isAlert = log.severity === 'warn' || log.is_bounty;
const isNew = log.id === newestLogId;
return (
<tr key={log.id} className={isNew ? 'row-enter' : ''}>
<td className="dim" style={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td>
{log.is_bounty
? <span className="chip violet"><Archive size={8} /> BOUNTY</span>
: <span className={`status-dot ${isAlert ? 'hot' : 'active'}`} />}
</td>
<td className="violet-accent">{log.decky}</td>
<td><span className="chip dim-chip">{log.service}</span></td>
<td className="matrix-text">{log.attacker_ip}</td>
<td style={{ minWidth: 0, maxWidth: 0, width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: '0.78rem', color: 'var(--text-color)' }}>
{(() => {
const et = log.event_type && log.event_type !== '-' ? log.event_type : null;
const parts = [et, msgHead].filter(Boolean) as string[];
return (
<>
{parts.join(' · ')}
{msgTail && (
<span style={{ fontWeight: 'normal', opacity: 0.8 }}>
{parts.length ? ' — ' : ''}{msgTail}
</span>
)}
</>
);
})()}
</div>
)}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
{parsedFields.stored_as != null && (
<button
onClick={() => setArtifact({
decky: log.decky,
storedAs: String(parsedFields.stored_as),
fields: parsedFields,
})}
title="Inspect captured artifact"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: '0.62rem',
backgroundColor: 'rgba(255, 170, 0, 0.1)',
padding: '2px 8px',
borderRadius: 4,
border: '1px solid rgba(255, 170, 0, 0.5)',
color: '#ffaa00',
cursor: 'pointer',
letterSpacing: 1,
}}
>
<Paperclip size={10} /> ARTIFACT
</button>
)}
{Object.entries(parsedFields)
.filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as')
.map(([k, v]) => {
const rendered = typeof v === 'object' ? JSON.stringify(v) : String(v);
return (
<span
key={k}
className="chip matrix chip-kv"
style={{ fontSize: '0.62rem' }}
title={`${k}: ${rendered}`}
>
<span className="dim" style={{ marginRight: 3 }}>{k}:</span>
{rendered}
</span>
);
})}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<tr className="empty-row">
<td colSpan={6} style={{ padding: 0 }}>
<EmptyState
icon={Activity}
title="NO INTERACTION DETECTED"
hint="waiting for the first decky hit"
/>
</td>
</tr>
);
}) : (
<tr>
<td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>NO INTERACTION DETECTED</td>
</tr>
)}
</tbody>
</table>
)}
</tbody>
</table>
</div>
</div>
<div className="dash-side">
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Flame size={16} />
<span>DECKIES UNDER SIEGE</span>
</div>
</div>
{deckiesUnderSiege.length > 0 ? (
<div className="panel-body">
{deckiesUnderSiege.map(d => (
<div
key={d.name}
className="attacker-row"
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-decky', payload: d.name } }))}
>
<span className={`status-dot ${d.status}`} />
<span className="violet-accent" style={{ width: 110, fontSize: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
<div className="attacker-bar-wrap">
<div
className={`attacker-bar ${d.status === 'hot' ? 'hot' : ''}`}
style={{ width: `${Math.min(100, (d.hits / Math.max(1, deckiesUnderSiege[0].hits)) * 100)}%` }}
/>
</div>
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{d.hits}</span>
</div>
))}
</div>
) : (
<EmptyState icon={Server} title="NO ACTIVITY" hint="all deckies quiet" />
)}
</div>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Users size={16} />
<span>TOP ATTACKERS</span>
</div>
</div>
{topAttackers.length > 0 ? (
<div className="panel-body">
{topAttackers.map(a => (
<div
key={a.ip}
className="attacker-row"
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-attacker', payload: a.ip } }))}
>
<span className={`chip ${a.hot ? 'alert-chip' : 'dim-chip'}`} style={{ minWidth: 34, textAlign: 'center', justifyContent: 'center' }}>??</span>
<span className="matrix-text" style={{ flex: 1, fontSize: '0.7rem', fontVariantNumeric: 'tabular-nums', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.ip}</span>
<div className="attacker-bar-wrap">
<div
className={`attacker-bar ${a.hot ? 'hot' : ''}`}
style={{ width: `${a.pct}%` }}
/>
</div>
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{a.hits}</span>
</div>
))}
</div>
) : (
<EmptyState icon={ShieldOff} title="NO ATTACKERS YET" hint="nothing on the radar" />
)}
</div>
</div>
</div>
{artifact && (
<ArtifactDrawer
decky={artifact.decky}
storedAs={artifact.storedAs}
fields={artifact.fields as Record<string, string>}
onClose={() => setArtifact(null)}
/>
)}
</div>
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<span className="stat-label">{label}</span>
<span className="stat-value">{value.toLocaleString()}</span>
</div>
</div>
);
export default Dashboard;

View File

@@ -0,0 +1,344 @@
/* DeckyFleet — design-handoff port, scoped to the fleet view. */
.fleet-root { display: flex; flex-direction: column; gap: 24px; }
.fleet-root .page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
gap: 24px;
}
.fleet-root .page-title-group { display: flex; flex-direction: column; gap: 6px; }
.fleet-root .page-header h1 { font-size: 1.3rem; letter-spacing: 4px; font-weight: 700; margin: 0; color: var(--matrix); }
.fleet-root .page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; }
.fleet-root .page-header .actions { display: flex; gap: 10px; align-items: center; }
.fleet-root .dim { opacity: 0.5; }
.fleet-root .violet-accent { color: var(--violet); }
.fleet-root .matrix-text { color: var(--matrix); }
.fleet-root .alert-text { color: var(--alert); }
/* Filter tabs */
.fleet-filter-group { display: flex; border: 1px solid var(--border); background: var(--panel); }
.fleet-filter-btn {
border: 0;
border-right: 1px solid var(--border);
padding: 8px 14px;
font-size: 0.68rem;
letter-spacing: 1px;
font-family: inherit;
cursor: pointer;
background: transparent;
color: rgba(0, 255, 65, 0.6);
text-transform: uppercase;
}
.fleet-filter-btn:last-child { border-right: none; }
.fleet-filter-btn.active { background: var(--matrix-tint-10); color: var(--matrix); }
/* Buttons */
.fleet-root .btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
transition: all 0.3s;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.fleet-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.fleet-root .btn.violet { border-color: var(--violet); color: var(--violet); }
.fleet-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.fleet-root .btn.alert { border-color: var(--alert); color: var(--alert); }
.fleet-root .btn.alert:hover { background: var(--alert); color: #000; box-shadow: 0 0 10px rgba(255, 65, 65, 0.5); }
.fleet-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.fleet-root .btn.ghost:hover { color: var(--matrix); opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); background: transparent; }
.fleet-root .btn.small { padding: 4px 10px; font-size: 0.68rem; }
.fleet-root .btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* Grid + decky card */
.grid-fleet {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.decky-card {
background: var(--panel);
border: 1px solid var(--border);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.3s;
position: relative;
}
.decky-card:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.decky-card.hot { border-color: var(--alert); }
.decky-card.hot::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border: 1px solid var(--alert);
opacity: 0.4;
animation: dfleet-pulse 1s infinite alternate;
}
.decky-head {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.decky-name {
font-size: 1rem;
font-weight: 700;
color: var(--matrix);
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 10px;
}
.decky-ip {
font-size: 0.7rem;
opacity: 0.6;
background: rgba(0, 255, 65, 0.08);
padding: 2px 7px;
}
.decky-meta { display: flex; flex-direction: column; gap: 6px; font-size: 0.75rem; }
.decky-meta .row { display: flex; gap: 8px; align-items: center; }
.decky-meta .label { opacity: 0.5; min-width: 82px; letter-spacing: 1px; font-size: 0.62rem; }
.decky-services { display: flex; flex-wrap: wrap; gap: 5px; }
.decky-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid var(--border);
font-size: 0.68rem;
gap: 8px;
flex-wrap: wrap;
}
.decky-hits { font-variant-numeric: tabular-nums; }
/* Status dots */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); }
.status-dot.idle { background: #30363d; }
.status-dot.hot { background: var(--alert); box-shadow: 0 0 8px var(--alert); animation: dfleet-pulse 1s infinite alternate; }
.status-dot.mutating { background: var(--violet); animation: dfleet-blink 1s infinite; }
/* Service tag (page-scoped so we don't collide with MazeNET) */
.fleet-root .service-tag {
padding: 3px 9px;
font-size: 0.68rem;
border: 1px solid var(--violet);
color: var(--violet);
border-radius: 2px;
letter-spacing: 1px;
white-space: nowrap;
}
/* Swarm meta row inside the card */
.decky-swarm-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 0.65rem;
letter-spacing: 1px;
}
.decky-swarm-chip {
display: flex;
gap: 6px;
align-items: center;
border: 1px solid var(--border);
padding: 2px 8px;
}
.decky-swarm-state {
padding: 2px 8px;
border: 1px solid;
letter-spacing: 1px;
}
/* Modal / wizard */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.modal {
width: 640px;
max-width: 96vw;
max-height: 90vh;
background: var(--panel);
border: 1px solid var(--matrix);
box-shadow: 0 0 30px rgba(0, 255, 65, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal.violet { border-color: var(--violet); box-shadow: 0 0 30px rgba(238, 130, 238, 0.25); }
.modal.wide { width: 880px; }
.modal-head {
padding: 16px 22px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-head h3 { font-size: 0.9rem; letter-spacing: 3px; margin: 0; display: flex; align-items: center; gap: 8px; }
.modal-body { padding: 20px 22px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
.modal-foot {
padding: 14px 22px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.close-btn {
background: transparent;
border: none;
color: var(--matrix);
cursor: pointer;
opacity: 0.6;
padding: 0;
}
.close-btn:hover { color: var(--violet); opacity: 1; }
/* Wizard steps */
.wizard-steps { display: flex; gap: 0; border-bottom: 1px solid var(--border); }
.wizard-step {
flex: 1;
padding: 12px 14px;
font-size: 0.65rem;
letter-spacing: 1.5px;
opacity: 0.4;
border-bottom: 2px solid transparent;
text-align: center;
}
.wizard-step.active { opacity: 1; border-bottom-color: var(--violet); color: var(--violet); }
.wizard-step.done { opacity: 0.8; color: var(--matrix); }
/* Sub-tabs inside wizard step 1 */
.wizard-subtabs { display: flex; border: 1px solid var(--border); }
.wizard-subtab {
flex: 1;
padding: 8px 12px;
background: transparent;
border: 0;
border-right: 1px solid var(--border);
color: rgba(0, 255, 65, 0.6);
font-family: inherit;
font-size: 0.7rem;
letter-spacing: 1.5px;
cursor: pointer;
text-transform: uppercase;
}
.wizard-subtab:last-child { border-right: none; }
.wizard-subtab.active { background: var(--violet-tint-10); color: var(--violet); }
/* Pickable cards (archetype & service) */
.pick-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.pick-grid.two { grid-template-columns: repeat(2, 1fr); }
.pick-card {
padding: 14px;
border: 1px solid var(--border);
background: var(--panel);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
transition: all 0.2s;
color: inherit;
font-family: inherit;
text-align: left;
}
.pick-card:hover { border-color: var(--violet); }
.pick-card.active { border-color: var(--violet); background: var(--violet-tint-10); }
.pick-card .pc-title { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 700; letter-spacing: 1px; }
.pick-card .pc-slug { font-size: 0.65rem; letter-spacing: 1px; opacity: 0.5; }
.pick-card .pc-services { display: flex; flex-wrap: wrap; gap: 4px; }
.pick-card .pc-services .service-tag { font-size: 0.6rem; padding: 2px 6px; }
/* Type + tweak + input + code */
.type-label { font-size: 0.7rem; letter-spacing: 1px; text-transform: uppercase; opacity: 0.6; }
.tweak-group { display: flex; flex-direction: column; gap: 6px; }
.tweak-group label { font-size: 0.62rem; opacity: 0.55; letter-spacing: 1.5px; }
.input {
width: 100%;
background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--border);
color: var(--matrix);
padding: 8px 12px;
font-family: inherit;
font-size: 0.8rem;
letter-spacing: 1px;
outline: none;
}
.input:focus { border-color: var(--matrix); box-shadow: var(--matrix-glow); }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.code-block {
background: #000;
border: 1px solid var(--border);
padding: 14px 16px;
font-size: 0.75rem;
color: var(--matrix);
overflow-x: auto;
white-space: pre;
line-height: 1.6;
font-family: var(--font-mono);
min-height: 80px;
}
.code-block .comment { color: rgba(0, 255, 65, 0.4); }
.code-block .str { color: var(--violet); }
.code-block .key { color: rgba(0, 255, 65, 0.7); }
.replay-cursor {
display: inline-block;
width: 7px;
height: 14px;
background: var(--matrix);
animation: dfleet-blink 1s infinite;
vertical-align: middle;
margin-left: 2px;
}
/* Empty state */
.fleet-empty {
grid-column: 1 / -1;
padding: 48px 24px;
border: 1px dashed var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
letter-spacing: 1px;
font-size: 0.85rem;
}
.fleet-empty .dim { opacity: 0.5; }
/* Animations */
@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } }
@keyframes dfleet-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.fx-spin { animation: dfleet-spin 1.5s linear infinite; }
@keyframes dfleet-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
/* Shared EmptyState styling. Component-scoped .empty-state rules
(Attackers/Bounty/LiveLogs) still win over these base rules. */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 10px;
padding: 36px 20px;
min-height: 140px;
}
.empty-state-icon {
opacity: 0.4;
color: var(--violet);
}
.empty-state-title {
font-size: 0.75rem;
letter-spacing: 2px;
opacity: 0.7;
}
.empty-state-hint {
font-size: 0.65rem;
opacity: 0.45;
letter-spacing: 1px;
margin-top: -4px;
}
.empty-state-cta {
margin-top: 6px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 2px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
}
.empty-state-cta:hover {
background: rgba(0, 255, 65, 0.1);
box-shadow: 0 0 8px rgba(0, 255, 65, 0.3);
}
.empty-state.empty-state-compact {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 4px;
font-size: 0.65rem;
letter-spacing: 1.5px;
opacity: 0.5;
color: var(--text-dim, #888);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import type { LucideIcon } from '../../icons';
import './EmptyState.css';
interface CTA {
label: string;
onClick: () => void;
icon?: LucideIcon;
}
interface Props {
icon?: LucideIcon;
title: string;
hint?: string;
cta?: CTA;
size?: 'default' | 'compact';
className?: string;
}
const EmptyState: React.FC<Props> = ({ icon: Icon, title, hint, cta, size = 'default', className = '' }) => {
if (size === 'compact') {
return (
<div className={`empty-state empty-state-compact ${className}`}>
{Icon && <Icon size={12} />}
<span>{title}</span>
</div>
);
}
const CtaIcon = cta?.icon;
return (
<div className={`empty-state ${className}`}>
{Icon && <Icon size={28} className="empty-state-icon" />}
<div className="type-label empty-state-title">{title}</div>
{hint && <div className="empty-state-hint">{hint}</div>}
{cta && (
<button type="button" className="empty-state-cta" onClick={cta.onClick}>
{CtaIcon && <CtaIcon size={12} />}
<span>{cta.label}</span>
</button>
)}
</div>
);
};
export default EmptyState;

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Fingerprint, Search,
} from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
import { useIdentityStream } from './useIdentityStream';
import './Dashboard.css';
interface IdentityEntry {
uuid: string;
schema_version: number;
campaign_id: string | null;
first_seen_at: string | null;
last_seen_at: string | null;
updated_at: string;
confidence: number | null;
observation_count: number;
ja3_hashes: string | null;
hassh_hashes: string | null;
payload_simhashes: string | null;
c2_endpoints: string | null;
merged_into_uuid: string | null;
}
const safeListLen = (raw: string | null): number => {
if (!raw) return 0;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.length : 0;
} catch {
return 0;
}
};
const timeAgo = (dateStr: string | null): string => {
if (!dateStr) return '—';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
};
const Identities: React.FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const query = (searchParams.get('q') || '').toLowerCase();
const page = parseInt(searchParams.get('page') || '1');
const [identities, setIdentities] = useState<IdentityEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '');
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const limit = 50;
const fetchIdentities = async () => {
setLoading(true);
try {
const offset = (page - 1) * limit;
const res = await api.get(`/identities?limit=${limit}&offset=${offset}`);
setIdentities(res.data.data ?? []);
setTotal(res.data.total ?? 0);
} catch (err) {
console.error('Failed to fetch identities', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchIdentities(); }, [page]);
// Live updates: refetch on any clusterer event so the list stays
// current without polling.
useIdentityStream({
enabled: true,
onEvent: () => { fetchIdentities(); },
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearchParams({ q: searchInput, page: '1' });
};
const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() });
const totalPages = Math.max(1, Math.ceil(total / limit));
const visible = query
? identities.filter((i) =>
i.uuid.toLowerCase().includes(query)
|| (i.campaign_id || '').toLowerCase().includes(query),
)
: identities;
const assignedCount = identities.filter((i) => i.campaign_id).length;
return (
<div className="bounty-root">
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Fingerprint size={22} className="violet-accent" />
<h1>IDENTITY RESOLUTION</h1>
</div>
<span className="page-sub">
{total.toLocaleString()} IDENTITIES · {assignedCount} CAMPAIGN-ASSIGNED
</span>
</div>
</div>
<form className="controls-row" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Filter by UUID or campaign..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
</form>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{visible.length.toLocaleString()} IDENTITIES SHOWN</span>
</div>
<div className="section-actions">
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>UUID</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th>JA3 / HASSH</th>
<th>PAYLOADS / C2</th>
<th>OBS</th>
<th>CAMPAIGN</th>
<th></th>
</tr>
</thead>
<tbody>
{visible.length > 0 ? visible.map((i) => (
<tr
key={i.uuid}
className="clickable"
onClick={() => navigate(`/identities/${i.uuid}`)}
>
<td className="matrix-text" style={{ fontFamily: 'var(--font-mono)' }}>
{i.uuid.slice(0, 12)}
</td>
<td className="dim">{timeAgo(i.first_seen_at)}</td>
<td className="dim">{timeAgo(i.last_seen_at)}</td>
<td>
<span className="chip dim-chip">{safeListLen(i.ja3_hashes)} JA3</span>{' '}
<span className="chip dim-chip">{safeListLen(i.hassh_hashes)} HASSH</span>
</td>
<td>
<span className="chip dim-chip">{safeListLen(i.payload_simhashes)} PAYLOAD</span>{' '}
<span className="chip dim-chip">{safeListLen(i.c2_endpoints)} C2</span>
</td>
<td className="matrix-text">{i.observation_count}</td>
<td>
{i.campaign_id ? (
<span
className="chip violet"
onClick={(e) => {
e.stopPropagation();
navigate(`/campaigns/${i.campaign_id}`);
}}
>
{i.campaign_id.slice(0, 8)}
</span>
) : (
<span className="dim"></span>
)}
</td>
<td style={{ textAlign: 'right', opacity: 0.4 }}>
<ChevR size={14} />
</td>
</tr>
)) : (
<tr>
<td colSpan={8}>
<EmptyState
icon={Fingerprint}
title={loading ? 'RESOLVING IDENTITIES…' : 'NO IDENTITIES YET'}
hint={loading ? undefined : 'the clusterer populates this view as observations correlate'}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Identities;

View File

@@ -0,0 +1,299 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useIdentityStream } from './useIdentityStream';
import './Dashboard.css';
/*
* IdentityDetail — read-only view of a resolved attacker identity.
*
* Header (page-header), aggregated stats in the sub-line, fingerprint
* groups in their own section, observations in a logs-section table.
* Same vocabulary as CampaignDetail one layer up.
*/
interface IdentityData {
uuid: string;
schema_version: number;
campaign_id: string | null;
first_seen_at: string | null;
last_seen_at: string | null;
created_at: string;
updated_at: string;
confidence: number | null;
observation_count: number;
observation_count_live: number;
ja3_hashes: string | null;
hassh_hashes: string | null;
payload_simhashes: string | null;
c2_endpoints: string | null;
kd_digraph_simhash: string | null;
merged_into_uuid: string | null;
notes: string | null;
}
interface ObservationRow {
uuid: string;
ip: string;
first_seen: string;
last_seen: string;
event_count: number;
asn?: number | null;
country_code?: string | null;
}
const safeParseJsonList = (raw: string | null): string[] => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const IdentityDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [identity, setIdentity] = useState<IdentityData | null>(null);
const [observations, setObservations] = useState<ObservationRow[]>([]);
const [observationTotal, setObservationTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
const fetchIdentity = async () => {
setLoading(true);
try {
const res = await api.get(`/identities/${id}`);
setIdentity(res.data);
setError(null);
} catch (err: any) {
if (err.response?.status === 404) {
setError('IDENTITY NOT FOUND');
} else {
setError('FAILED TO LOAD IDENTITY');
}
} finally {
setLoading(false);
}
};
fetchIdentity();
}, [id]);
useEffect(() => {
if (!id) return;
const fetchObservations = async () => {
try {
const res = await api.get(`/identities/${id}/observations?limit=50&offset=0`);
setObservations(res.data.data ?? []);
setObservationTotal(res.data.total ?? 0);
} catch {
setObservations([]);
setObservationTotal(0);
}
};
fetchObservations();
}, [id]);
useIdentityStream({
enabled: !!id,
onEvent: (ev) => {
if (!id) return;
const refs = new Set<string>();
const addUuid = (v: unknown) => {
if (typeof v === 'string') refs.add(v);
};
const payload = ev.payload || {};
addUuid(payload.identity_uuid);
addUuid(payload.winner_uuid);
addUuid(payload.loser_uuid);
addUuid(payload.resurrected_uuid);
addUuid(payload.former_winner_uuid);
if (refs.has(id)) {
api.get(`/identities/${id}`).then((res) => setIdentity(res.data)).catch(() => {});
api.get(`/identities/${id}/observations?limit=50&offset=0`)
.then((res) => {
setObservations(res.data.data ?? []);
setObservationTotal(res.data.total ?? 0);
})
.catch(() => {});
}
},
});
if (loading) {
return (
<div className="bounty-root">
<EmptyState icon={Fingerprint} title="LOADING IDENTITY…" />
</div>
);
}
if (error || !identity) {
return (
<div className="bounty-root">
<button onClick={() => navigate('/identities')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO IDENTITIES</span>
</button>
<EmptyState icon={Fingerprint} title={error || 'IDENTITY NOT FOUND'} />
</div>
);
}
const ja3List = safeParseJsonList(identity.ja3_hashes);
const hasshList = safeParseJsonList(identity.hassh_hashes);
const payloadList = safeParseJsonList(identity.payload_simhashes);
const c2List = safeParseJsonList(identity.c2_endpoints);
return (
<div className="bounty-root">
<button onClick={() => navigate('/identities')} className="back-button">
<ArrowLeft size={18} />
<span>BACK TO IDENTITIES</span>
</button>
<div className="page-header">
<div className="page-title-group">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Fingerprint size={22} className="violet-accent" />
<h1>IDENTITY · {identity.uuid.slice(0, 12)}</h1>
{identity.campaign_id && (
<span
className="chip violet"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/campaigns/${identity.campaign_id}`)}
title="Campaign assignment from the campaign clusterer"
>
CAMPAIGN · {identity.campaign_id.slice(0, 8)}
</span>
)}
{identity.merged_into_uuid && (
<span
className="chip dim-chip"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/identities/${identity.merged_into_uuid}`)}
title="Soft-merged. Click to view canonical winner."
>
MERGED {identity.merged_into_uuid.slice(0, 8)}
</span>
)}
</div>
<span className="page-sub">
{identity.observation_count_live} OBSERVATIONS ·
{' '}{ja3List.length} JA3 · {hasshList.length} HASSH ·
{' '}{payloadList.length} PAYLOAD · {c2List.length} C2
{identity.confidence !== null && (
<> · CONFIDENCE {identity.confidence.toFixed(3)}</>
)}
{' '}· SCHEMA v{identity.schema_version}
</span>
</div>
</div>
{(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Fingerprint size={14} />
<span>FINGERPRINTS</span>
</div>
</div>
<div className="logs-table-container" style={{ padding: 12 }}>
{ja3List.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="JA3" items={ja3List} />
)}
{hasshList.length > 0 && (
<FingerprintGroup icon={<Globe size={14} />} label="HASSH" items={hasshList} />
)}
{c2List.length > 0 && (
<FingerprintGroup icon={<Radio size={14} />} label="C2 ENDPOINTS" items={c2List} />
)}
</div>
</div>
)}
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{observationTotal} OBSERVATIONS LINKED</span>
</div>
</div>
<div className="logs-table-container">
{observations.length === 0 ? (
<EmptyState
icon={Crosshair}
title="NO OBSERVATIONS LINKED YET"
hint="the clusterer assigns observations asynchronously"
/>
) : (
<table className="logs-table">
<thead>
<tr>
<th>IP</th>
<th>FIRST SEEN</th>
<th>LAST SEEN</th>
<th style={{ textAlign: 'right' }}>EVENTS</th>
</tr>
</thead>
<tbody>
{observations.map((obs) => (
<tr
key={obs.uuid}
className="clickable"
onClick={() => navigate(`/attackers/${obs.uuid}`)}
>
<td className="matrix-text">{obs.ip}</td>
<td className="dim">{obs.first_seen}</td>
<td className="dim">{obs.last_seen}</td>
<td className="matrix-text" style={{ textAlign: 'right' }}>
{obs.event_count}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{identity.notes && (
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<span>ANALYST NOTES</span>
</div>
</div>
<div className="logs-table-container" style={{ padding: 12, fontFamily: 'var(--font-mono)', whiteSpace: 'pre-wrap' }}>
{identity.notes}
</div>
</div>
)}
</div>
);
};
const FingerprintGroup: React.FC<{
icon: React.ReactNode;
label: string;
items: string[];
}> = ({ icon, label, items }) => (
<div className="fp-group">
<div className="fp-group-label">
{icon}
<span>{label}</span>
</div>
<div className="fp-group-items">
{items.map((v) => (
<span key={v} className="chip dim-chip">{v}</span>
))}
</div>
</div>
);
export default IdentityDetail;

View File

@@ -56,10 +56,16 @@
}
.sidebar-nav {
flex-grow: 1;
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 20px 0;
}
.sidebar-footer {
flex-shrink: 0;
}
.nav-item {
display: flex;
align-items: center;
@@ -70,11 +76,27 @@
opacity: 0.7;
}
.nav-item {
border-left: 3px solid transparent;
gap: 12px;
position: relative;
}
.nav-item:hover, .nav-item.active {
background-color: rgba(0, 255, 65, 0.1);
background-color: var(--accent-tint-10);
opacity: 1;
color: var(--text-color);
border-left: 3px solid var(--text-color);
border-left-color: var(--accent);
}
.nav-badge {
font-size: 0.6rem;
color: var(--alert);
border: 1px solid var(--alert);
padding: 1px 5px;
background: rgba(255, 65, 65, 0.1);
letter-spacing: 1px;
margin-left: auto;
}
.nav-label {
@@ -83,9 +105,58 @@
white-space: nowrap;
}
.nav-group {
display: flex;
flex-direction: column;
}
.nav-group-toggle {
background: transparent;
border: none;
width: 100%;
text-align: left;
font-family: inherit;
font-size: inherit;
letter-spacing: 1px;
text-transform: uppercase;
opacity: 0.6;
}
.nav-group-toggle:hover {
box-shadow: none;
opacity: 1;
}
.nav-group-chevron {
margin-left: auto;
display: flex;
align-items: center;
}
.nav-group-children {
display: flex;
flex-direction: column;
}
.nav-subitem {
padding-left: 40px;
font-size: 0.85rem;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar-meta {
font-size: 0.6rem;
opacity: 0.4;
letter-spacing: 1px;
line-height: 1.6;
white-space: nowrap;
}
.logout-btn {
@@ -119,18 +190,65 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
padding: 0 24px;
gap: 16px;
flex-shrink: 0;
overflow: hidden;
background-color: var(--background-color);
}
.topbar-left {
display: flex;
align-items: center;
gap: 24px;
flex: 1 1 auto;
min-width: 0;
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.crumbs {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.7rem;
letter-spacing: 1px;
opacity: 0.7;
white-space: nowrap;
text-transform: uppercase;
}
.crumbs .sep {
opacity: 0.3;
}
.crumb-sector {
color: var(--violet);
}
.search-container {
display: flex;
align-items: center;
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 4px 12px;
max-width: 400px;
max-width: 420px;
min-width: 0;
width: 100%;
font-size: 0.85rem;
flex-shrink: 1;
}
.search-container > input {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-icon {
@@ -149,8 +267,59 @@
box-shadow: none;
}
.search-kbd {
font-size: 0.6rem;
border: 1px solid var(--border-color);
padding: 1px 5px;
opacity: 0.5;
letter-spacing: 1px;
margin-left: 8px;
white-space: nowrap;
}
.threat-level {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
letter-spacing: 1px;
padding: 6px 10px;
border: 1px solid var(--alert);
color: var(--alert);
background: rgba(255, 65, 65, 0.08);
text-transform: uppercase;
}
.threat-level .dot {
width: 6px;
height: 6px;
background: var(--alert);
border-radius: 50%;
animation: decnet-pulse 1s infinite alternate;
}
.topbar-clock {
font-size: 0.75rem;
opacity: 0.6;
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
}
.topbar-status {
font-size: 0.8rem;
font-size: 0.7rem;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 1280px) {
.threat-level span:last-child { display: none; }
.topbar-clock { display: none; }
}
@media (max-width: 980px) {
.crumbs { display: none; }
}
.neon-blink {

View File

@@ -1,19 +1,78 @@
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 api from '../utils/api';
import { NavLink, useLocation } from 'react-router-dom';
import {
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail,
Target, FileText, Sliders,
} from '../icons';
import { prefetchRoute } from '../routePrefetch';
import './Layout.css';
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
interface LayoutProps {
children: React.ReactNode;
onLogout: () => void;
onSearch: (q: string) => void;
onOpenCmd?: () => void;
sector?: string;
persona?: string;
threat?: ThreatLevel;
alertCount?: number;
build?: string;
}
const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
const ROUTE_LABELS: Record<string, string> = {
'/': 'DASHBOARD',
'/fleet': 'FLEET',
'/mazenet': 'MAZENET',
'/live-logs': 'LIVE LOGS',
'/webhooks': 'WEBHOOKS',
'/bounty': 'BOUNTY',
'/credentials': 'CREDENTIALS',
'/attackers': 'ATTACKERS',
'/identities': 'IDENTITIES',
'/campaigns': 'CAMPAIGNS',
'/orchestrator': 'ORCHESTRATOR',
'/persona-generation': 'PERSONA GENERATION',
'/synthetic-files': 'SYNTHETIC FILES',
'/realism-config': 'REALISM CONFIG',
'/canary-tokens': 'CANARY TOKENS',
'/config': 'CONFIG',
'/swarm-updates': 'REMOTE UPDATES',
'/swarm/hosts': 'SWARM HOSTS',
};
function labelForPath(pathname: string): string {
if (ROUTE_LABELS[pathname]) return ROUTE_LABELS[pathname];
const prefix = Object.keys(ROUTE_LABELS).find(p => p !== '/' && pathname.startsWith(p));
return prefix ? ROUTE_LABELS[prefix] : pathname.replace(/^\//, '').toUpperCase();
}
function formatClock(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const Layout: React.FC<LayoutProps> = ({
children,
onLogout,
onSearch,
onOpenCmd,
sector = 'PRODUCTION',
persona = 'ADMIN',
threat: threatProp = 'nominal',
alertCount: alertCountProp = 0,
build = 'v0.1',
}) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [search, setSearch] = useState('');
const [systemActive, setSystemActive] = useState(false);
const [clockTime, setClockTime] = useState(() => formatClock(new Date()));
const [threat, setThreat] = useState<ThreatLevel>(threatProp);
const [alertCount, setAlertCount] = useState<number>(alertCountProp);
const location = useLocation();
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -21,19 +80,25 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
};
useEffect(() => {
const fetchStatus = async () => {
try {
const res = await api.get('/stats');
setSystemActive(res.data.deployed_deckies > 0);
} catch (err) {
console.error('Failed to fetch system status', err);
}
const onStats = (e: Event) => {
const detail = (e as CustomEvent).detail;
setSystemActive(detail.deployed_deckies > 0);
if (detail.threat) setThreat(detail.threat as ThreatLevel);
if (typeof detail.alert_count === 'number') setAlertCount(detail.alert_count);
};
fetchStatus();
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
window.addEventListener('decnet:stats', onStats);
return () => window.removeEventListener('decnet:stats', onStats);
}, []);
useEffect(() => {
const iv = setInterval(() => setClockTime(formatClock(new Date())), 1000);
return () => clearInterval(iv);
}, []);
const routeLabel = labelForPath(location.pathname);
const showThreat = threat !== 'nominal';
const threatLabel = threat.toUpperCase();
return (
<div className="layout-container">
{/* Sidebar */}
@@ -45,13 +110,48 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="sidebar-nav">
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" 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="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavGroup label="DEPLOY" icon={<Server size={20} />} open={sidebarOpen}>
<NavItem to="/fleet" icon={<Server size={18} />} label="Decoy Fleet" open={sidebarOpen} indent />
<NavItem to="/mazenet" icon={<Network size={18} />} label="MazeNET" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="ALERTS" icon={<Bell size={20} />} open={sidebarOpen}>
<NavItem
to="/live-logs"
icon={<Terminal size={18} />}
label="Live Logs"
open={sidebarOpen}
indent
badge={alertCount}
/>
<NavItem
to="/webhooks"
icon={<Webhook size={18} />}
label="Webhooks"
open={sidebarOpen}
indent
/>
</NavGroup>
<NavGroup label="THREAT DATA" icon={<Activity size={20} />} open={sidebarOpen}>
<NavItem to="/attackers" icon={<Activity size={18} />} label="Attackers" open={sidebarOpen} indent />
<NavItem to="/identities" icon={<Fingerprint size={18} />} label="Identities" open={sidebarOpen} indent />
<NavItem to="/campaigns" icon={<Crosshair size={18} />} label="Campaigns" open={sidebarOpen} indent />
<NavItem to="/credentials" icon={<Lock size={18} />} label="Credentials" open={sidebarOpen} indent />
<NavItem to="/bounty" icon={<Archive size={18} />} label="Bounty" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="AUTOMATION" icon={<Zap size={20} />} open={sidebarOpen}>
<NavItem to="/orchestrator" icon={<Cpu size={18} />} label="Orchestrator" open={sidebarOpen} indent />
<NavItem to="/persona-generation" icon={<Mail size={18} />} label="Persona Generation" open={sidebarOpen} indent />
<NavItem to="/synthetic-files" icon={<FileText size={18} />} label="Synthetic Files" open={sidebarOpen} indent />
<NavItem to="/realism-config" icon={<Sliders size={18} />} label="Realism Config" open={sidebarOpen} indent />
<NavItem to="/canary-tokens" icon={<Target size={18} />} label="Canary Tokens" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
</NavGroup>
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav>
@@ -60,6 +160,13 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
<LogOut size={20} />
{sidebarOpen && <span>Logout</span>}
</button>
{sidebarOpen && (
<div className="sidebar-meta">
<div>SECTOR · {sector.toUpperCase()}</div>
<div>OPERATOR · {persona.toUpperCase()}</div>
<div>BUILD · {build.toUpperCase()}</div>
</div>
)}
</div>
</aside>
@@ -67,19 +174,42 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
<main className="main-content">
{/* Topbar */}
<header className="topbar">
<form onSubmit={handleSearchSubmit} className="search-container">
<Search size={18} className="search-icon" />
<input
type="text"
placeholder="Search logs, deckies, IPs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</form>
<div className="topbar-status">
<span className="matrix-text" style={{ color: systemActive ? 'var(--text-color)' : 'var(--accent-color)' }}>
SYSTEM: {systemActive ? 'ACTIVE' : 'INACTIVE'}
</span>
<div className="topbar-left">
<div className="crumbs">
<span className="crumb-sector">{sector.toUpperCase()}</span>
<span className="sep">/</span>
<span>{routeLabel}</span>
</div>
<form onSubmit={handleSearchSubmit} className="search-container">
<Search size={18} className="search-icon" />
<input
type="text"
placeholder="Search logs, deckies, IPs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => onOpenCmd?.()}
/>
<span className="search-kbd">Alt+K</span>
</form>
</div>
<div className="topbar-right">
{showThreat && (
<div className="threat-level" title={`Threat: ${threatLabel}`}>
<span className="dot" />
<ShieldAlert size={12} />
<span>THREAT: {threatLabel}</span>
</div>
)}
<div className="topbar-status">
<span
className="matrix-text"
style={{ color: systemActive ? 'var(--text-color)' : 'var(--accent-color)' }}
>
SYSTEM: {systemActive ? 'ACTIVE' : 'INACTIVE'}
</span>
</div>
<div className="topbar-clock">{clockTime}</div>
</div>
</header>
@@ -97,13 +227,55 @@ interface NavItemProps {
icon: React.ReactNode;
label: string;
open: boolean;
indent?: boolean;
badge?: number;
}
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open }) => (
<NavLink to={to} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} end={to === '/'}>
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent, badge }) => (
<NavLink
to={to}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
end={to === '/'}
onMouseEnter={() => prefetchRoute(to)}
onFocus={() => prefetchRoute(to)}
>
{icon}
{open && <span className="nav-label">{label}</span>}
{open && badge !== undefined && badge > 0 && (
<span className="nav-badge">{badge > 99 ? '99+' : badge}</span>
)}
</NavLink>
);
interface NavGroupProps {
label: string;
icon: React.ReactNode;
open: boolean;
children: React.ReactNode;
}
const NavGroup: React.FC<NavGroupProps> = ({ label, icon, open, children }) => {
const [expanded, setExpanded] = useState(true);
return (
<div className="nav-group">
<button
type="button"
className="nav-item nav-group-toggle"
onClick={() => setExpanded((v) => !v)}
>
{icon}
{open && (
<>
<span className="nav-label">{label}</span>
<span className="nav-group-chevron">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
</>
)}
</button>
{expanded && <div className="nav-group-children">{children}</div>}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,182 @@
.logs-root {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Button system (mirrors DeckyFleet.css scoping) */
.logs-root .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
}
.logs-root .btn:hover {
background: var(--matrix);
color: #000;
box-shadow: var(--matrix-glow);
}
.logs-root .btn.violet { border-color: var(--violet); color: var(--violet); }
.logs-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.logs-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.logs-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); }
.logs-root .btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* Control row */
.logs-root .logs-controls {
display: flex;
gap: 12px;
align-items: stretch;
}
.logs-root .logs-controls .search-container { flex: 1; max-width: none; }
.logs-root .time-select {
background: var(--panel);
border: 1px solid var(--border);
color: var(--matrix);
padding: 8px 14px;
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 2px;
cursor: pointer;
}
.logs-root .time-select:focus { outline: none; border-color: var(--accent); }
/* Histogram */
.logs-root .histogram-wrap {
background: var(--panel);
border: 1px solid var(--border);
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.logs-root .histogram-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.65rem;
letter-spacing: 1.5px;
opacity: 0.75;
}
.logs-root .histogram-header .violet-accent {
color: var(--violet);
opacity: 1;
}
.logs-root .histogram-header .clear-sel {
cursor: pointer;
text-decoration: underline;
margin-left: 4px;
}
.logs-root .histogram {
display: flex;
align-items: flex-end;
gap: 3px;
height: 80px;
}
.logs-root .histogram .bar {
flex: 1;
min-height: 2px;
background: var(--accent);
opacity: 0.4;
cursor: pointer;
transition: opacity 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.logs-root .histogram .bar:hover { opacity: 0.8; }
.logs-root .histogram .bar.selected {
background: var(--violet);
opacity: 1;
box-shadow: var(--violet-glow);
}
.logs-root .histogram .bar.has-bounty { background: #ffaa00; opacity: 0.7; }
.logs-root .histogram .bar.has-bounty.selected { background: var(--violet); }
.logs-root .histogram-axis {
display: flex;
justify-content: space-between;
font-size: 0.6rem;
opacity: 0.5;
letter-spacing: 1px;
}
/* Table tweaks */
.logs-root .logs-table td.t-time { font-size: 0.72rem; opacity: 0.55; white-space: nowrap; }
.logs-root .logs-table td.t-decky { color: var(--violet); }
.logs-root .logs-table td.t-svc { color: var(--matrix); }
.logs-root .logs-table td.t-event .event-head {
font-weight: 700;
color: var(--matrix);
font-size: 0.85rem;
}
.logs-root .logs-table td.t-event .event-tail { font-weight: normal; opacity: 0.75; }
.logs-root .logs-table td.t-event .badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.logs-root .field-badge {
font-size: 0.68rem;
padding: 2px 8px;
border-radius: 3px;
background: var(--matrix-tint-10);
border: 1px solid var(--matrix-tint-30);
word-break: break-all;
}
.logs-root .field-badge .k { opacity: 0.6; margin-right: 4px; }
.logs-root .artifact-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.68rem;
padding: 2px 8px;
border-radius: 3px;
background: rgba(255, 170, 0, 0.1);
border: 1px solid rgba(255, 170, 0, 0.5);
color: #ffaa00;
cursor: pointer;
font-family: inherit;
}
.logs-root .artifact-btn:hover {
background: rgba(255, 170, 0, 0.2);
box-shadow: 0 0 8px rgba(255, 170, 0, 0.4);
}
/* Empty state */
.logs-root .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px 20px;
opacity: 0.45;
}
.logs-root .empty-state .type-label {
font-size: 0.7rem;
letter-spacing: 2px;
}
/* Pagination */
.logs-root .pager {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.7rem;
}
.logs-root .pager button {
padding: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--matrix);
display: flex;
cursor: pointer;
}
.logs-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.logs-root .pager button:hover:not(:disabled) { border-color: var(--accent); }

View File

@@ -1,14 +1,16 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useFocusSearch } from '../hooks/useFocusSearch';
import { useSearchParams } from 'react-router-dom';
import {
Terminal, Search, Activity,
ChevronLeft, ChevronRight, Play, Pause
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
} from 'recharts';
import {
Terminal, Search, BarChart3, ChevronLeft, ChevronRight,
Play, Pause, Paperclip, Download, Radio, X as XIcon,
} from '../icons';
import api from '../utils/api';
import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css';
import './LiveLogs.css';
interface LogEntry {
id: number;
@@ -20,90 +22,66 @@ interface LogEntry {
raw_line: string;
fields: string;
msg: string;
is_bounty?: boolean;
}
interface HistogramData {
time: string;
count: number;
}
const LIMIT = 50;
const LiveLogs: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
// URL-synced state
const query = searchParams.get('q') || '';
const timeRange = searchParams.get('time') || '1h';
const isLive = searchParams.get('live') !== 'false';
const page = parseInt(searchParams.get('page') || '1');
// Local state
const [logs, setLogs] = useState<LogEntry[]>([]);
const [histogram, setHistogram] = useState<HistogramData[]>([]);
const [totalLogs, setTotalLogs] = useState(0);
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(isLive);
const [searchInput, setSearchInput] = useState(query);
const eventSourceRef = useRef<EventSource | null>(null);
const limit = 50;
const searchRef = useRef<HTMLInputElement | null>(null);
useFocusSearch(searchRef);
const [selectedHour, setSelectedHour] = useState<number | null>(null);
// Sync search input if URL changes (e.g. back button)
useEffect(() => {
setSearchInput(query);
}, [query]);
const eventSourceRef = useRef<EventSource | null>(null);
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
useEffect(() => { setSearchInput(query); }, [query]);
const startTimeParam = (): string | null => {
if (timeRange === 'all') return null;
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (!minutes) return null;
return new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19);
};
const fetchData = async () => {
if (streaming) return; // Don't fetch historical if streaming
setLoading(true);
try {
const offset = (page - 1) * limit;
let url = `/logs?limit=${limit}&offset=${offset}&search=${encodeURIComponent(query)}`;
// Calculate time bounds for historical fetch
const now = new Date();
let startTime: string | null = null;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
startTime = new Date(now.getTime() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19);
url += `&start_time=${startTime}`;
}
}
const offset = (page - 1) * LIMIT;
let url = `/logs?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(query)}`;
const startTime = startTimeParam();
if (startTime) url += `&start_time=${startTime}`;
const res = await api.get(url);
setLogs(res.data.data);
setTotalLogs(res.data.total);
// Fetch histogram for historical view
let histUrl = `/logs/histogram?search=${encodeURIComponent(query)}`;
if (startTime) histUrl += `&start_time=${startTime}`;
const histRes = await api.get(histUrl);
setHistogram(histRes.data);
} catch (err) {
console.error('Failed to fetch historical logs', err);
console.error('Failed to fetch logs', err);
} finally {
setLoading(false);
}
};
const setupSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
if (eventSourceRef.current) eventSourceRef.current.close();
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
let url = `${baseUrl}/stream?token=${token}&search=${encodeURIComponent(query)}`;
if (timeRange !== 'all') {
const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0;
if (minutes > 0) {
const startTime = new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19);
url += `&start_time=${startTime}`;
}
}
const startTime = startTimeParam();
if (startTime) url += `&start_time=${startTime}`;
const es = new EventSource(url);
eventSourceRef.current = es;
@@ -113,8 +91,6 @@ const LiveLogs: React.FC = () => {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 500));
} else if (payload.type === 'histogram') {
setHistogram(payload.data);
} else if (payload.type === 'stats') {
setTotalLogs(payload.data.total_logs);
}
@@ -123,29 +99,29 @@ const LiveLogs: React.FC = () => {
}
};
es.onerror = () => {
console.error('SSE connection lost, reconnecting...');
};
es.onerror = () => console.error('SSE connection lost, reconnecting...');
};
// Always seed with REST backlog on mount / filter changes.
useEffect(() => {
fetchData();
}, [query, timeRange, page]);
// SSE follows the streaming toggle independently.
useEffect(() => {
if (streaming) {
setupSSE();
setLoading(false);
} else {
} else if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
fetchData();
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, [query, timeRange, streaming, page]);
}, [streaming, query, timeRange]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -166,170 +142,231 @@ const LiveLogs: React.FC = () => {
setSearchParams({ q: query, time: timeRange, live: 'false', page: newPage.toString() });
};
const buckets = useMemo(() => {
const b = Array.from({ length: 24 }, (_, i) => ({ i, count: 0, bounties: 0 }));
logs.forEach(l => {
const h = parseInt(String(l.timestamp).slice(11, 13), 10);
if (!isNaN(h) && h >= 0 && h < 24) {
b[h].count++;
if (l.is_bounty) b[h].bounties++;
}
});
return b;
}, [logs]);
const maxBar = Math.max(...buckets.map(b => b.count), 1);
const peakHour = buckets.findIndex(b => b.count === maxBar);
const filteredLogs = useMemo(() => {
if (selectedHour == null) return logs;
return logs.filter(l => parseInt(String(l.timestamp).slice(11, 13), 10) === selectedHour);
}, [logs, selectedHour]);
const handleExport = () => {
const header = 'timestamp,decky,service,attacker_ip,event_type,msg';
const rows = filteredLogs.map(l =>
[l.timestamp, l.decky, l.service, l.attacker_ip, l.event_type, (l.msg || '').replace(/"/g, '""')]
.map(v => `"${v ?? ''}"`).join(',')
);
const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `decnet-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const totalPages = Math.max(1, Math.ceil(totalLogs / LIMIT));
return (
<div className="dashboard">
{/* Control Bar */}
<div className="logs-section" style={{ border: 'none', background: 'transparent', padding: 0 }}>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '16px', marginBottom: '24px' }}>
<div className="search-container" style={{ flexGrow: 1, maxWidth: 'none' }}>
<Search className="search-icon" size={18} />
<input
type="text"
placeholder="Query logs (e.g. decky:decky-01 service:http attacker:192.168.1.5 status:failed)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<select
value={timeRange}
onChange={(e) => handleTimeChange(e.target.value)}
className="search-container"
style={{ width: 'auto', color: 'var(--text-color)', cursor: 'pointer' }}
>
<option value="15m">LAST 15 MIN</option>
<option value="1h">LAST 1 HOUR</option>
<option value="24h">LAST 24 HOURS</option>
<option value="all">ALL TIME</option>
</select>
<button
type="button"
onClick={handleToggleLive}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
border: `1px solid ${streaming ? 'var(--text-color)' : 'var(--accent-color)'}`,
color: streaming ? 'var(--text-color)' : 'var(--accent-color)',
minWidth: '120px', justifyContent: 'center'
}}
>
{streaming ? <><Play size={14} className="neon-blink" /> LIVE</> : <><Pause size={14} /> PAUSED</>}
</button>
</form>
</div>
{/* Histogram Chart */}
<div className="logs-section" style={{ height: '200px', padding: '20px', marginBottom: '24px', minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.7rem', color: 'var(--dim-color)' }}>
<Activity size={12} /> ATTACK VOLUME OVER TIME
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-color)' }}>
MATCHES: {totalLogs.toLocaleString()}
</div>
<div className="logs-root">
<div className="page-header">
<div className="page-title-group">
<h1>LOGS</h1>
<span className="page-sub">
{filteredLogs.length.toLocaleString()} SHOWN · {totalLogs.toLocaleString()} MATCHES · STREAM {streaming ? 'LIVE' : 'PAUSED'}
</span>
</div>
<div className="actions">
<button className={`btn ${streaming ? '' : 'violet'}`} onClick={handleToggleLive}>
{streaming
? <><Pause size={12} className="fx-blink" /> PAUSE</>
: <><Play size={12} /> GO LIVE</>}
</button>
<button className="btn ghost" onClick={handleExport} disabled={filteredLogs.length === 0}>
<Download size={12} /> EXPORT
</button>
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={histogram}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis
dataKey="time"
hide
/>
<YAxis
stroke="#30363d"
fontSize={10}
tickFormatter={(val) => Math.floor(val).toString()}
/>
<Tooltip
contentStyle={{ backgroundColor: '#0d1117', border: '1px solid #30363d', fontSize: '0.8rem' }}
itemStyle={{ color: 'var(--text-color)' }}
labelStyle={{ color: 'var(--dim-color)', marginBottom: '4px' }}
cursor={{ fill: 'rgba(0, 255, 65, 0.05)' }}
/>
<Bar dataKey="count" fill="var(--text-color)" radius={[2, 2, 0, 0]}>
{histogram.map((entry, index) => (
<Cell key={`cell-${index}`} fillOpacity={0.6 + (entry.count / (Math.max(...histogram.map(h => h.count)) || 1)) * 0.4} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Logs Table */}
<div className="logs-section">
<div className="section-header" style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Terminal size={20} />
<h2>LOG EXPLORER</h2>
</div>
{!streaming && (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {page} of {Math.ceil(totalLogs / limit)}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={page === 1}
onClick={() => changePage(page - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page === 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= Math.ceil(totalLogs / limit)}
onClick={() => changePage(page + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= Math.ceil(totalLogs / limit) ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div>
</div>
<form className="logs-controls" onSubmit={handleSearch}>
<div className="search-container">
<Search size={14} className="search-icon" />
<input
ref={searchRef}
type="text"
placeholder="Query (e.g. decky:decky-03 service:ssh attacker:89.248)"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
{searchInput && (
<button
type="button"
className="close-btn"
onClick={() => { setSearchInput(''); setSearchParams({ q: '', time: timeRange, live: streaming.toString(), page: '1' }); }}
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', padding: 0, display: 'flex' }}
aria-label="Clear search"
>
<XIcon size={12} />
</button>
)}
</div>
<select
className="time-select"
value={timeRange}
onChange={(e) => handleTimeChange(e.target.value)}
>
<option value="15m">LAST 15 MIN</option>
<option value="1h">LAST 1 HOUR</option>
<option value="24h">LAST 24 HOURS</option>
<option value="all">ALL TIME</option>
</select>
</form>
<div className="logs-table-container">
<div className="histogram-wrap">
<div className="histogram-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BarChart3 size={12} />
<span>ATTACK VOLUME PAST 24 HOURS</span>
{selectedHour != null && (
<span className="violet-accent" style={{ marginLeft: 8 }}>
· {String(selectedHour).padStart(2, '0')}:00 SELECTED
<span className="clear-sel" onClick={() => setSelectedHour(null)}>clear</span>
</span>
)}
</div>
<span>PEAK: {maxBar} @ HOUR {String(peakHour).padStart(2, '0')}</span>
</div>
<div className="histogram">
{buckets.map(b => (
<div
key={b.i}
className={`bar ${selectedHour === b.i ? 'selected' : ''} ${b.bounties > 0 ? 'has-bounty' : ''}`}
style={{ height: `${(b.count / maxBar) * 100}%` }}
title={`${String(b.i).padStart(2, '0')}:00 — ${b.count} events${b.bounties ? `, ${b.bounties} bounties` : ''}`}
onClick={() => setSelectedHour(selectedHour === b.i ? null : b.i)}
/>
))}
</div>
<div className="histogram-axis">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>23:59</span>
</div>
</div>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Terminal size={14} />
<span>LOG EXPLORER</span>
</div>
<div className="section-actions">
<span>SHOWING {filteredLogs.length} OF {totalLogs.toLocaleString()}</span>
{!streaming && (
<div className="pager" style={{ marginLeft: 16 }}>
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page === 1} onClick={() => changePage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => changePage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
)}
</div>
</div>
<div className="logs-table-container" style={{ maxHeight: 520 }}>
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>TIME</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
{filteredLogs.length > 0 ? filteredLogs.map(log => {
let parsedFields: Record<string, any> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {}
try { parsedFields = JSON.parse(log.fields); } catch { /* noop */ }
}
let msgHead: string | null = null;
let msgTail: string | null = null;
if (Object.keys(parsedFields).length === 0) {
const parsed = parseEventBody(log.msg);
parsedFields = parsed.fields;
msgHead = parsed.head;
msgTail = parsed.tail;
} else if (log.msg && log.msg !== '-') {
msgTail = log.msg;
}
const et = log.event_type && log.event_type !== '-' ? log.event_type : null;
const headParts = [et, msgHead].filter(Boolean) as string[];
const hasBadges = Object.keys(parsedFields).length > 0 || parsedFields.stored_as;
return (
<tr key={log.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td className="t-time">{new Date(log.timestamp).toLocaleString()}</td>
<td className="t-decky">{log.decky}</td>
<td className="t-svc">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)', fontSize: '0.9rem' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>}
</div>
{Object.keys(parsedFields).length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
</span>
))}
</div>
<td className="t-event">
<div className="event-head">
{headParts.join(' · ')}
{msgTail && (
<span className="event-tail">
{headParts.length ? ' — ' : ''}{msgTail}
</span>
)}
</div>
{hasBadges && (
<div className="badges">
{parsedFields.stored_as && (
<button
className="artifact-btn"
onClick={() => setArtifact({
decky: log.decky,
storedAs: String(parsedFields.stored_as),
fields: parsedFields,
})}
title="Inspect captured artifact"
>
<Paperclip size={11} /> ARTIFACT
</button>
)}
{Object.entries(parsedFields)
.filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as')
.map(([k, v]) => (
<span key={k} className="field-badge">
<span className="k">{k}:</span>
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
</span>
))}
</div>
)}
</td>
</tr>
);
}) : (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '40px', opacity: 0.5 }}>
{loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'}
<tr className="empty-row">
<td colSpan={5}>
<EmptyState
icon={Radio}
title={loading ? 'RETRIEVING DATA…' : 'NO LOGS MATCHING CRITERIA'}
hint={loading ? undefined : 'adjust filters or wait for new events'}
/>
</td>
</tr>
)}
@@ -337,6 +374,15 @@ const LiveLogs: React.FC = () => {
</table>
</div>
</div>
{artifact && (
<ArtifactDrawer
decky={artifact.decky}
storedAs={artifact.storedAs}
fields={artifact.fields}
onClose={() => setArtifact(null)}
/>
)}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import api from '../utils/api';
import './Login.css';
import { Activity } from 'lucide-react';
import { Activity } from '../icons';
interface LoginProps {
onLogin: (token: string) => void;

View File

@@ -0,0 +1,216 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, Download, AlertTriangle, Paperclip } from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
interface MailDrawerProps {
decky: string;
storedAs: string;
fields: Record<string, any>;
onClose: () => void;
}
interface AttachmentManifest {
filename?: string | null;
content_type?: string | null;
size?: number | null;
sha256?: string | null;
}
function parseAttachments(fields: Record<string, any>): AttachmentManifest[] {
const raw = fields.attachments_json;
if (typeof raw !== 'string' || !raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
console.error('mail: failed to parse attachments_json', err);
return [];
}
}
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}></span>}</div>
</div>
);
const MailDrawer: React.FC<MailDrawerProps> = ({ decky, storedAs, fields, onClose }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const attachments = parseAttachments(fields);
const handleDownload = async () => {
setDownloading(true);
setError(null);
try {
const res = await api.get(
`/artifacts/${encodeURIComponent(decky)}/${encodeURIComponent(storedAs)}?service=smtp`,
{ responseType: 'blob' },
);
const blobUrl = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = blobUrl;
a.download = storedAs;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err: any) {
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to download mail.' :
status === 404 ? 'Message not found on disk (may have been purged).' :
status === 400 ? 'Server rejected the request (invalid parameters).' :
'Download failed — see console.'
);
console.error('mail download failed', err);
} finally {
setDownloading(false);
}
};
const recipients = Array.isArray(fields.rcpts)
? fields.rcpts.join(', ')
: (typeof fields.rcpts === 'string' ? fields.rcpts : null);
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'flex-end',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
style={{
width: 'min(620px, 100%)', height: '100%',
backgroundColor: 'var(--bg-color, #0d1117)',
borderLeft: '1px solid var(--border-color, #30363d)',
padding: '24px', overflowY: 'auto',
color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
STORED MESSAGE · {decky}
</div>
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', wordBreak: 'break-all' }}>
{fields.subject || storedAs}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: '1px solid rgba(255, 170, 0, 0.3)',
backgroundColor: 'rgba(255, 170, 0, 0.05)',
fontSize: '0.75rem', color: '#ffaa00',
}}>
<AlertTriangle size={14} />
Attacker-controlled content. Phishing kits / malware likely.
</div>
<button
onClick={handleDownload}
disabled={downloading}
style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 14px', marginBottom: '20px',
border: '1px solid var(--text-color)',
background: 'transparent', color: 'var(--text-color)',
cursor: downloading ? 'wait' : 'pointer',
opacity: downloading ? 0.5 : 1,
}}
>
<Download size={14} /> {downloading ? 'DOWNLOADING…' : 'DOWNLOAD .EML'}
</button>
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
)}
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
HEADERS
</h3>
<Row label="Subject" value={fields.subject} />
<Row label="From" value={fields.from_addr ?? fields.from} />
<Row label="To" value={recipients} />
<Row label="Date" value={fields.date} />
<Row label="Message-ID" value={fields.message_id} />
<Row label="Mail from" value={fields.mail_from} />
</section>
<section style={{ marginBottom: '24px' }}>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
BODY
</h3>
<Row label="Size" value={fields.size ? `${fields.size} bytes` : null} />
<Row label="SHA-256" value={fields.sha256} />
<Row label="Truncated" value={fields.truncated ? 'yes (10 MB cap)' : 'no'} />
<Row label="Stored as" value={storedAs} />
</section>
{attachments.length > 0 && (
<section>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
ATTACHMENTS ({attachments.length})
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{attachments.map((att, idx) => (
<div
key={idx}
style={{
padding: '8px 12px',
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.02)',
fontSize: '0.8rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<Paperclip size={12} />
<span style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
{att.filename || '(unnamed)'}
</span>
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace' }}>
{att.content_type ?? '?'} · {att.size != null ? `${att.size} B` : '? B'}
</div>
{att.sha256 && (
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{att.sha256}
</div>
)}
</div>
))}
</div>
</section>
)}
</div>
</div>
);
};
export default MailDrawer;

View File

@@ -0,0 +1,281 @@
import React, { forwardRef, useCallback, useMemo } from 'react';
import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from '../../icons';
import NetBox from './NetBox';
import NodeCard from './NodeCard';
import type { Net, MazeNode, Edge } from './types';
import type { Selection } from './Inspector';
import type { ResizeHandle } from './useMazeInteraction';
interface Props {
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
deployed: boolean;
selection: Selection;
setSelection: (s: Selection) => void;
pan: { x: number; y: number };
zoom: number;
dropTargetId: string | null;
dragging: boolean;
edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null;
onCanvasMouseDown: (e: React.MouseEvent) => void;
onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
onPortMouseDown: (id: string) => (e: React.MouseEvent) => void;
onNodeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void;
onCanvasContextMenu?: (e: React.MouseEvent) => void;
onResetView?: () => void;
onAutoLayout?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
sseConnected?: boolean;
lastEventAt?: Date | null;
onSelectService?: (nodeId: string, slug: string) => void;
panLayerRef?: React.RefObject<HTMLDivElement | null>;
gridPatternRef?: React.RefObject<SVGPatternElement | null>;
}
const fmtTime = (d: Date) =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
const NODE_W = 140;
const NODE_HEAD_H = 22;
const Canvas = forwardRef<HTMLDivElement, Props>(function Canvas(
{ nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw,
onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown,
onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu,
onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService,
panLayerRef, gridPatternRef },
ref,
) {
const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]);
// Pre-indexed node lookup so edge rendering is O(E) instead of
// O(E·N) from the prior `nodes.find(...)` inside the edge loop.
const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const absPos = (node: MazeNode) => {
const net = netById.get(node.netId);
return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y };
};
// Stable per-kind selection callbacks so React.memo on children
// (NetBox/NodeCard) can actually short-circuit re-renders instead
// of seeing a fresh closure on every Canvas render.
const selectNet = useCallback((id: string) => setSelection({ type: 'net', id }), [setSelection]);
const selectNode = useCallback((id: string) => setSelection({ type: 'node', id }), [setSelection]);
// Flowing-dash edge animation is the single most expensive thing
// on the canvas — each animated <path> invalidates its bounding
// box every frame, and inter-LAN paths are long so the invalidated
// rects overlap most of the viewport. Past ~60 edges the compositor
// spends every frame repainting. Drop the animation class above
// the threshold; edges stay fully visible, just static.
const ANIMATE_EDGE_LIMIT = 60;
const animateEdges = edges.length <= ANIMATE_EDGE_LIMIT;
const activeNetIds = useMemo(() => {
const nodeNet = new Map(nodes.map((n) => [n.id, n.netId]));
const ids = new Set<string>();
for (const e of edges) {
const a = nodeNet.get(e.from); const b = nodeNet.get(e.to);
if (a) ids.add(a); if (b) ids.add(b);
}
return ids;
}, [nodes, edges]);
const selNetId = selection?.type === 'net' ? selection.id : null;
const selNodeId = selection?.type === 'node' ? selection.id
: selection?.type === 'service' ? selection.nodeId : null;
const selEdgeId = selection?.type === 'edge' ? selection.id : null;
const selServiceNodeId = selection?.type === 'service' ? selection.nodeId : null;
const selServiceSlug = selection?.type === 'service' ? selection.id : null;
return (
<div
ref={ref}
className="maze-canvas-wrap"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setSelection(null);
onCanvasMouseDown(e);
}}
onContextMenu={(e) => {
if (e.target === e.currentTarget && onCanvasContextMenu) onCanvasContextMenu(e);
}}
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
>
<div className="maze-grid-bg">
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
ref={gridPatternRef ?? null}
id="maze-grid-pat"
x={pan.x}
y={pan.y}
width={40 * zoom}
height={40 * zoom}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${40 * zoom} 0 L 0 0 0 ${40 * zoom}`}
fill="none"
stroke="var(--grid-line)"
strokeWidth="1"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#maze-grid-pat)" />
</svg>
</div>
<div
ref={panLayerRef ?? null}
className="maze-pan-layer"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<svg className="maze-svg" overflow="visible">
<defs>
<marker id="arrow-matrix" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#00ff41" />
</marker>
<marker id="arrow-violet" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#ee82ee" />
</marker>
<marker id="arrow-alert" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M0,0 L10,5 L0,10 z" fill="#ff4141" />
</marker>
</defs>
{edges.map((e) => {
const from = nodeById.get(e.from);
const to = nodeById.get(e.to);
if (!from || !to) return null;
const a = absPos(from); const b = absPos(to);
const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H;
const x2 = b.x, y2 = b.y + NODE_HEAD_H;
const cx = (x1 + x2) / 2;
const d = `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`;
const klass = e.traffic === 'hot' ? 'hot' : e.traffic === 'active' ? 'active' : '';
const marker = e.traffic === 'hot' ? 'arrow-alert' : e.traffic === 'active' ? 'arrow-violet' : 'arrow-matrix';
const isSel = e.id === selEdgeId;
return (
<g key={e.id} style={{ pointerEvents: 'auto' }}
onClick={(ev) => { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }}
onContextMenu={onEdgeContextMenu?.(e.id)}>
<path d={d} className={`maze-edge ${klass} ${animateEdges ? 'maze-edge-dash' : ''}`} markerEnd={`url(#${marker})`}
style={{ strokeWidth: isSel ? 2.5 : 1.5 }} />
<path d={d} stroke="transparent" strokeWidth="12" fill="none" style={{ cursor: 'pointer' }} />
{e.label && (
<text x={cx} y={(y1 + y2) / 2 - 6} textAnchor="middle"
fill={e.traffic === 'hot' ? '#ff4141' : '#ee82ee'}
fontSize="9" fontFamily="var(--font-mono)" letterSpacing="1">
{e.label}
</text>
)}
</g>
);
})}
{edgeDraw && (() => {
const cx = (edgeDraw.fromX + edgeDraw.toX) / 2;
const d = `M${edgeDraw.fromX},${edgeDraw.fromY} C${cx},${edgeDraw.fromY} ${cx},${edgeDraw.toY} ${edgeDraw.toX},${edgeDraw.toY}`;
return <path d={d} className={`ghost-edge ${edgeDraw.hoverTarget ? 'snap' : ''}`} />;
})()}
</svg>
<div className="maze-nodes">
{nets.map((net) => {
const inactive = net.kind !== 'internet' && !activeNetIds.has(net.id);
return (
<NetBox
key={net.id}
net={net}
selected={net.id === selNetId}
dropTarget={dropTargetId === net.id}
inactive={inactive}
deployed={deployed}
onSelect={selectNet}
onHeaderMouseDown={onNetMouseDown}
onResizeMouseDown={onNetResizeMouseDown}
onContextMenu={onNetContextMenu?.(net.id)}
/>
);
})}
{nodes.map((n) => {
const p = absPos(n);
return (
<NodeCard
key={n.id}
node={n}
absX={p.x}
absY={p.y}
selected={n.id === selNodeId}
deployed={deployed}
dragging={dragging && n.id === selNodeId}
selectedServiceSlug={n.id === selServiceNodeId ? selServiceSlug : null}
onSelect={selectNode}
onSelectService={onSelectService}
onMouseDown={onNodeMouseDown}
onPortMouseDown={onPortMouseDown}
onContextMenu={onNodeContextMenu}
/>
);
})}
</div>
</div>
{(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
<div className="maze-toolbar">
{onResetView && (
<button type="button" className="maze-btn ghost small" onClick={onResetView} title="Reset pan + zoom">
<RotateCcw size={11} /> RESET VIEW
</button>
)}
{onAutoLayout && (
<button type="button" className="maze-btn ghost small" onClick={onAutoLayout} title="Auto-layout nodes">
<LayoutGrid size={11} /> AUTO-LAYOUT
</button>
)}
{onZoomOut && (
<button type="button" className="maze-btn ghost small" onClick={onZoomOut} title="Zoom out">
<ZoomOut size={11} />
</button>
)}
{onZoomIn && (
<button type="button" className="maze-btn ghost small" onClick={onZoomIn} title="Zoom in">
<ZoomIn size={11} />
</button>
)}
</div>
)}
<div className="maze-status">
<span className={`status-seg ${sseConnected ? 'live' : 'dim'}`}>
<span className={`status-dot ${sseConnected ? 'active' : 'idle'}`} />
GRAPH {sseConnected ? 'LIVE' : 'IDLE'}
</span>
<span className="status-seg">PAN: {Math.round(pan.x)},{Math.round(pan.y)}</span>
<span className="status-seg">ZOOM: {Math.round(zoom * 100)}%</span>
<span className="status-seg">AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'}</span>
{!animateEdges && (
<span className="status-seg" title={`Flow animation auto-disabled above ${ANIMATE_EDGE_LIMIT} edges (${edges.length} active) to keep the canvas responsive.`}>
MOTION: OFF
</span>
)}
</div>
<div className="maze-legend">
<div className="lg-row"><span className="lg-swatch alert" /> ACTIVE ATTACK</div>
<div className="lg-row"><span className="lg-swatch violet" /> OBSERVED FLOW</div>
<div className="lg-row"><span className="lg-swatch matrix" /> CONFIGURED</div>
<div className="lg-row"><span className="lg-swatch inactive" /> INACTIVE NET</div>
</div>
</div>
);
});
export default Canvas;

View File

@@ -0,0 +1,98 @@
import React, { useEffect, useRef, useState } from 'react';
import { ChevronRight } from '../../icons';
export interface MenuItem {
label: string;
onClick?: () => void;
disabled?: boolean;
title?: string;
danger?: boolean;
separator?: boolean;
icon?: React.ReactNode;
submenu?: MenuItem[];
}
interface Props {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
title?: string;
}
const ContextMenu: React.FC<Props> = ({ x, y, items, onClose, title }) => {
const ref = useRef<HTMLDivElement>(null);
const [openSub, setOpenSub] = useState<number | null>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', onDown);
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
const renderItem = (it: MenuItem, i: number) => {
if (it.separator) return <div key={i} className="ctx-divider" />;
const hasSub = !!it.submenu?.length;
return (
<div
key={i}
className="ctx-item-wrap"
onMouseEnter={() => setOpenSub(hasSub ? i : null)}
>
<button
type="button"
className={`ctx-item ${it.danger ? 'danger' : ''}`}
disabled={it.disabled}
title={it.title}
onClick={() => {
if (it.disabled) return;
if (hasSub) return;
it.onClick?.();
onClose();
}}
>
{it.icon && <span className="ctx-icon">{it.icon}</span>}
<span className="ctx-label">{it.label}</span>
{hasSub && <ChevronRight size={12} className="ctx-chev" />}
</button>
{hasSub && openSub === i && (
<div className="ctx-submenu">
{it.submenu!.map((s, j) =>
s.separator ? (
<div key={j} className="ctx-divider" />
) : (
<button
key={j}
type="button"
className={`ctx-item ${s.danger ? 'danger' : ''}`}
disabled={s.disabled}
title={s.title}
onClick={() => { if (!s.disabled) { s.onClick?.(); onClose(); } }}
>
{s.icon && <span className="ctx-icon">{s.icon}</span>}
<span className="ctx-label">{s.label}</span>
</button>
),
)}
</div>
)}
</div>
);
};
return (
<div ref={ref} className="ctx-menu" style={{ left: x, top: y }}>
{title && <div className="ctx-title">{title}</div>}
{items.map(renderItem)}
</div>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,303 @@
import React, { useMemo } from 'react';
import {
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
Server, Trash2, X, Shield,
} from '../../icons';
import type { Net, MazeNode, Edge } from './types';
import { DEFAULT_SERVICES } from './data';
export type Selection =
| { type: 'net'; id: string }
| { type: 'node'; id: string }
| { type: 'edge'; id: string }
| { type: 'service'; id: string; nodeId: string }
| null;
interface Props {
selection: Selection;
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
topologyStatus?: string;
onClose?: () => void;
onDeleteNet?: (id: string) => void;
onDeleteNode?: (id: string) => void;
onDeleteEdge?: (id: string) => void;
onRemoveService?: (nodeId: string, slug: string) => void;
onAddDecky?: (netId: string) => void;
setSelection?: (sel: Selection) => void;
pendingChanges?: number;
className?: string;
}
const Inspector: React.FC<Props> = ({
selection, nets, nodes, edges, topologyStatus, onClose,
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onAddDecky, setSelection,
pendingChanges = 0,
className = '',
}) => {
const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined;
const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined;
const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined;
const serviceSel = selection?.type === 'service' ? selection : undefined;
const serviceMeta = serviceSel ? DEFAULT_SERVICES.find((s) => s.slug === serviceSel.id) : undefined;
const serviceParent = serviceSel ? nodes.find((n) => n.id === serviceSel.nodeId) : undefined;
const serviceParentNet = serviceParent ? nets.find((n) => n.id === serviceParent.netId) : undefined;
const activeNetIds = useMemo(() => {
const s = new Set<string>();
edges.forEach((e) => {
const f = nodes.find((n) => n.id === e.from);
const t = nodes.find((n) => n.id === e.to);
if (f) s.add(f.netId);
if (t) s.add(t.netId);
});
return s;
}, [edges, nodes]);
const typeLabel = selection ? selection.type.toUpperCase() : 'IDLE';
const isGateway = node?.kind === 'decky' && !!node.decky_config?.forwards_l3;
const isObserved = node?.kind === 'observed';
return (
<aside className={`maze-inspector ${className}`}>
<div className="maze-inspector-title">
<Crosshair size={12} className="violet-accent" />
<span>INSPECTOR</span>
<span className="dim inspector-type-label">{typeLabel}</span>
{onClose && (
<button
type="button"
className="inspector-close-btn"
onClick={onClose}
title="Hide inspector"
>
<X size={12} />
</button>
)}
</div>
<div className="maze-inspector-body">
{!selection && (
<div className="inspector-empty">
<MousePointer2 size={22} style={{ opacity: 0.4, marginBottom: 10 }} />
<div>SELECT A NODE, NETWORK, OR EDGE</div>
<div style={{ marginTop: 10, fontSize: '0.6rem', opacity: 0.5 }}>
Right-click for actions
</div>
</div>
)}
{node && (
<>
<div className="inspector-head">
<span className={`status-dot ${node.status}`} />
<span className="inspector-head-title">{node.name}</span>
<span className="chip violet inspector-head-chip">{node.archetype}</span>
</div>
<div className="kvs">
<div className="k">NETWORK</div>
<div className="v violet-accent">
{nets.find((nn) => nn.id === node.netId)?.label ?? node.netId}
</div>
<div className="k">STATUS</div>
<div className="v">{node.status.toUpperCase()}</div>
<div className="k">SERVICES</div>
<div className="v">
<div className="inspector-service-row">
{node.services.length === 0 && <span className="dim"></span>}
{node.services.map((s) => (
<span key={s} className="service-tag">{s}</span>
))}
</div>
</div>
</div>
<div>
<div className="type-label inspector-section-label">CONNECTIONS</div>
{edges.filter((e) => e.from === node.id || e.to === node.id).map((e) => {
const otherId = e.from === node.id ? e.to : e.from;
const other = nodes.find((n) => n.id === otherId);
const Arrow = e.from === node.id ? ArrowRight : ArrowLeft;
return (
<div key={e.id} className="inspector-conn-row">
<Arrow size={10} className={e.traffic === 'hot' ? 'alert-text' : 'dim'} />
<span>{other?.name ?? '—'}</span>
<span className="chip dim-chip inspector-conn-chip">{e.traffic}</span>
</div>
);
})}
{edges.filter((e) => e.from === node.id || e.to === node.id).length === 0 && (
<div className="dim inspector-empty-line">NO EDGES</div>
)}
</div>
{onDeleteNode && (
<button
type="button"
className="maze-btn alert small"
disabled={isObserved || isGateway}
title={
isObserved ? 'observed entity — not a deployed decky'
: isGateway ? 'DMZ gateway — pinned to its DMZ network'
: undefined
}
onClick={() => !isObserved && !isGateway && onDeleteNode(node.id)}
>
<Trash2 size={12} /> REMOVE FROM GRAPH
</button>
)}
</>
)}
{net && (
<>
<div className="inspector-head">
{net.kind === 'internet'
? <Globe size={14} className="violet-accent" />
: <GitMerge size={14} className="violet-accent" />}
<span className="inspector-head-title">{net.label}</span>
{net.kind !== 'internet' && !activeNetIds.has(net.id) && (
<span className="chip-mini inspector-head-chip">INACTIVE</span>
)}
</div>
<div className="kvs">
<div className="k">KIND</div><div className="v">{net.kind.toUpperCase()}</div>
<div className="k">CIDR</div><div className="v">{net.cidr}</div>
<div className="k">DECKIES</div>
<div className="v" style={{ fontWeight: 700 }}>
{nodes.filter((n) => n.netId === net.id).length}
</div>
</div>
<div>
<div className="type-label inspector-section-label">MEMBERS</div>
{nodes.filter((n) => n.netId === net.id).map((n) => (
<div
key={n.id}
className="inspector-member-row"
onClick={() => setSelection?.({ type: 'node', id: n.id })}
>
<span className={`status-dot ${n.status}`} />
<span>{n.name}</span>
<span className="dim inspector-member-arch">{n.archetype}</span>
</div>
))}
{nodes.filter((n) => n.netId === net.id).length === 0 && (
<div className="dim inspector-empty-line">NO MEMBERS</div>
)}
</div>
{net.kind !== 'internet' && onAddDecky && (
<button type="button" className="maze-btn small" onClick={() => onAddDecky(net.id)}>
<Plus size={10} /> ADD DECKY
</button>
)}
{net.kind !== 'internet' && onDeleteNet && (
<button
type="button"
className="maze-btn alert small"
onClick={() => onDeleteNet(net.id)}
>
<Trash2 size={10} /> REMOVE NETWORK
</button>
)}
</>
)}
{edge && (
<>
<div className="inspector-head">
<Server size={14} className="violet-accent" />
<span className="inspector-head-title">EDGE · {edge.id.slice(0, 8)}</span>
</div>
<div className="kvs">
<div className="k">FROM</div>
<div className="v">{nodes.find((n) => n.id === edge.from)?.name ?? edge.from}</div>
<div className="k">TO</div>
<div className="v">{nodes.find((n) => n.id === edge.to)?.name ?? edge.to}</div>
<div className="k">TRAFFIC</div>
<div className="v">{edge.traffic.toUpperCase()}</div>
{edge.label && (
<>
<div className="k">LABEL</div>
<div className="v">{edge.label}</div>
</>
)}
</div>
{onDeleteEdge && (
<button
type="button"
className="maze-btn alert small"
onClick={() => onDeleteEdge(edge.id)}
>
<Trash2 size={10} /> CUT EDGE
</button>
)}
</>
)}
{serviceSel && (
<>
<div className="inspector-head">
<Shield
size={14}
className={serviceMeta?.risk === 'high' ? 'alert-text' : 'violet-accent'}
/>
<span className="inspector-head-title">
{serviceMeta?.name ?? serviceSel.id.toUpperCase()}
</span>
{serviceMeta && (
<span className={`chip inspector-head-chip ${
serviceMeta.risk === 'high' ? 'alert'
: serviceMeta.risk === 'med' ? 'violet'
: 'dim-chip'
}`}>
{serviceMeta.risk.toUpperCase()}
</span>
)}
</div>
<div className="kvs">
<div className="k">EXPOSED ON</div>
<div className="v violet-accent">{serviceParent?.name ?? '—'}</div>
<div className="k">PROTOCOL</div>
<div className="v">{(serviceMeta?.proto ?? '—').toUpperCase()}</div>
<div className="k">PORT</div>
<div className="v" style={{ fontWeight: 700 }}>{serviceMeta?.port ?? '—'}</div>
<div className="k">SUBNET</div>
<div className="v">{serviceParentNet?.label ?? '—'}</div>
</div>
{onRemoveService && serviceParent && serviceParent.kind !== 'observed' && (
<button
type="button"
className="maze-btn alert small"
disabled={topologyStatus === 'degraded'}
title={topologyStatus === 'degraded' ? 'topology degraded — mutations blocked' : undefined}
onClick={() => onRemoveService(serviceSel.nodeId, serviceSel.id)}
>
<Trash2 size={10} /> REMOVE SERVICE
</button>
)}
</>
)}
{pendingChanges > 0 && (
<div className="inspector-diff-block">
<div className="type-label inspector-section-label">PENDING DIFF</div>
<div className="maze-diff">
<span className="ctx"> +{pendingChanges} graph mutation(s)</span>{'\n'}
<span className="ctx"> networks: {nets.length}</span>{'\n'}
<span className="ctx"> deckies: {nodes.length}</span>{'\n'}
<span className="ctx"> paths: {edges.length}</span>
</div>
</div>
)}
{topologyStatus && !selection && (
<div className="kvs inspector-status-block">
<div className="k">TOPOLOGY</div>
<div className="v">{topologyStatus.toUpperCase()}</div>
</div>
)}
</div>
</aside>
);
};
export default Inspector;

View File

@@ -0,0 +1,590 @@
/* ── MazeNET canvas ─────────────────────────── */
body.maze-fullscreen .sidebar,
body.maze-fullscreen .topbar {
display: none !important;
}
body.maze-fullscreen .content-viewport {
padding: 16px 32px;
}
body.maze-fullscreen .maze-shell {
/* Full viewport minus content-viewport padding (16 top + 32 bottom) and header+gap. */
/* With flex:1 this stays correct because maze-page fills 100% of the new viewport. */
}
.maze-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
}
.maze-page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
gap: 24px;
}
.maze-page-header h1 { font-size: 1.3rem; letter-spacing: 4px; font-weight: 700; }
.maze-page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; }
.maze-page-actions { display: flex; gap: 10px; align-items: center; }
.maze-btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
}
.maze-btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.maze-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.maze-btn.ghost:hover {
background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow);
}
.maze-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.maze-btn:disabled:hover { background: transparent; color: var(--matrix); box-shadow: none; }
.maze-shell {
display: grid;
grid-template-columns: 240px 1fr 320px;
gap: 0;
flex: 1;
min-height: 0;
margin: 0 -32px -32px;
border-top: 1px solid var(--border);
transition: grid-template-columns 260ms cubic-bezier(0.4, 0, 0.2, 1);
}
.maze-palette,
.maze-inspector {
transition: opacity 200ms ease, transform 260ms cubic-bezier(0.4, 0, 0.2, 1);
min-width: 0;
}
.maze-palette.collapsed,
.maze-inspector.collapsed {
opacity: 0;
pointer-events: none;
overflow: hidden;
}
.maze-palette.collapsed { transform: translateX(-8px); }
.maze-inspector.collapsed { transform: translateX(8px); }
/* ── Palette ────────────────────────────────── */
.maze-palette {
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 18px;
}
.palette-group { display: flex; flex-direction: column; gap: 6px; }
.palette-group > label {
font-size: 0.6rem; letter-spacing: 1.5px; opacity: 0.5; text-transform: uppercase;
}
.palette-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border: 1px solid var(--border);
cursor: grab; transition: all 0.15s; font-size: 0.72rem;
color: var(--matrix); background: transparent;
}
.palette-item:hover {
border-color: var(--violet); color: var(--violet); background: var(--violet-tint-10);
}
.palette-item:active { cursor: grabbing; }
.palette-item .chip-mini {
font-size: 0.68rem; padding: 2px 6px;
border: 1px solid var(--accent-tint-30);
background: var(--accent-tint-10);
color: var(--accent);
letter-spacing: 0.5px; margin-left: auto;
font-family: var(--font-mono); opacity: 1;
}
.palette-hint {
font-size: 0.62rem; opacity: 0.5; line-height: 1.6; letter-spacing: 0.5px;
}
.palette-subgroup { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; }
.palette-subgroup:first-child { margin-top: 0; }
.palette-subgroup-label {
font-size: 0.55rem; letter-spacing: 1.5px; opacity: 0.4;
color: var(--violet); margin-top: 6px;
}
.violet-accent { color: var(--violet); }
.alert-text { color: var(--alert); }
.matrix-text { color: var(--matrix); }
/* ── Canvas ─────────────────────────────────── */
.maze-canvas-wrap {
position: relative; background: #000;
overflow: hidden; user-select: none;
height: 100%; min-height: 0;
}
.maze-pan-layer { position: absolute; inset: 0; will-change: transform; }
.maze-grid-bg {
position: absolute; inset: 0;
pointer-events: none; opacity: 0.6; overflow: hidden; background: #000;
}
.maze-grid-bg svg { display: block; width: 100%; height: 100%; }
.maze-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.maze-nodes { position: absolute; inset: 0; }
.maze-empty-hint {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none; font-size: 0.72rem; letter-spacing: 1.5px;
color: var(--fg-4); text-transform: uppercase;
}
/* ── Network box ────────────────────────────── */
.maze-net-box {
position: absolute;
border: 1px dashed var(--border);
background: rgba(13, 17, 23, 0.6);
padding: 32px 16px 16px;
min-width: 220px; min-height: 140px;
transition: border-color 0.2s;
cursor: move;
}
.maze-net-box.selected {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet-tint-10) inset;
}
.maze-net-box.drop-target {
border-color: var(--matrix); background: var(--matrix-tint-5);
}
.maze-net-box.internet {
border-color: var(--alert); background: rgba(255, 65, 65, 0.04);
}
.maze-net-box.dmz {
border-color: var(--alert); background: rgba(255, 65, 65, 0.06);
border-style: dashed;
}
.maze-net-box.dmz .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.45);
}
/* Deployed: topology is active/degraded — make it visually unmistakable.
* Subnet LANs glow matrix-green; DMZ stays hot red (and gets a stronger
* glow so you can tell it's live). */
.maze-net-box.deployed {
border-style: solid;
border-color: var(--matrix);
background: rgba(0, 255, 65, 0.05);
box-shadow: 0 0 0 1px rgba(0, 255, 65, 0.25) inset, var(--matrix-glow);
}
.maze-net-box.deployed .maze-net-box-head {
color: var(--matrix); border-bottom-color: rgba(0, 255, 65, 0.45);
}
.maze-net-box.deployed.dmz {
border-color: var(--alert);
background: rgba(255, 65, 65, 0.09);
box-shadow: 0 0 0 1px rgba(255, 65, 65, 0.35) inset,
0 0 16px rgba(255, 65, 65, 0.5);
}
.maze-net-box.deployed.dmz .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.55);
}
.maze-net-box.inactive {
opacity: 0.42; filter: grayscale(0.7); border-style: dotted;
}
.maze-net-box.inactive .maze-net-box-head { color: rgba(255, 255, 255, 0.5); }
/* Optimistic placeholder for an enqueued LAN-add. Amber tint matches
* the REAP button voice — clearly "in-flight, not committed" without
* collapsing into the dimmed-out 'inactive' style which means the
* opposite (no traffic on a deployed LAN). */
.maze-net-box.pending {
border-color: var(--warn, #e0a040);
background: rgba(224, 160, 64, 0.04);
border-style: dashed;
filter: none; opacity: 1;
}
.maze-net-box.pending .maze-net-box-head {
color: var(--warn, #e0a040);
border-bottom-color: rgba(224, 160, 64, 0.45);
}
.maze-net-box.pending .cidr { color: rgba(224, 160, 64, 0.7); }
.maze-net-box-head {
position: absolute; top: 0; left: 0; right: 0;
padding: 6px 12px; border-bottom: 1px dashed var(--border);
display: flex; align-items: center; gap: 8px; justify-content: space-between;
font-size: 0.65rem; letter-spacing: 1.5px; opacity: 0.8;
background: rgba(13, 17, 23, 0.8); cursor: move;
}
.maze-net-box-head .cidr { opacity: 0.5; font-size: 0.6rem; letter-spacing: 1px; }
.maze-net-box.internet .maze-net-box-head {
color: var(--alert); border-bottom-color: rgba(255, 65, 65, 0.4);
}
/* ── Network resize handles ─────────────────── */
.net-resize { position: absolute; z-index: 2; }
.net-resize-e { top: 8px; bottom: 8px; right: -4px; width: 8px; cursor: ew-resize; }
.net-resize-w { top: 8px; bottom: 8px; left: -4px; width: 8px; cursor: ew-resize; }
.net-resize-s { left: 8px; right: 8px; bottom: -4px; height: 8px; cursor: ns-resize; }
.net-resize-n { left: 8px; right: 8px; top: -4px; height: 8px; cursor: ns-resize; }
.net-resize-se { right: -5px; bottom: -5px; width: 12px; height: 12px; cursor: nwse-resize; }
.net-resize-sw { left: -5px; bottom: -5px; width: 12px; height: 12px; cursor: nesw-resize; }
.net-resize-ne { right: -5px; top: -5px; width: 12px; height: 12px; cursor: nesw-resize; }
.net-resize-nw { left: -5px; top: -5px; width: 12px; height: 12px; cursor: nwse-resize; }
.maze-net-box.selected .net-resize-se,
.maze-net-box.selected .net-resize-sw,
.maze-net-box.selected .net-resize-ne,
.maze-net-box.selected .net-resize-nw { background: var(--violet); opacity: 0.5; }
.maze-net-box:hover .net-resize-se,
.maze-net-box:hover .net-resize-sw,
.maze-net-box:hover .net-resize-ne,
.maze-net-box:hover .net-resize-nw { background: var(--border); opacity: 0.8; }
/* ── Decky/observed node card ──────────────── */
.maze-node {
position: absolute; width: 140px;
background: var(--panel); border: 1px solid var(--border);
padding: 8px 10px; cursor: grab;
transition: border-color 0.15s, box-shadow 0.15s;
user-select: none; display: flex; flex-direction: column; gap: 4px;
}
.maze-node:hover { border-color: var(--matrix); box-shadow: var(--matrix-glow); z-index: 3; }
.maze-node.selected { border-color: var(--violet); box-shadow: var(--violet-glow); z-index: 4; }
.maze-node.hot { border-color: var(--alert); }
.maze-node.hot::after {
content: ''; position: absolute; inset: -1px;
border: 1px solid var(--alert); opacity: 0.4;
pointer-events: none; animation: decnet-pulse 1s infinite alternate;
}
.maze-node.observed { border-style: dashed; }
.maze-node.dragging { opacity: 0.8; z-index: 10; cursor: grabbing; }
.maze-node.deployed {
border-color: var(--matrix);
box-shadow: var(--matrix-glow);
background: rgba(0, 255, 65, 0.04);
}
.maze-node.deployed .mn-head { color: var(--matrix); }
.maze-node.deployed.dmz-gateway {
border-color: var(--alert);
box-shadow: 0 0 12px rgba(255, 65, 65, 0.55);
background: rgba(255, 65, 65, 0.06);
}
.maze-node.deployed.dmz-gateway .mn-head { color: var(--alert); }
.maze-node .mn-head {
display: flex; align-items: center; gap: 6px;
font-size: 0.74rem; font-weight: 700; letter-spacing: 0.5px;
}
.maze-node .mn-sub { font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px; }
.maze-node .mn-services { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 2px; }
.maze-node .mn-services .service-tag {
font-size: 0.55rem; padding: 1px 5px; letter-spacing: 0.5px;
cursor: pointer; transition: all 0.12s;
border: 1px solid var(--violet); color: var(--violet); border-radius: 2px;
white-space: nowrap;
}
.maze-node .mn-services .service-tag:hover { background: var(--violet-tint-10); }
.maze-node .mn-services .service-tag.hot { border-color: var(--alert); color: var(--alert); }
.maze-node .mn-services .service-tag.service-selected {
background: var(--violet); color: #000; font-weight: 700;
}
.maze-node .mn-port {
position: absolute; width: 8px; height: 8px;
background: var(--panel); border: 1px solid var(--matrix);
top: 50%; transform: translateY(-50%);
}
.maze-node .mn-port.in { left: -5px; }
.maze-node .mn-port.out { right: -5px; cursor: crosshair; }
.maze-node .mn-port:hover { background: var(--matrix); box-shadow: var(--matrix-glow); }
/* ── Edges ──────────────────────────────────── */
.maze-edge { stroke: var(--matrix); stroke-width: 1.5; fill: none; opacity: 0.5; }
.maze-edge.active { opacity: 0.9; stroke: var(--violet); }
.maze-edge.hot { stroke: var(--alert); opacity: 0.9; }
.maze-edge-dash { stroke-dasharray: 4 3; animation: dash-flow 0.6s linear infinite; }
@keyframes dash-flow { to { stroke-dashoffset: -14; } }
.ghost-edge {
stroke: var(--violet); stroke-width: 1.5;
stroke-dasharray: 3 3; opacity: 0.7; fill: none;
}
/* ── Canvas overlays ────────────────────────── */
.maze-toolbar { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; z-index: 5; }
.maze-status {
position: absolute; bottom: 12px; left: 12px;
display: flex; gap: 12px; z-index: 5;
font-size: 0.62rem; opacity: 0.6; letter-spacing: 1px;
background: rgba(0, 0, 0, 0.6); padding: 6px 10px; border: 1px solid var(--border);
}
.maze-legend {
position: absolute; bottom: 12px; right: 12px; z-index: 5;
background: rgba(13, 17, 23, 0.85); border: 1px solid var(--border);
padding: 8px 10px; font-size: 0.6rem; letter-spacing: 1px;
display: flex; flex-direction: column; gap: 4px;
}
.maze-legend .lg-row { display: flex; align-items: center; gap: 6px; }
.maze-legend .lg-swatch { width: 14px; height: 2px; background: var(--matrix); }
.maze-legend .lg-swatch.alert { background: var(--alert); box-shadow: 0 0 6px var(--alert); }
.maze-legend .lg-swatch.violet { background: var(--violet); box-shadow: 0 0 6px var(--violet); }
.maze-legend .lg-swatch.matrix { background: var(--matrix); }
.maze-legend .lg-swatch.inactive {
background: transparent;
height: 0;
border-top: 1px dashed var(--border);
}
/* Status bar segments */
.maze-status .status-seg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.maze-status .status-seg.live { color: var(--matrix); opacity: 0.9; }
.maze-status .status-seg.dim { opacity: 0.45; }
/* Toolbar button sizing override */
.maze-toolbar .maze-btn.small {
padding: 4px 10px; font-size: 0.62rem; letter-spacing: 1.5px;
background: rgba(0, 0, 0, 0.6);
}
/* NodeCard head icon alignment */
.maze-node .mn-head .mn-head-icon { opacity: 0.8; flex-shrink: 0; }
.maze-node .mn-head .mn-head-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ── Inspector ──────────────────────────────── */
.maze-inspector {
background: var(--panel); border-left: 1px solid var(--border);
overflow-y: auto; padding: 0;
display: flex; flex-direction: column;
}
.maze-inspector-title {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px; border-bottom: 1px solid var(--border);
font-size: 0.72rem; letter-spacing: 2px; font-weight: 700;
position: sticky; top: 0; background: var(--panel); z-index: 1; flex-shrink: 0;
}
.maze-inspector-body {
padding: 14px; display: flex; flex-direction: column; gap: 14px;
}
.inspector-empty {
opacity: 0.5; text-align: center;
padding: 30px 10px; font-size: 0.7rem; letter-spacing: 1px;
}
.maze-diff {
background: #000; border: 1px solid var(--border);
padding: 10px 12px; font-size: 0.68rem; line-height: 1.6;
white-space: pre; overflow-x: auto;
}
.maze-diff .add { color: var(--matrix); }
.maze-diff .rem { color: var(--alert); }
.maze-diff .ctx { opacity: 0.5; }
.kvs {
display: grid; grid-template-columns: 140px 1fr;
gap: 8px 14px; font-size: 0.78rem;
}
.kvs .k {
opacity: 0.5; letter-spacing: 1px; font-size: 0.65rem;
text-transform: uppercase; align-self: center;
}
.kvs .v { color: var(--matrix); word-break: break-all; }
/* ── Context menu ───────────────────────────── */
.ctx-scrim { position: absolute; inset: 0; z-index: 30; }
.ctx-menu {
position: fixed; z-index: 1000;
width: auto; border-radius: var(--radius-0, 0);
background: var(--panel); border: 1px solid var(--violet);
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8), var(--violet-glow);
padding: 4px 0; font-family: var(--font-mono);
}
.ctx-header {
font-size: 0.58rem; letter-spacing: 2px; opacity: 0.5;
padding: 6px 12px 4px; border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.ctx-title {
font-size: 0.58rem; letter-spacing: 2px; opacity: 0.5;
padding: 6px 12px 4px; border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.ctx-item-wrap { position: relative; }
.ctx-icon { display: inline-flex; width: 14px; align-items: center; justify-content: center; opacity: 0.8; }
.ctx-label { flex: 1; }
.ctx-chev { opacity: 0.6; }
.ctx-item {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 7px 12px; font-size: 0.74rem; cursor: pointer; letter-spacing: 0.5px;
background: transparent; border: 0; color: var(--matrix); text-align: left;
font-family: inherit;
}
.ctx-item:disabled { opacity: 0.35; cursor: not-allowed; }
.ctx-item:disabled:hover { background: transparent; color: inherit; }
.ghost-edge.snap { stroke: var(--matrix); opacity: 0.9; }
.ctx-item:hover { background: var(--violet-tint-10); color: var(--violet); }
.ctx-item.danger { color: var(--alert); }
.ctx-item.danger:hover { background: rgba(255, 65, 65, 0.12); }
.ctx-item.disabled { opacity: 0.35; cursor: not-allowed; }
.ctx-item.disabled:hover { background: transparent; color: inherit; }
.ctx-divider { height: 1px; background: var(--border); margin: 4px 0; }
.palette-ghost {
position: fixed; z-index: 2000; pointer-events: none;
padding: 4px 10px; font-family: var(--font-mono); font-size: 0.68rem;
letter-spacing: 1.5px; background: var(--panel);
border: 1px solid var(--violet); color: var(--violet);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6), var(--violet-glow);
}
.ctx-submenu {
position: absolute; left: 100%; top: 0;
background: var(--panel); border: 1px solid var(--violet);
min-width: 180px; padding: 4px 0;
max-height: 320px; overflow-y: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.8);
}
/* ── Inspector: rich visual layout ──────────── */
.inspector-type-label {
margin-left: auto;
font-size: 0.6rem;
letter-spacing: 1px;
}
.inspector-close-btn {
background: transparent;
border: 1px solid var(--border);
color: rgba(255, 255, 255, 0.5);
padding: 3px 5px;
cursor: pointer;
display: flex;
transition: all 0.15s;
}
.inspector-close-btn:hover { color: var(--alert); border-color: var(--alert); }
.inspector-head {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.inspector-head-title { font-size: 0.9rem; font-weight: 700; }
.inspector-head-chip { margin-left: auto; }
.inspector-section-label { margin-bottom: 6px; }
.type-label {
font-size: 0.6rem;
letter-spacing: 1.5px;
opacity: 0.6;
text-transform: uppercase;
}
.inspector-conn-row {
font-size: 0.7rem;
padding: 6px 0;
border-bottom: 1px dashed var(--border);
display: flex;
gap: 6px;
align-items: center;
}
.inspector-conn-chip { margin-left: auto; }
.inspector-member-row {
font-size: 0.72rem;
padding: 5px 0;
display: flex;
gap: 6px;
align-items: center;
cursor: pointer;
}
.inspector-member-row:hover { color: var(--violet); }
.inspector-member-arch { margin-left: auto; font-size: 0.6rem; }
.inspector-empty-line {
font-size: 0.68rem;
padding: 4px 0;
}
.inspector-diff-block {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.inspector-status-block { margin-top: 12px; }
.dim { opacity: 0.5; }
/* Status dots used in inspector head + member rows */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0;
flex-shrink: 0;
}
.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); }
.status-dot.idle { background: #30363d; }
.status-dot.hot { background: var(--alert); box-shadow: 0 0 8px var(--alert);
animation: decnet-pulse 1s infinite alternate; }
.status-dot.mutating { background: var(--violet); animation: decnet-blink 1s infinite; }
/* Chips — inline badges for archetypes, traffic, severity */
.chip {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--matrix);
color: var(--matrix);
background: var(--matrix-tint-10);
letter-spacing: 1px;
white-space: nowrap;
}
.chip.violet { border-color: var(--violet); color: var(--violet); background: var(--violet-tint-10); }
.chip.matrix { border-color: var(--matrix); color: var(--matrix); background: var(--matrix-tint-10); }
.chip.alert { border-color: var(--alert); color: var(--alert); background: var(--alert-tint-10); }
.chip.dim-chip { border-color: var(--border); color: rgba(0, 255, 65, 0.6); background: transparent; }
.chip-mini {
font-size: 0.55rem;
padding: 1px 5px;
border: 1px solid var(--border);
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.55);
}
/* Inspector buttons */
.maze-btn.small { padding: 5px 10px; font-size: 0.68rem; }
.maze-btn.alert {
border-color: var(--alert);
color: var(--alert);
opacity: 0.85;
}
.maze-btn.alert:hover {
background: var(--alert);
color: #000;
box-shadow: 0 0 10px rgba(255, 65, 65, 0.5);
opacity: 1;
}
/* Service tag reuse for inspector "SERVICES" chip row */
.inspector-service-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.maze-inspector .service-tag {
font-size: 0.6rem;
padding: 2px 6px;
letter-spacing: 0.5px;
border: 1px solid var(--violet);
color: var(--violet);
border-radius: 2px;
}
.dragging-from-palette {
position: fixed; pointer-events: none; z-index: 200;
background: var(--panel); border: 1px solid var(--violet);
padding: 6px 10px; font-size: 0.7rem; color: var(--violet);
box-shadow: var(--violet-glow);
}

View File

@@ -0,0 +1,816 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
PanelRightOpen, PanelRightClose, PanelLeftOpen, PanelLeftClose,
Maximize2, Minimize2, RotateCcw, UploadCloud, ArrowLeft,
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail,
} from '../../icons';
import './MazeNET.css';
import axios from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import Palette from './Palette';
import Canvas from './Canvas';
import Inspector from './Inspector';
import type { Selection } from './Inspector';
import ContextMenu, { type MenuItem } from './ContextMenu';
import { DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { useMazeApi } from './useMazeApi';
import { useTopologyEditor } from './useTopologyEditor';
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
import { useLayoutPersistor } from './useMazeLayoutStore';
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
import { useToast } from '../Toasts/useToast';
/* Short unique suffix for default names — avoids the DB uniqueness
* constraint regardless of delete/re-add sequencing on the client. */
const hex4 = (): string => {
const r = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '')
: Math.random().toString(16).slice(2);
return r.slice(0, 4);
};
const MazeNET: React.FC = () => {
const api = useMazeApi();
const navigate = useNavigate();
const { push: pushToast } = useToast();
const [params] = useSearchParams();
const topologyId = params.get('topology') ?? '';
const { byUuid: hostsByUuid } = useSwarmHosts();
const [nets, setNets] = useState<Net[]>([]);
const [nodes, setNodes] = useState<MazeNode[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const [topoStatus, setTopoStatus] = useState<string>('pending');
const [topoName, setTopoName] = useState<string>('');
const [topoVersion, setTopoVersion] = useState<number>(0);
const [topoTargetHost, setTopoTargetHost] = useState<string | null>(null);
const [topoMode, setTopoMode] = useState<string>('unihost');
const [selection, setSelection] = useState<Selection>(null);
const [inspectorOpen, setInspectorOpen] = useState(true);
const [paletteOpen, setPaletteOpen] = useState(true);
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const cls = 'maze-fullscreen';
if (fullscreen) document.body.classList.add(cls);
else document.body.classList.remove(cls);
return () => document.body.classList.remove(cls);
}, [fullscreen]);
// Request/exit browser fullscreen alongside the in-app chrome hide.
// Ignore failures (fullscreen requires a user gesture; the chrome-only
// mode still works if the API rejects).
useEffect(() => {
if (fullscreen && !document.fullscreenElement) {
document.documentElement.requestFullscreen?.().catch(() => {});
} else if (!fullscreen && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
}
}, [fullscreen]);
// Sync state if the user presses F11/Esc to leave fullscreen from
// outside our button.
useEffect(() => {
const onFsChange = () => {
if (!document.fullscreenElement) setFullscreen(false);
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && fullscreen) setFullscreen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [fullscreen]);
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
useLayoutPersistor(topologyId || null, nets, nodes);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [actionErr, setActionErr] = useState<string | null>(null);
const [deploying, setDeploying] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
const flashErr = useCallback((err: unknown, fallback: string) => {
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
setActionErr(msg);
setTimeout(() => setActionErr(null), 4000);
}, []);
/* ── Palette drop — create LANs / deckies / services via REST ─── */
const onPaletteDrop = useCallback(
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
if (!topologyId) return;
if (drag.kind === 'network-subnet' || drag.kind === 'network-dmz') {
const isDmz = drag.kind === 'network-dmz';
if (isDmz && nets.some((n) => n.kind === 'dmz')) {
flashErr(null, 'topology already has a DMZ');
return;
}
// Append to the 3-col grid matching adaptTopology. Counting
// existing nets PLUS any pending placeholders (live-topology
// enqueued mutations that haven't echoed through SSE yet)
// keeps successive drops from stacking on the same cell.
const w = 300, h = 240;
const GAP = 40, COLS = 3;
const i = nets.filter((n) => n.kind !== 'internet').length;
const x = GAP + (i % COLS) * (w + GAP);
const y = GAP + Math.floor(i / COLS) * (h + GAP);
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
try {
const subnet = await api.getNextSubnet().catch(() => undefined);
const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
if (lanRes.kind !== 'applied') {
// Live topology: mutator will materialise the LAN. Drop
// a placeholder net so the grid index advances and the
// user gets an immediate visual ack. Real LAN arriving
// via SSE replaces the placeholder by id when its
// canonical id lands; until then, the temp id is unique.
const tempId = `pending-lan-${name}`;
setNets((p) => [...p, {
id: tempId, name, label: name.toUpperCase(),
cidr: subnet ?? '', kind: isDmz ? 'dmz' : 'subnet',
x, y, w, h, pending: true,
}]);
return;
}
const lan = lanRes.data;
const net: Net = {
id: lan.id, name: lan.name, label: lan.name.toUpperCase(), cidr: lan.subnet,
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
};
setNets((p) => [...p, net]);
if (isDmz) {
const gwName = `dmz-gateway-${hex4()}`;
const gwRes = await editor.addDeckyToLan(
topologyId,
{ name: gwName, services: ['ssh'], x: 20, y: 40,
decky_config: { archetype: 'deaddeck', forwards_l3: true } },
lan.id, lan.name,
{ is_bridge: true, forwards_l3: true },
);
if (gwRes.kind !== 'applied') return;
const gw = gwRes.data;
const gwNode: DeckyNode = {
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
x: 20, y: 40, decky_config: { forwards_l3: true },
};
setNodes((p) => [...p, gwNode]);
}
} catch (err) {
flashErr(err, 'create network failed');
}
return;
}
if (drag.kind === 'archetype') {
if (!overNetId) return;
const net = nets.find((n) => n.id === overNetId);
if (!net) return;
const arch = archetypes.find((a) => a.slug === drag.slug);
const archSlug = drag.slug;
const dServices = drag.services ?? arch?.services ?? [];
const nx = Math.max(8, Math.round(world.x - net.x - 70));
const ny = Math.max(28, Math.round(world.y - net.y - 24));
const name = `decky-${hex4()}`;
try {
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: dServices, x: nx, y: ny,
decky_config: { archetype: archSlug } },
overNetId, net.name,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
};
setNodes((p) => [...p, node]);
} catch (err) {
flashErr(err, 'create decky failed');
}
return;
}
if (drag.kind === 'service') {
if (!overNodeId) return;
const target = nodes.find((n) => n.id === overNodeId);
if (!target || target.kind !== 'decky') return;
if (target.services.includes(drag.slug)) return;
const nextServices = [...target.services, drag.slug];
try {
const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
? { ...n, services: nextServices }
: n));
} catch (err) {
flashErr(err, 'update services failed');
}
}
},
[api, archetypes, editor, flashErr, nets, nodes, topologyId],
);
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
const onReparent = useCallback(async (nodeId: string, fromNetId: string, toNetId: string) => {
if (!topologyId) return;
try {
const { data: detail } = await axios.get(`/topologies/${topologyId}`);
const existingEdge = (detail.edges ?? []).find(
(e: { decky_uuid: string; lan_id: string; id: string }) =>
e.decky_uuid === nodeId && e.lan_id === fromNetId,
);
const node = nodes.find((n) => n.id === nodeId);
const fromNet = nets.find((n) => n.id === fromNetId);
const toNet = nets.find((n) => n.id === toNetId);
const nodeName = node?.kind === 'decky' ? node.name : '';
if (existingEdge) {
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.name ?? '');
}
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.name ?? '');
} catch (err) {
flashErr(err, 'reparent failed');
}
}, [editor, flashErr, nets, nodes, topologyId]);
/* Port→port edges:
* - Same-LAN: visual-only (no bridge to create).
* - Cross-LAN: promote the source decky to multi-home into the
* target LAN via attachEdge. The resulting viz edge carries a
* backendEdgeId so removeEdge can detach it later. Observed
* entities (attacker-pool) are read-only and never bridge. */
const onAddEdge = useCallback(async (fromId: string, toId: string) => {
const fromNode = nodes.find((n) => n.id === fromId);
const toNode = nodes.find((n) => n.id === toId);
if (!fromNode || !toNode) return;
if (fromNode.kind === 'observed' || toNode.kind === 'observed') return;
const dup = edges.some((e) =>
(e.from === fromId && e.to === toId) || (e.from === toId && e.to === fromId),
);
if (dup) return;
const sameLan = fromNode.netId === toNode.netId;
if (sameLan || !topologyId) {
const id = `viz-${fromId}-${toId}-${Date.now()}`;
setEdges((prev) => [...prev, { id, from: fromId, to: toId, traffic: 'active' as const }]);
return;
}
const targetNet = nets.find((n) => n.id === toNode.netId);
if (!targetNet) return;
const fromName = fromNode.kind === 'decky' ? fromNode.name : '';
try {
const res = await editor.attachEdge(
topologyId,
{ decky_uuid: fromId, lan_id: toNode.netId, is_bridge: true },
fromName,
targetNet.name,
);
const backendEdgeId = res.kind === 'applied' ? res.data.id : `enqueued:${res.mutationId}`;
const id = `viz-${fromId}-${toId}-${Date.now()}`;
setEdges((prev) => [
...prev,
{ id, from: fromId, to: toId, traffic: 'active' as const, backendEdgeId },
]);
pushToast({
text: `BRIDGED ${fromName.toUpperCase()}${targetNet.label.toUpperCase()}`,
tone: 'violet',
icon: 'terminal',
});
} catch (err) {
flashErr(err, 'bridge failed');
}
}, [edges, editor, flashErr, nets, nodes, pushToast, topologyId]);
const interaction = useMazeInteraction({
nets, nodes, setNets, setNodes, canvasRef,
onPaletteDrop, onReparent, onAddEdge,
});
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const removeNet = async (id: string) => {
const net = nets.find((n) => n.id === id);
if (!net || net.kind === 'internet') return;
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
try {
for (const m of members) {
const mName = m.kind === 'decky' ? m.name : '';
await editor.deleteDecky(topologyId, m.id, mName);
}
await editor.deleteLan(topologyId, id, net.name);
setNets((p) => p.filter((n) => n.id !== id));
setNodes((p) => p.filter((n) => n.netId !== id));
setEdges((p) => p.filter((e) => {
const a = nodes.find((x) => x.id === e.from)?.netId;
const b = nodes.find((x) => x.id === e.to)?.netId;
return a !== id && b !== id;
}));
setSelection(null);
} catch (err) {
flashErr(err, 'delete network failed');
}
};
const removeNode = async (id: string) => {
const node = nodes.find((n) => n.id === id);
if (!node || node.kind === 'observed') return;
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
try {
await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : '');
setNodes((p) => p.filter((n) => n.id !== id));
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
setSelection(null);
} catch (err) {
flashErr(err, 'delete decky failed');
}
};
const removeEdge = async (id: string) => {
const edge = edges.find((e) => e.id === id);
if (!edge) return;
/* Viz-only edges (same-LAN, pre-bridge era, or attach still in
* flight without a backing id) just drop from local state. */
if (!edge.backendEdgeId || !topologyId) {
setEdges((p) => p.filter((e) => e.id !== id));
setSelection(null);
return;
}
/* Cross-LAN bridge: detach the membership edge before removing
* the viz edge. Look the names up from the endpoints so the live
* mutation path has what it needs. */
const fromNode = nodes.find((n) => n.id === edge.from);
const toNode = nodes.find((n) => n.id === edge.to);
const targetNet = toNode ? nets.find((n) => n.id === toNode.netId) : undefined;
const fromName = fromNode?.kind === 'decky' ? fromNode.name : '';
const lanName = targetNet?.name ?? '';
try {
await editor.detachEdge(topologyId, edge.backendEdgeId, fromName, lanName);
setEdges((p) => p.filter((e) => e.id !== id));
setSelection(null);
} catch (err) {
flashErr(err, 'unbridge failed');
}
};
const duplicateNode = async (id: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky') return;
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
try {
const parentNet = nets.find((net) => net.id === n.netId);
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...n.services], x: n.x + 24, y: n.y + 24,
decky_config: { archetype: n.archetype } },
n.netId, parentNet?.name ?? '',
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const copy: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
archetype: n.archetype, services: [...n.services], status: 'idle',
x: n.x + 24, y: n.y + 24,
};
setNodes((p) => [...p, copy]);
} catch (err) {
flashErr(err, 'duplicate failed');
}
};
const removeServiceFromNode = async (id: string, slug: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return;
const nextServices = n.services.filter((s) => s !== slug);
try {
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
? { ...x, services: nextServices } : x));
setSelection(null);
} catch (err) {
flashErr(err, 'remove service failed');
}
};
const addServiceToNode = async (id: string, slug: string) => {
const n = nodes.find((x) => x.id === id);
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
const nextServices = [...n.services, slug];
try {
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
if (r.kind !== 'applied') return;
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
? { ...x, services: nextServices } : x));
} catch (err) {
flashErr(err, 'add service failed');
}
};
/* Force-mutate is a no-op against a pending topology (no live containers).
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
const forceMutate = (_id: string) => {
flashErr(null, 'force-mutate only applies to deployed topologies');
};
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const node = nodes.find((n) => n.id === id);
if (!node) return;
setSelection({ type: 'node', id });
const isObs = node.kind === 'observed';
const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
const locked = isObs || isGateway;
const lockedTitle = isObs
? 'observed entity — not a deployed decky'
: isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined;
const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set<string>();
const serviceSubmenu: MenuItem[] = services
.filter((s) => !usedServices.has(s.slug))
.slice(0, 16)
.map((s) => ({
label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`,
disabled: isObs,
onClick: () => addServiceToNode(id, s.slug),
}));
if (serviceSubmenu.length === 0) {
serviceSubmenu.push({ label: '(no free services)', disabled: true });
}
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add service…', icon: <Plus size={12} />, disabled: isObs,
title: isObs ? 'observed entity — services fixed' : undefined,
submenu: serviceSubmenu },
{ label: 'Force mutate', icon: <Zap size={12} />, disabled: isObs,
onClick: () => forceMutate(id) },
{ label: 'Duplicate decky', icon: <Copy size={12} />, disabled: locked,
title: lockedTitle, onClick: () => duplicateNode(id) },
{ separator: true, label: '' },
{ label: 'Delete decky', icon: <Trash2 size={12} />, danger: true,
disabled: locked, title: lockedTitle,
onClick: () => removeNode(id) },
],
});
};
const onNetContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const net = nets.find((n) => n.id === id);
if (!net) return;
setSelection({ type: 'net', id });
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
label: a.name, icon: <Server size={12} />,
onClick: async () => {
const name = `decky-${hex4()}`;
try {
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...a.services], x: 20, y: 40,
decky_config: { archetype: a.slug } },
id, net.name,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
archetype: a.slug, services: [...a.services], status: 'idle',
x: 20, y: 40,
};
setNodes((p) => [...p, node]);
} catch (err) {
flashErr(err, 'create decky failed');
}
},
}));
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add decky…', icon: <Plus size={12} />, submenu: archetypeSubmenu },
{ label: 'Inspect', icon: <Eye size={12} />, onClick: () => setSelection({ type: 'net', id }) },
{ separator: true, label: '' },
{ label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network',
icon: <Trash2 size={12} />, danger: true,
disabled: net.kind === 'internet',
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
onClick: () => removeNet(id) },
],
});
};
const onEdgeContextMenu = (id: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setSelection({ type: 'edge', id });
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Remove edge', icon: <Trash2 size={12} />, danger: true, onClick: () => removeEdge(id) },
],
});
};
const onCanvasContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ label: 'Add subnet here', icon: <GitMerge size={12} />,
onClick: () => {
const rect = canvasRef.current?.getBoundingClientRect();
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
onPaletteDrop(
{ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY },
{ x: wx, y: wy }, null, null,
);
},
},
{ label: 'Add DMZ here', icon: <ShieldAlert size={12} />,
onClick: () => {
const rect = canvasRef.current?.getBoundingClientRect();
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
onPaletteDrop(
{ kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY },
{ x: wx, y: wy }, null, null,
);
},
},
],
});
};
/* Load catalogs. */
useEffect(() => {
let cancelled = false;
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {});
return () => { cancelled = true; };
}, [api]);
/* Hydrate topology. Route guard in App.tsx ensures topologyId is set;
* if the id is bogus, surface a friendly error. */
const refetch = useCallback(async () => {
if (!topologyId) return;
try {
const h = await api.getTopology(topologyId);
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
setTopoStatus(h.topology.status);
setTopoName(h.topology.name);
setTopoVersion(h.topology.version);
setTopoMode(h.topology.mode ?? 'unihost');
setTopoTargetHost(h.topology.target_host_uuid ?? null);
setLoadErr(null);
} catch (err) {
setLoadErr((err as Error)?.message ?? 'topology load failed');
}
}, [api, topologyId]);
useEffect(() => { refetch(); }, [refetch]);
/* Live topology stream. Open only when the topology is deployed —
* pending topologies have no mutator loop and would just idle on
* keepalives. On any state-transition event we refetch; DB is the
* source of truth and the bus is at-most-once. */
const [streamLive, setStreamLive] = useState(false);
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
// Flip LIVE only on named, purposeful events — not incidental keepalives.
if (event.name === 'snapshot'
|| event.name.startsWith('mutation.')
|| event.name === 'status') {
setStreamLive(true);
setLastEventAt(new Date());
}
if (event.name === 'mutation.failed') {
const p = event.payload ?? {};
const reason = typeof p.reason === 'string' ? p.reason
: typeof p.error === 'string' ? p.error
: 'mutation failed — check mutator logs';
setActionErr(`mutation failed: ${reason}`);
setTimeout(() => setActionErr(null), 6000);
}
if (event.name === 'mutation.applied'
|| event.name === 'mutation.failed'
|| event.name === 'status') {
refetch();
}
}, [refetch]);
const onStreamError = useCallback(() => { setStreamLive(false); }, []);
useTopologyStream({
topologyId: streamEnabled ? topologyId : null,
enabled: streamEnabled,
onEvent: onStreamEvent,
onError: onStreamError,
});
useEffect(() => { if (!streamEnabled) setStreamLive(false); }, [streamEnabled]);
const onDeploy = async () => {
if (!topologyId) return;
setDeploying(true);
try {
await api.deployTopology(topologyId);
await refetch();
} catch (err) {
flashErr(err, 'deploy failed');
} finally {
setDeploying(false);
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setSelection(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const canDeploy = topoStatus === 'pending' && nets.length > 0;
const deckyNodes = nodes.filter((n) => n.kind === 'decky');
const runningDeckies = deckyNodes.filter((n) => n.status === 'active').length;
return (
<div className="maze-page">
<div className="maze-page-header">
<div>
<h1>MAZENET · {topoName || topologyId}</h1>
<div className="maze-page-sub">
NETWORK OF NETWORKS · {topoStatus.toUpperCase()} · v{topoVersion} ·{' '}
HOST:{' '}
{topoMode === 'agent' && topoTargetHost ? (
<span title={topoTargetHost}>
<Server size={11} style={{ marginRight: 3, verticalAlign: '-1px' }} />
{hostsByUuid.get(topoTargetHost)?.name ?? topoTargetHost.slice(0, 8)}
</span>
) : (
<span>MASTER</span>
)}
{' · '}
{nets.length} NETS · {nodes.length} NODES · {edges.length} PATHS ·{' '}
{runningDeckies}/{deckyNodes.length} DECKIES RUNNING
{streamEnabled && (
<span className="alert-text" style={{ color: streamLive ? undefined : 'var(--fg-dim)' }}>
{' '}· {streamLive ? 'LIVE' : 'CONNECTING…'}
</span>
)}
{loadErr && <span className="alert-text"> · {loadErr}</span>}
{actionErr && <span className="alert-text"> · {actionErr}</span>}
</div>
</div>
<div className="maze-page-actions">
<button type="button" className="maze-btn ghost" onClick={() => navigate('/mazenet')}>
<ArrowLeft size={12} /> TOPOLOGIES
</button>
<button type="button" className="maze-btn ghost" onClick={() => setPaletteOpen((o) => !o)}>
{paletteOpen ? <PanelLeftClose size={12} /> : <PanelLeftOpen size={12} />} SERVICE FLEET
</button>
<button type="button" className="maze-btn ghost" onClick={() => setInspectorOpen((o) => !o)}>
{inspectorOpen ? <PanelRightClose size={12} /> : <PanelRightOpen size={12} />} INSPECTOR
</button>
<button
type="button"
className="maze-btn ghost"
onClick={() => setFullscreen((f) => !f)}
title={fullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen canvas'}
>
{fullscreen ? <Minimize2 size={12} /> : <Maximize2 size={12} />}
{fullscreen ? ' EXIT FULL' : ' FULLSCREEN'}
</button>
<button type="button" className="maze-btn ghost" onClick={refetch} title="Revert local state to server">
<RotateCcw size={12} /> REFRESH
</button>
<button
type="button"
className="maze-btn ghost"
onClick={() => navigate(`/topologies/${topologyId}/personas`)}
disabled={!topologyId}
title="Edit email personas for this topology"
>
<Mail size={12} /> PERSONAS
</button>
<button
type="button"
className="maze-btn"
disabled={!canDeploy || deploying}
onClick={onDeploy}
title={canDeploy ? 'Deploy topology' : 'Deploy requires pending status + at least one network'}
>
<UploadCloud size={12} /> {deploying ? 'DEPLOYING…' : 'DEPLOY'}
</button>
</div>
</div>
<div
className="maze-shell"
style={{
gridTemplateColumns: `${paletteOpen ? '240px' : '0px'} 1fr ${inspectorOpen ? '320px' : '0px'}`,
}}
>
<Palette
services={services}
archetypes={archetypes}
startPaletteDrag={interaction.startPaletteDrag}
className={paletteOpen ? '' : 'collapsed'}
/>
<Canvas
ref={canvasRef}
nets={nets}
nodes={nodes}
edges={edges}
deployed={topoStatus === 'active' || topoStatus === 'degraded'}
selection={selection}
setSelection={setSelection}
pan={interaction.pan}
zoom={interaction.zoom}
dropTargetId={interaction.dropTargetId}
dragging={interaction.dragging}
edgeDraw={interaction.edgeDraw}
onCanvasMouseDown={interaction.onCanvasMouseDown}
onNodeMouseDown={interaction.onNodeMouseDown}
onNetMouseDown={interaction.onNetMouseDown}
onNetResizeMouseDown={interaction.onNetResizeMouseDown}
onPortMouseDown={interaction.onPortMouseDown}
onNodeContextMenu={onNodeContextMenu}
onNetContextMenu={onNetContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onCanvasContextMenu={onCanvasContextMenu}
onResetView={interaction.resetPan}
onAutoLayout={() => pushToast({ text: 'AUTO-LAYOUT COMING SOON', tone: 'violet', icon: 'info' })}
onZoomIn={() => interaction.zoomBy(1.2)}
onZoomOut={() => interaction.zoomBy(1 / 1.2)}
sseConnected={streamLive}
lastEventAt={lastEventAt}
onSelectService={(nodeId, slug) => setSelection({ type: 'service', id: slug, nodeId })}
panLayerRef={interaction.panLayerRef}
gridPatternRef={interaction.gridPatternRef}
/>
{ctxMenu && (
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
)}
{interaction.paletteDrag && (
<div
className="palette-ghost"
style={{ left: interaction.paletteDrag.clientX + 8, top: interaction.paletteDrag.clientY + 8 }}
>
{interaction.paletteDrag.label}
</div>
)}
<Inspector
selection={selection}
setSelection={setSelection}
nets={nets}
nodes={nodes}
edges={edges}
topologyStatus={topoStatus}
onClose={() => setInspectorOpen(false)}
onDeleteNet={removeNet}
onDeleteNode={removeNode}
onDeleteEdge={removeEdge}
onRemoveService={removeServiceFromNode}
onAddDecky={(netId) => {
const net = nets.find((n) => n.id === netId);
if (!net) return;
onPaletteDrop(
{ kind: 'archetype', slug: archetypes[0]?.slug ?? 'deaddeck',
services: archetypes[0]?.services.slice(0, 2) ?? [],
label: archetypes[0]?.name ?? 'DECKY',
clientX: 0, clientY: 0 },
{ x: net.x + 40, y: net.y + 60 }, netId, null,
);
}}
className={inspectorOpen ? '' : 'collapsed'}
/>
</div>
</div>
);
};
export default MazeNET;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { Globe, GitMerge, ShieldAlert } from '../../icons';
import type { Net } from './types';
import type { ResizeHandle } from './useMazeInteraction';
interface Props {
net: Net;
selected: boolean;
dropTarget: boolean;
inactive: boolean;
deployed?: boolean;
onSelect?: (id: string) => void;
onHeaderMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onResizeMouseDown?: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
children?: React.ReactNode;
}
const NetBox: React.FC<Props> = ({
net, selected, dropTarget, inactive, deployed, onSelect, onHeaderMouseDown, onResizeMouseDown, onContextMenu, children,
}) => {
const classes = [
'maze-net-box',
net.kind === 'internet' ? 'internet' : '',
net.kind === 'dmz' ? 'dmz' : '',
selected ? 'selected' : '',
dropTarget ? 'drop-target' : '',
inactive ? 'inactive' : '',
deployed ? 'deployed' : '',
net.pending ? 'pending' : '',
].filter(Boolean).join(' ');
const Icon = net.kind === 'internet' ? Globe : net.kind === 'dmz' ? ShieldAlert : GitMerge;
const resizable = net.kind !== 'internet';
const handleBoxDown = (e: React.MouseEvent) => {
if (e.target !== e.currentTarget) return;
onSelect?.(net.id);
};
const handleHeadDown = (e: React.MouseEvent) => {
onSelect?.(net.id);
onHeaderMouseDown?.(net.id)(e);
};
return (
<div
className={classes}
style={{ left: net.x, top: net.y, width: net.w, height: net.h }}
onMouseDown={handleBoxDown}
onContextMenu={onContextMenu}
>
<div className="maze-net-box-head" onMouseDown={handleHeadDown}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Icon size={10} />
<span>{net.label}</span>
{inactive && !net.pending && (
<span className="chip-mini"
style={{ marginLeft: 4, borderColor: 'var(--border)', color: 'rgba(255,255,255,0.45)' }}>
INACTIVE
</span>
)}
{net.pending && (
<span className="chip-mini"
style={{ marginLeft: 4,
borderColor: 'var(--warn, #e0a040)',
color: 'var(--warn, #e0a040)' }}>
PENDING
</span>
)}
</div>
<span className="cidr">{net.cidr}</span>
</div>
{resizable && onResizeMouseDown && (
<>
<div className="net-resize net-resize-e" onMouseDown={onResizeMouseDown(net.id, 'e')} />
<div className="net-resize net-resize-w" onMouseDown={onResizeMouseDown(net.id, 'w')} />
<div className="net-resize net-resize-s" onMouseDown={onResizeMouseDown(net.id, 's')} />
<div className="net-resize net-resize-n" onMouseDown={onResizeMouseDown(net.id, 'n')} />
<div className="net-resize net-resize-se" onMouseDown={onResizeMouseDown(net.id, 'se')} />
<div className="net-resize net-resize-sw" onMouseDown={onResizeMouseDown(net.id, 'sw')} />
<div className="net-resize net-resize-ne" onMouseDown={onResizeMouseDown(net.id, 'ne')} />
<div className="net-resize net-resize-nw" onMouseDown={onResizeMouseDown(net.id, 'nw')} />
</>
)}
{children}
</div>
);
};
export default React.memo(NetBox);

View File

@@ -0,0 +1,103 @@
import React from 'react';
import {
Server, Monitor, Shield, Database, Cpu, Globe, Users, HardDrive, Eye,
type LucideIcon,
} from '../../icons';
import type { MazeNode } from './types';
import { DEFAULT_SERVICES } from './data';
const ARCHETYPE_ICONS: Record<string, LucideIcon> = {
'linux-server': Server,
'windows-workstation': Monitor,
'domain-controller': Shield,
'database-server': Database,
'iot-device': Cpu,
'web-application': Globe,
'deaddeck': HardDrive,
'attacker-pool': Eye,
'directory-services': Users,
};
interface Props {
node: MazeNode;
absX: number;
absY: number;
selected: boolean;
dragging?: boolean;
deployed?: boolean;
selectedServiceSlug?: string | null;
onSelect?: (id: string) => void;
onSelectService?: (nodeId: string, slug: string) => void;
onMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onPortMouseDown?: (id: string) => (e: React.MouseEvent) => void;
onContextMenu?: (id: string) => (e: React.MouseEvent) => void;
}
const NodeCard: React.FC<Props> = ({ node, absX, absY, selected, dragging, deployed, selectedServiceSlug, onSelect, onSelectService, onMouseDown, onPortMouseDown, onContextMenu }) => {
const isDmzGateway = !!(node as { decky_config?: { forwards_l3?: boolean } }).decky_config?.forwards_l3;
const classes = [
'maze-node',
node.kind === 'observed' ? 'observed' : '',
node.status === 'hot' ? 'hot' : '',
selected ? 'selected' : '',
dragging ? 'dragging' : '',
deployed ? 'deployed' : '',
deployed && isDmzGateway ? 'dmz-gateway' : '',
].filter(Boolean).join(' ');
const handleDown = (e: React.MouseEvent) => {
onSelect?.(node.id);
onMouseDown?.(node.id)(e);
};
return (
<div
className={classes}
style={{ left: absX, top: absY }}
onMouseDown={handleDown}
onContextMenu={onContextMenu?.(node.id)}
>
<div className="mn-head">
<span className={`status-dot ${node.status}`} />
{(() => {
const Icon = ARCHETYPE_ICONS[node.archetype] ?? Server;
return <Icon size={10} className="mn-head-icon" />;
})()}
<span className="mn-head-name">{node.name}</span>
</div>
<div className="mn-sub">{node.archetype.toUpperCase()}</div>
{node.services.length > 0 && (
<div className="mn-services">
{node.services.map((s) => {
const meta = DEFAULT_SERVICES.find((x) => x.slug === s);
const isHigh = meta?.risk === 'high' || node.status === 'hot';
const isSel = selectedServiceSlug === s;
return (
<span
key={s}
className={`service-tag ${isHigh ? 'hot' : ''} ${isSel ? 'service-selected' : ''}`}
title={meta ? `${meta.name} · ${meta.proto.toUpperCase()}:${meta.port}` : s}
onMouseDown={(e) => {
if (!onSelectService) return;
e.stopPropagation();
onSelectService(node.id, s);
}}
>
{s}
</span>
);
})}
</div>
)}
{node.kind === 'decky' && <>
<span className="mn-port in" />
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
</>}
{node.kind === 'observed' && (
<span className="mn-port out" onMouseDown={onPortMouseDown?.(node.id)} />
)}
</div>
);
};
export default React.memo(NodeCard);

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { GitMerge, ShieldAlert, Server, Monitor, Shield, Database, Cpu, Globe,
Terminal, Lock, Folder, HardDrive, Users, KeyRound,
Radio, Zap, Wifi, Circle, Mail, Phone, Activity, Box } from '../../icons';
import type { ServiceDef, Archetype, ServiceGroup } from './data';
import { SERVICE_GROUP_ORDER } from './data';
import type { PaletteDrag } from './useMazeInteraction';
const ICON: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
'git-merge': GitMerge, 'shield-alert': ShieldAlert,
server: Server, monitor: Monitor, shield: Shield,
database: Database, cpu: Cpu, globe: Globe, terminal: Terminal, lock: Lock,
folder: Folder, 'hard-drive': HardDrive, users: Users, 'key-round': KeyRound,
radio: Radio, zap: Zap, wifi: Wifi, circle: Circle,
mail: Mail, phone: Phone, activity: Activity, box: Box,
};
function Icon({ name, size = 14, className }: { name: string; size?: number; className?: string }) {
const C = ICON[name] ?? Circle;
return <C size={size} className={className} />;
}
interface Props {
services: ServiceDef[];
archetypes: Archetype[];
startPaletteDrag: (d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => void;
className?: string;
}
const Palette: React.FC<Props> = ({ services, archetypes, startPaletteDrag, className = '' }) => {
const start = (d: Omit<PaletteDrag, 'clientX' | 'clientY'>) =>
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
startPaletteDrag(d, e);
};
return (
<div className={`maze-palette ${className}`}>
<div className="palette-group">
<label> NETWORKS</label>
<div className="palette-item" onMouseDown={start({ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET' })}>
<Icon name="git-merge" className="violet-accent" />
<span>Subnet</span>
<span className="chip-mini">VLAN</span>
</div>
<div className="palette-item" onMouseDown={start({ kind: 'network-dmz', slug: 'dmz', label: 'DMZ' })}>
<Icon name="shield-alert" className="alert-text" />
<span>DMZ</span>
<span className="chip-mini">HOST</span>
</div>
</div>
<div className="palette-group">
<label> ARCHETYPES</label>
{archetypes.map((a: Archetype) => (
<div
key={a.slug}
className="palette-item"
onMouseDown={start({ kind: 'archetype', slug: a.slug, label: a.name, services: a.services })}
>
<Icon name={a.icon} className="violet-accent" />
<span>{a.name}</span>
<span className="chip-mini">{a.services.length}</span>
</div>
))}
</div>
<div className="palette-group">
<label> SERVICES</label>
{(() => {
const byGroup = new Map<ServiceGroup, ServiceDef[]>();
for (const s of services) {
const g = (s.group ?? 'Miscellaneous') as ServiceGroup;
const list = byGroup.get(g) ?? [];
list.push(s);
byGroup.set(g, list);
}
const extras = [...byGroup.keys()].filter((g) => !SERVICE_GROUP_ORDER.includes(g));
const order = [...SERVICE_GROUP_ORDER, ...extras];
return order
.filter((g) => byGroup.has(g))
.map((g) => (
<div key={g} className="palette-subgroup">
<div className="palette-subgroup-label">{g.toUpperCase()}</div>
{byGroup.get(g)!.map((s) => (
<div
key={s.slug}
className="palette-item"
onMouseDown={start({ kind: 'service', slug: s.slug, label: s.name })}
>
<Icon
name={s.icon}
size={12}
className={s.risk === 'high' ? 'alert-text' : s.risk === 'med' ? 'violet-accent' : 'matrix-text'}
/>
<span>{s.name}</span>
<span className="chip-mini">{s.proto.toUpperCase()}/{s.port}</span>
</div>
))}
</div>
));
})()}
</div>
<div className="palette-group">
<label>HINT</label>
<div className="palette-hint">
Drag a network onto the canvas, or an archetype onto a network,
or a service onto a decky. Right-click for menus.
</div>
</div>
</div>
);
};
export default Palette;

View File

@@ -0,0 +1,98 @@
export interface Archetype {
slug: string;
name: string;
services: string[];
icon: string;
}
export interface ServiceDef {
slug: string;
name: string;
port: number;
proto: 'tcp' | 'udp';
icon: string;
risk: 'low' | 'med' | 'high';
group: ServiceGroup;
}
export type ServiceGroup =
| 'Remote Access'
| 'Web'
| 'File Transfer'
| 'Directory'
| 'Databases'
| 'Mail'
| 'Communications'
| 'IoT / OT'
| 'Observability'
| 'Containers'
| 'Miscellaneous';
// Rendering order for the palette.
export const SERVICE_GROUP_ORDER: ServiceGroup[] = [
'Remote Access',
'Web',
'File Transfer',
'Directory',
'Databases',
'Mail',
'Communications',
'IoT / OT',
'Observability',
'Containers',
'Miscellaneous',
];
export const ARCHETYPES: Archetype[] = [
{ slug: 'linux-server', name: 'Linux Server', services: ['ssh', 'http'], icon: 'server' },
{ slug: 'windows-workstation', name: 'Windows Workstation', services: ['smb', 'rdp'], icon: 'monitor' },
{ slug: 'domain-controller', name: 'Domain Controller', services: ['smb', 'rdp', 'ldap', 'llmnr', 'kerberos'], icon: 'shield' },
{ slug: 'database-server', name: 'Database Server', services: ['mysql', 'postgres', 'redis'], icon: 'database' },
{ slug: 'iot-device', name: 'IoT / OT Device', services: ['modbus', 'mqtt', 'coap'], icon: 'cpu' },
{ slug: 'web-application', name: 'Web Application', services: ['http', 'https'], icon: 'globe' },
];
export const DEFAULT_SERVICES: ServiceDef[] = [
// Remote Access
{ slug: 'ssh', name: 'SSH', port: 22, proto: 'tcp', icon: 'terminal', risk: 'high', group: 'Remote Access' },
{ slug: 'telnet', name: 'Telnet', port: 23, proto: 'tcp', icon: 'terminal', risk: 'high', group: 'Remote Access' },
{ slug: 'rdp', name: 'RDP', port: 3389, proto: 'tcp', icon: 'monitor', risk: 'high', group: 'Remote Access' },
{ slug: 'vnc', name: 'VNC', port: 5900, proto: 'tcp', icon: 'monitor', risk: 'high', group: 'Remote Access' },
// Web
{ slug: 'http', name: 'HTTP', port: 80, proto: 'tcp', icon: 'globe', risk: 'med', group: 'Web' },
{ slug: 'https', name: 'HTTPS', port: 443, proto: 'tcp', icon: 'lock', risk: 'med', group: 'Web' },
// File Transfer
{ slug: 'ftp', name: 'FTP', port: 21, proto: 'tcp', icon: 'folder', risk: 'high', group: 'File Transfer' },
{ slug: 'tftp', name: 'TFTP', port: 69, proto: 'udp', icon: 'folder', risk: 'high', group: 'File Transfer' },
{ slug: 'smb', name: 'SMB', port: 445, proto: 'tcp', icon: 'hard-drive', risk: 'high', group: 'File Transfer' },
// Directory
{ slug: 'ldap', name: 'LDAP', port: 389, proto: 'tcp', icon: 'users', risk: 'med', group: 'Directory' },
{ slug: 'kerberos', name: 'Kerberos', port: 88, proto: 'tcp', icon: 'key-round', risk: 'med', group: 'Directory' },
{ slug: 'llmnr', name: 'LLMNR', port: 5355, proto: 'udp', icon: 'radio', risk: 'low', group: 'Directory' },
// Databases
{ slug: 'mysql', name: 'MySQL', port: 3306, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'postgres', name: 'Postgres', port: 5432, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'mssql', name: 'MSSQL', port: 1433, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'mongodb', name: 'MongoDB', port: 27017, proto: 'tcp', icon: 'database', risk: 'high', group: 'Databases' },
{ slug: 'redis', name: 'Redis', port: 6379, proto: 'tcp', icon: 'zap', risk: 'med', group: 'Databases' },
// Mail
{ slug: 'smtp', name: 'SMTP', port: 25, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'smtp_relay', name: 'SMTP Relay', port: 587, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'imap', name: 'IMAP', port: 143, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
{ slug: 'pop3', name: 'POP3', port: 110, proto: 'tcp', icon: 'mail', risk: 'med', group: 'Mail' },
// Communications
{ slug: 'sip', name: 'SIP', port: 5060, proto: 'udp', icon: 'phone', risk: 'med', group: 'Communications' },
// IoT / OT
{ slug: 'mqtt', name: 'MQTT', port: 1883, proto: 'tcp', icon: 'wifi', risk: 'low', group: 'IoT / OT' },
{ slug: 'modbus', name: 'Modbus', port: 502, proto: 'tcp', icon: 'cpu', risk: 'med', group: 'IoT / OT' },
{ slug: 'coap', name: 'CoAP', port: 5683, proto: 'udp', icon: 'wifi', risk: 'low', group: 'IoT / OT' },
{ slug: 'conpot', name: 'Conpot (ICS)', port: 102, proto: 'tcp', icon: 'cpu', risk: 'med', group: 'IoT / OT' },
// Observability
{ slug: 'elasticsearch', name: 'Elasticsearch', port: 9200, proto: 'tcp', icon: 'activity', risk: 'med', group: 'Observability' },
{ slug: 'snmp', name: 'SNMP', port: 161, proto: 'udp', icon: 'activity', risk: 'low', group: 'Observability' },
// Containers
{ slug: 'docker_api', name: 'Docker API', port: 2375, proto: 'tcp', icon: 'box', risk: 'high', group: 'Containers' },
{ slug: 'k8s', name: 'Kubernetes', port: 6443, proto: 'tcp', icon: 'box', risk: 'high', group: 'Containers' },
{ slug: 'registry', name: 'Registry', port: 5000, proto: 'tcp', icon: 'box', risk: 'med', group: 'Containers' },
];

View File

@@ -0,0 +1,64 @@
export type NetKind = 'internet' | 'subnet' | 'dmz';
export interface Net {
id: string;
/** Display string (uppercased for the canvas chrome). */
label: string;
/** Canonical LAN name as stored on the backend — lowercase. Use
* this (not ``label``) for any API call that identifies a LAN by
* name (mutator attach/detach, delete, etc.); the mutator looks
* up case-sensitively and will 404 on the uppercased form. */
name: string;
cidr: string;
kind: NetKind;
x: number;
y: number;
w: number;
h: number;
/** Optimistic placeholder for an enqueued mutation on a live
* topology. Replaced on next refetch when the mutator emits the
* applied event. Rendered with an amber tint so the user can tell
* it's a queued add, not a regular non-deployed LAN. */
pending?: boolean;
}
export type NodeKind = 'decky' | 'observed';
interface NodeBase {
id: string;
netId: string;
name: string;
archetype: string;
services: string[];
status: 'active' | 'idle' | 'hot' | 'mutating';
x: number;
y: number;
}
export interface DeckyNode extends NodeBase {
kind: 'decky';
ip?: string;
decky_config?: Record<string, unknown>;
mutate_interval?: number | null;
}
export interface ObservedNode extends NodeBase {
kind: 'observed';
archetype: 'attacker-pool';
services: ['*'];
}
export type MazeNode = DeckyNode | ObservedNode;
export interface Edge {
id: string;
from: string;
to: string;
traffic: 'hot' | 'active' | 'idle';
label?: string;
/** Backend membership-edge id when this visual edge mirrors a
* cross-LAN bridge attachment. Same-LAN edges stay visual-only
* and leave this undefined. Set at attach, consumed at detach. */
backendEdgeId?: string;
}

View File

@@ -0,0 +1,415 @@
import { useCallback, useMemo } from 'react';
import api from '../../utils/api';
import { ARCHETYPES as DEFAULT_ARCHETYPES, DEFAULT_SERVICES } from './data';
import type { Archetype, ServiceDef } from './data';
import type { Net, MazeNode, Edge, DeckyNode } from './types';
import { applyLayout, loadLayout } from './useMazeLayoutStore';
export interface LANRow {
id: string;
topology_id: string;
name: string;
subnet: string;
is_dmz: boolean;
x?: number | null;
y?: number | null;
}
export interface DeckyRow {
uuid: string;
topology_id: string;
name: string;
services: string[];
decky_config?: Record<string, unknown> | null;
ip?: string | null;
state: string;
x?: number | null;
y?: number | null;
}
export interface EdgeRow {
id: string;
topology_id: string;
decky_uuid: string;
lan_id: string;
is_bridge: boolean;
forwards_l3: boolean;
}
export interface TopologySummary {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
}
interface TopologyDetail {
topology: TopologySummary;
lans: LANRow[];
deckies: DeckyRow[];
edges: EdgeRow[];
}
export interface HydratedTopology {
topology: TopologySummary;
nets: Net[];
nodes: MazeNode[];
edges: Edge[];
}
/** Adapt the wire shape to canvas entities. Backend edges are
* decky↔LAN membership (bipartite); we surface them as node-in-net
* placement. Decky-to-decky traffic edges are derived from
* shared-LAN co-membership for visualization only. */
export function adaptTopology(detail: TopologyDetail): HydratedTopology {
// Auto-layout: DMZ pinned top-left, subnets flow in a grid to the right.
// We ignore lan.x/lan.y from the backend because canvas position
// persistence is deferred (handled via localStorage in a later pass).
// Computing layout from the graph keeps the canvas readable no matter
// how sloppy the original drop points were.
const NET_W = 300;
const NET_H = 240;
const GAP_X = 40;
const GAP_Y = 40;
const COLS = 3;
const dmzs = detail.lans.filter((l) => l.is_dmz);
const subnets = detail.lans.filter((l) => !l.is_dmz);
const ordered = [...dmzs, ...subnets];
const nets: Net[] = ordered.map((lan, i) => ({
id: lan.id,
name: lan.name,
label: lan.name.toUpperCase(),
cidr: lan.subnet,
kind: lan.is_dmz ? 'dmz' : 'subnet',
x: GAP_X + (i % COLS) * (NET_W + GAP_X),
y: GAP_Y + Math.floor(i / COLS) * (NET_H + GAP_Y),
w: NET_W,
h: NET_H,
}));
// Home LAN = first edge; a multi-homed gateway is drawn inside its
// home LAN, membership in others is expressed via the edge list.
// Gateways (forwards_l3) MUST render inside a DMZ — edge ordering from
// the backend is not guaranteed, so we pick the DMZ edge explicitly.
const dmzIds = new Set(detail.lans.filter((l) => l.is_dmz).map((l) => l.id));
const gatewayUuids = new Set(
detail.edges.filter((e) => e.forwards_l3).map((e) => e.decky_uuid),
);
const firstLanFor = new Map<string, string>();
for (const e of detail.edges) {
if (gatewayUuids.has(e.decky_uuid)) {
// Only accept a DMZ edge as home for a gateway.
if (dmzIds.has(e.lan_id) && !firstLanFor.has(e.decky_uuid)) {
firstLanFor.set(e.decky_uuid, e.lan_id);
}
continue;
}
if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id);
}
// Layout deckies in a 2-column grid inside their home LAN so two
// members never overlap regardless of backend x/y. Same reasoning as
// the LAN grid above.
const NODE_COL_W = 140;
const NODE_ROW_H = 82;
const NODE_X0 = 12;
const NODE_Y0 = 40;
const perNetIndex = new Map<string, number>();
const nodes: MazeNode[] = detail.deckies.map((d): DeckyNode => {
const homeNetId = firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? '');
const idx = perNetIndex.get(homeNetId) ?? 0;
perNetIndex.set(homeNetId, idx + 1);
return {
kind: 'decky',
id: d.uuid,
netId: homeNetId,
name: d.name,
archetype: (d.decky_config as { archetype?: string } | null)?.archetype ?? 'linux-server',
services: d.services,
status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle',
x: NODE_X0 + (idx % 2) * NODE_COL_W,
y: NODE_Y0 + Math.floor(idx / 2) * NODE_ROW_H,
ip: d.ip ?? undefined,
decky_config: d.decky_config ?? undefined,
};
});
const byLan = new Map<string, string[]>();
for (const e of detail.edges) {
const arr = byLan.get(e.lan_id) ?? [];
arr.push(e.decky_uuid);
byLan.set(e.lan_id, arr);
}
const seen = new Set<string>();
const edges: Edge[] = [];
for (const [lanId, members] of byLan) {
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const key = `${members[i]}::${members[j]}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
id: `${lanId}-${members[i]}-${members[j]}`,
from: members[i],
to: members[j],
traffic: 'idle',
});
}
}
}
return { topology: detail.topology, nets, nodes, edges };
}
interface ArchetypeRow {
slug: string;
display_name: string;
description: string;
services: string[];
preferred_distros: string[];
nmap_os: string;
}
const NMAP_OS_TO_ICON: Record<string, string> = {
linux: 'server',
windows: 'monitor',
embedded: 'cpu',
};
export interface CreateLanBody {
name: string;
is_dmz: boolean;
x: number;
y: number;
subnet?: string;
}
export interface CreateDeckyBody {
name: string;
services: string[];
x: number;
y: number;
decky_config?: Record<string, unknown>;
}
export type MutationOp =
| 'add_lan' | 'remove_lan' | 'update_lan'
| 'add_decky' | 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky';
export interface EnqueueMutationResponse {
mutation_id: string;
state: string;
}
export interface MazeApi {
listTopologies: () => Promise<TopologySummary[]>;
createBlankTopology: (name: string) => Promise<TopologySummary>;
getTopology: (id: string) => Promise<HydratedTopology>;
getServices: () => Promise<ServiceDef[]>;
getArchetypes: () => Promise<Archetype[]>;
getNextIp: (topologyId: string, lanId: string) => Promise<string>;
getNextSubnet: (base?: string) => Promise<string>;
createLan: (topologyId: string, body: CreateLanBody) => Promise<LANRow>;
updateLan: (topologyId: string, lanId: string, patch: Partial<LANRow>) => Promise<LANRow>;
deleteLan: (topologyId: string, lanId: string) => Promise<void>;
createDecky: (topologyId: string, body: CreateDeckyBody) => Promise<DeckyRow>;
updateDecky: (topologyId: string, uuid: string, patch: Partial<DeckyRow>) => Promise<DeckyRow>;
deleteDecky: (topologyId: string, uuid: string) => Promise<void>;
attachEdge: (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }) => Promise<EdgeRow>;
detachEdge: (topologyId: string, edgeId: string) => Promise<void>;
enqueueMutation: (
topologyId: string,
op: MutationOp,
payload: Record<string, unknown>,
expectedVersion?: number,
) => Promise<EnqueueMutationResponse>;
deployTopology: (topologyId: string) => Promise<void>;
}
export function useMazeApi(): MazeApi {
const listTopologies = useCallback(async () => {
const { data } = await api.get('/topologies/');
return (data?.data ?? []) as TopologySummary[];
}, []);
const createBlankTopology = useCallback(async (name: string): Promise<TopologySummary> => {
const { data } = await api.post<TopologySummary>('/topologies/blank', { name });
return data;
}, []);
const getTopology = useCallback(async (id: string) => {
const { data } = await api.get<TopologyDetail>(`/topologies/${id}`);
const hydrated = adaptTopology(data);
const layout = loadLayout(id);
const { nets, nodes } = applyLayout(hydrated.nets, hydrated.nodes, layout);
return { ...hydrated, nets, nodes };
}, []);
const getServices = useCallback(async () => {
try {
const { data } = await api.get<{ services: string[] }>('/topologies/services');
const known = new Map(DEFAULT_SERVICES.map((s) => [s.slug, s]));
return data.services.map(
(slug) =>
known.get(slug) ?? {
slug,
name: slug.toUpperCase(),
port: 0,
proto: 'tcp' as const,
icon: 'circle',
risk: 'low' as const,
group: 'Miscellaneous' as const,
},
);
} catch {
return DEFAULT_SERVICES;
}
}, []);
const getArchetypes = useCallback(async (): Promise<Archetype[]> => {
try {
const { data } = await api.get<{ archetypes: ArchetypeRow[] }>('/topologies/archetypes');
const known = new Map(DEFAULT_ARCHETYPES.map((a) => [a.slug, a.icon]));
return data.archetypes.map((a) => ({
slug: a.slug,
name: a.display_name,
services: a.services,
icon: known.get(a.slug) ?? NMAP_OS_TO_ICON[a.nmap_os] ?? 'server',
}));
} catch {
return DEFAULT_ARCHETYPES;
}
}, []);
const getNextIp = useCallback(async (topologyId: string, lanId: string) => {
const { data } = await api.get<{ subnet: string; ip: string }>(
`/topologies/${topologyId}/lans/${lanId}/next-ip`,
);
return data.ip;
}, []);
const getNextSubnet = useCallback(async (base: string = '10.0') => {
const { data } = await api.get<{ subnet: string }>(
`/topologies/next-subnet`,
{ params: { base } },
);
return data.subnet;
}, []);
const createLan = useCallback(
async (topologyId: string, body: CreateLanBody): Promise<LANRow> => {
const { data } = await api.post<LANRow>(`/topologies/${topologyId}/lans`, body);
return data;
},
[],
);
const updateLan = useCallback(
async (topologyId: string, lanId: string, patch: Partial<LANRow>): Promise<LANRow> => {
const { data } = await api.patch<LANRow>(`/topologies/${topologyId}/lans/${lanId}`, patch);
return data;
},
[],
);
const deleteLan = useCallback(
async (topologyId: string, lanId: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/lans/${lanId}`);
},
[],
);
const createDecky = useCallback(
async (topologyId: string, body: CreateDeckyBody): Promise<DeckyRow> => {
const { data } = await api.post<DeckyRow>(`/topologies/${topologyId}/deckies`, body);
return data;
},
[],
);
const updateDecky = useCallback(
async (topologyId: string, uuid: string, patch: Partial<DeckyRow>): Promise<DeckyRow> => {
const { data } = await api.patch<DeckyRow>(
`/topologies/${topologyId}/deckies/${uuid}`,
patch,
);
return data;
},
[],
);
const deleteDecky = useCallback(
async (topologyId: string, uuid: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/deckies/${uuid}`);
},
[],
);
const attachEdge = useCallback(
async (topologyId: string, body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean }): Promise<EdgeRow> => {
const { data } = await api.post<EdgeRow>(`/topologies/${topologyId}/edges`, body);
return data;
},
[],
);
const detachEdge = useCallback(
async (topologyId: string, edgeId: string): Promise<void> => {
await api.delete(`/topologies/${topologyId}/edges/${edgeId}`);
},
[],
);
const deployTopology = useCallback(
async (topologyId: string): Promise<void> => {
await api.post(`/topologies/${topologyId}/deploy`, {});
},
[],
);
const enqueueMutation = useCallback(
async (
topologyId: string,
op: MutationOp,
payload: Record<string, unknown>,
expectedVersion?: number,
): Promise<EnqueueMutationResponse> => {
const body: { op: MutationOp; payload: Record<string, unknown>; expected_version?: number } = { op, payload };
if (expectedVersion !== undefined) body.expected_version = expectedVersion;
const { data } = await api.post<EnqueueMutationResponse>(
`/topologies/${topologyId}/mutations`,
body,
);
return data;
},
[],
);
return useMemo(
() => ({
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
getNextIp, getNextSubnet,
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation,
deployTopology,
}),
[
listTopologies, createBlankTopology, getTopology, getServices, getArchetypes,
getNextIp, getNextSubnet,
createLan, updateLan, deleteLan,
createDecky, updateDecky, deleteDecky,
attachEdge, detachEdge,
enqueueMutation,
deployTopology,
],
);
}

View File

@@ -0,0 +1,424 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Net, MazeNode } from './types';
export type ResizeHandle = 'e' | 'w' | 'n' | 's' | 'ne' | 'nw' | 'se' | 'sw';
export type PaletteDragKind = 'network-subnet' | 'network-dmz' | 'archetype' | 'service';
export interface PaletteDrag {
kind: PaletteDragKind;
slug: string;
label: string;
services?: string[];
clientX: number;
clientY: number;
}
type Drag =
| null
| { type: 'pan'; startX: number; startY: number; panX: number; panY: number }
| { type: 'node'; id: string; offX: number; offY: number }
| { type: 'net'; id: string; offX: number; offY: number }
| { type: 'resize'; id: string; handle: ResizeHandle; startX: number; startY: number; start: Net };
interface Args {
nets: Net[];
nodes: MazeNode[];
setNets: React.Dispatch<React.SetStateAction<Net[]>>;
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
canvasRef: React.RefObject<HTMLDivElement | null>;
onPaletteDrop?: (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => void;
/** Structural callbacks — only these hit the backend. */
onReparent?: (nodeId: string, fromNetId: string, toNetId: string) => void;
onAddEdge?: (fromNodeId: string, toNodeId: string) => void;
}
interface EdgeDraw {
fromId: string;
fromX: number; fromY: number;
toX: number; toY: number;
hoverTarget: string | null;
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
export function useMazeInteraction({ nets, nodes, setNets, setNodes, canvasRef, onPaletteDrop, onReparent, onAddEdge }: Args) {
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [drag, setDrag] = useState<Drag>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const [edgeDraw, setEdgeDraw] = useState<EdgeDraw | null>(null);
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
const edgeDrawRef = useRef<EdgeDraw | null>(null);
const paletteDragRef = useRef<PaletteDrag | null>(null);
useEffect(() => { edgeDrawRef.current = edgeDraw; }, [edgeDraw]);
useEffect(() => { paletteDragRef.current = paletteDrag; }, [paletteDrag]);
/* DOM refs for the pan/zoom layer and grid pattern. Pan mousemoves
* write transforms here directly via rAF, bypassing React until
* mouseup. This is what keeps a 30-LAN topology from melting the
* browser — React re-rendering hundreds of SVG paths and div cards
* on every mousemove is the dominant cost, and panning doesn't
* mutate any data, only the viewport. */
const panLayerRef = useRef<HTMLDivElement | null>(null);
const gridPatternRef = useRef<SVGPatternElement | null>(null);
const rafHandle = useRef<number | null>(null);
const writeTransform = useCallback(() => {
if (rafHandle.current !== null) return;
rafHandle.current = requestAnimationFrame(() => {
rafHandle.current = null;
const p = panRef.current;
const z = zoomRef.current;
const layer = panLayerRef.current;
if (layer) {
layer.style.transform = `translate(${p.x}px, ${p.y}px) scale(${z})`;
}
const grid = gridPatternRef.current;
if (grid) {
grid.setAttribute('x', String(p.x));
grid.setAttribute('y', String(p.y));
const size = String(40 * z);
grid.setAttribute('width', size);
grid.setAttribute('height', size);
}
});
}, []);
const startPaletteDrag = useCallback((d: Omit<PaletteDrag, 'clientX' | 'clientY'>, e: React.MouseEvent) => {
setPaletteDrag({ ...d, clientX: e.clientX, clientY: e.clientY });
}, []);
/* Refs to avoid re-binding global listeners on every state change. */
const netsRef = useRef(nets);
const nodesRef = useRef(nodes);
const panRef = useRef(pan);
const zoomRef = useRef(zoom);
const dragRef = useRef(drag);
useEffect(() => { netsRef.current = nets; }, [nets]);
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
useEffect(() => { panRef.current = pan; }, [pan]);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => { dragRef.current = drag; }, [drag]);
const canvasOriginRef = useRef(() => {
const r = canvasRef.current?.getBoundingClientRect();
return { x: r?.left ?? 0, y: r?.top ?? 0 };
});
/* World-space coords from a client event (applies pan + zoom inverse). */
const toWorld = useCallback((clientX: number, clientY: number) => {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
return { x: (clientX - o.x - p.x) / z, y: (clientY - o.y - p.y) / z };
}, []);
/* ── Mousedown dispatchers ────────────────────────────── */
const onCanvasMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Pan starts whenever a left-mousedown bubbles up to the canvas.
// Node/header/resize/port handlers call stopPropagation() and never
// reach here; net-box body mousedowns DO bubble so you can pan from
// "inside" a LAN when zoomed in and no bare grid is visible.
setDrag({ type: 'pan', startX: e.clientX, startY: e.clientY, panX: panRef.current.x, panY: panRef.current.y });
}, []);
const onNodeMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const node = nodesRef.current.find((n) => n.id === id);
if (!node) return;
const net = netsRef.current.find((nn) => nn.id === node.netId);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'node', id, offX: w.x - (net.x + node.x), offY: w.y - (net.y + node.y) });
}, [toWorld]);
const onNetMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
const w = toWorld(e.clientX, e.clientY);
setDrag({ type: 'net', id, offX: w.x - net.x, offY: w.y - net.y });
}, [toWorld]);
const onPortMouseDown = useCallback((id: string) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const node = nodesRef.current.find((n) => n.id === id);
if (!node) return;
const parent = netsRef.current.find((n) => n.id === node.netId);
if (!parent) return;
const fx = parent.x + node.x + 140;
const fy = parent.y + node.y + 22;
const w = toWorld(e.clientX, e.clientY);
setEdgeDraw({ fromId: id, fromX: fx, fromY: fy, toX: w.x, toY: w.y, hoverTarget: null });
}, [toWorld]);
const onNetResizeMouseDown = useCallback((id: string, handle: ResizeHandle) => (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
const net = netsRef.current.find((n) => n.id === id);
if (!net) return;
setDrag({ type: 'resize', id, handle, startX: e.clientX, startY: e.clientY, start: { ...net } });
}, []);
/* ── Global mousemove / mouseup ───────────────────────── */
useEffect(() => {
const onMove = (e: MouseEvent) => {
const pd = paletteDragRef.current;
if (pd) {
setPaletteDrag({ ...pd, clientX: e.clientX, clientY: e.clientY });
return;
}
const ed = edgeDrawRef.current;
if (ed) {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const hover = nodesRef.current.find((n) => {
if (n.id === ed.fromId) return false;
const parent = netsRef.current.find((nn) => nn.id === n.netId);
if (!parent) return false;
const ax = parent.x + n.x;
const ay = parent.y + n.y;
return wx >= ax - 12 && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
});
setEdgeDraw({ ...ed, toX: wx, toY: wy, hoverTarget: hover?.id ?? null });
return;
}
const d = dragRef.current;
if (!d) return;
if (d.type === 'pan') {
// Mutate panRef directly and schedule a DOM write. setPan is
// deferred to mouseup so we avoid a full React re-render per
// mousemove. Other reads of panRef (toWorld, context menu, etc.)
// see the live value immediately.
panRef.current = {
x: d.panX + (e.clientX - d.startX),
y: d.panY + (e.clientY - d.startY),
};
writeTransform();
return;
}
const w = (() => {
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
return { x: (e.clientX - o.x - p.x) / z, y: (e.clientY - o.y - p.y) / z };
})();
if (d.type === 'net') {
setNets((prev) => prev.map((n) => n.id === d.id ? { ...n, x: Math.round(w.x - d.offX), y: Math.round(w.y - d.offY) } : n));
return;
}
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
if (!node) return;
const isObserved = node.kind === 'observed';
const isPinned = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
const targetNet = !isObserved && !isPinned ? netsRef.current.find((net) => {
if (net.id === node.netId) return false;
return w.x >= net.x && w.x <= net.x + net.w && w.y >= net.y && w.y <= net.y + net.h;
}) : undefined;
setDropTargetId(targetNet?.id ?? null);
const parent = netsRef.current.find((n) => n.id === node.netId);
if (!parent) return;
const maxX = Math.max(8, parent.w - 148);
const maxY = Math.max(28, parent.h - 88);
const nx = Math.min(maxX, Math.max(8, Math.round(w.x - d.offX - parent.x)));
const ny = Math.min(maxY, Math.max(28, Math.round(w.y - d.offY - parent.y)));
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, x: nx, y: ny } : n));
return;
}
if (d.type === 'resize') {
const z = zoomRef.current;
const dx = (e.clientX - d.startX) / z;
const dy = (e.clientY - d.startY) / z;
setNets((prev) => prev.map((n) => {
if (n.id !== d.id) return n;
let { x, y, w: width, h: height } = d.start;
const MIN_W = 220, MIN_H = 140;
if (d.handle.includes('e')) width = Math.max(MIN_W, d.start.w + dx);
if (d.handle.includes('s')) height = Math.max(MIN_H, d.start.h + dy);
if (d.handle.includes('w')) {
width = Math.max(MIN_W, d.start.w - dx);
x = d.start.x + (d.start.w - width);
}
if (d.handle.includes('n')) {
height = Math.max(MIN_H, d.start.h - dy);
y = d.start.y + (d.start.h - height);
}
return { ...n, x, y, w: width, h: height };
}));
return;
}
};
const onUp = (e: MouseEvent) => {
const pd = paletteDragRef.current;
if (pd) {
setPaletteDrag(null);
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const wx = (e.clientX - o.x - p.x) / z;
const wy = (e.clientY - o.y - p.y) / z;
const rect = canvasRef.current?.getBoundingClientRect();
const inside = rect
? e.clientX >= rect.left && e.clientX <= rect.right
&& e.clientY >= rect.top && e.clientY <= rect.bottom
: false;
if (!inside) return;
const overNet = netsRef.current.find(
(n) => wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h,
);
const overNode = nodesRef.current.find((n) => {
const parent = netsRef.current.find((nn) => nn.id === n.netId);
if (!parent) return false;
const ax = parent.x + n.x;
const ay = parent.y + n.y;
return wx >= ax && wx <= ax + 140 && wy >= ay && wy <= ay + 80;
});
onPaletteDrop?.(pd, { x: wx, y: wy }, overNet?.id ?? null, overNode?.id ?? null);
return;
}
const ed = edgeDrawRef.current;
if (ed) {
if (ed.hoverTarget && ed.hoverTarget !== ed.fromId) {
const target = nodesRef.current.find((n) => n.id === ed.hoverTarget);
if (target && target.kind !== 'observed') {
onAddEdge?.(ed.fromId, ed.hoverTarget);
}
}
setEdgeDraw(null);
return;
}
const d = dragRef.current;
if (!d) return;
if (d.type === 'node') {
const node = nodesRef.current.find((n) => n.id === d.id);
const target = dropTargetId;
if (node && node.kind === 'decky' && target && target !== node.netId) {
const parentOld = netsRef.current.find((nn) => nn.id === node.netId);
const parentNew = netsRef.current.find((nn) => nn.id === target);
if (parentOld && parentNew) {
const absX = parentOld.x + node.x;
const absY = parentOld.y + node.y;
const relX = Math.max(8, absX - parentNew.x);
const relY = Math.max(28, absY - parentNew.y);
const fromNetId = node.netId;
setNodes((prev) => prev.map((n) => n.id === d.id ? { ...n, netId: target, x: relX, y: relY } : n));
onReparent?.(d.id, fromNetId, target);
}
}
/* Intra-net moves and net/resize drags are cosmetic — never persisted. */
}
if (d.type === 'pan') {
// Commit the drag-accumulated pan (written only to panRef during
// the drag) back to React state so anything reading via props
// (status bar, auto-layout, persistence) sees the final value.
setPan(panRef.current);
}
setDropTargetId(null);
setDrag(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [setNets, setNodes, dropTargetId, onPaletteDrop, onReparent, onAddEdge, canvasRef]);
const resetPan = useCallback(() => {
panRef.current = { x: 0, y: 0 };
zoomRef.current = 1;
setPan({ x: 0, y: 0 });
setZoom(1);
writeTransform();
}, [writeTransform]);
/* Wheel zoom anchored at cursor — attached as a native non-passive
* listener so preventDefault() actually stops the page from scrolling
* while zooming the canvas. */
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const o = canvasOriginRef.current();
const p = panRef.current;
const z = zoomRef.current;
const factor = Math.exp(-e.deltaY * 0.0015);
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * factor));
if (nz === z) return;
const mx = e.clientX - o.x;
const my = e.clientY - o.y;
const wx = (mx - p.x) / z;
const wy = (my - p.y) / z;
const np = { x: mx - wx * nz, y: my - wy * nz };
panRef.current = np;
zoomRef.current = nz;
setZoom(nz);
setPan(np);
writeTransform();
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [canvasRef, writeTransform]);
const zoomBy = useCallback((mult: number) => {
const z = zoomRef.current;
const nz = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z * mult));
if (nz === z) return;
const rect = canvasRef.current?.getBoundingClientRect();
const cx = (rect?.width ?? 0) / 2;
const cy = (rect?.height ?? 0) / 2;
const p = panRef.current;
const wx = (cx - p.x) / z;
const wy = (cy - p.y) / z;
const np = { x: cx - wx * nz, y: cy - wy * nz };
panRef.current = np;
zoomRef.current = nz;
setZoom(nz);
setPan(np);
writeTransform();
}, [canvasRef, writeTransform]);
return {
pan,
zoom,
dropTargetId,
dragging: drag !== null,
edgeDraw,
paletteDrag,
startPaletteDrag,
onCanvasMouseDown,
onNodeMouseDown,
onNetMouseDown,
onNetResizeMouseDown,
onPortMouseDown,
resetPan,
zoomBy,
panLayerRef,
gridPatternRef,
};
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef } from 'react';
import type { Net, MazeNode } from './types';
/** Per-topology canvas layout persisted to localStorage. Keyed by
* topology id so two topologies don't share positions. Stored keys
* for missing LAN/decky ids are pruned on save (self-heal). */
interface NetLayout { x: number; y: number; w: number; h: number }
interface NodeLayout { x: number; y: number }
export interface LayoutSnapshot {
nets: Record<string, NetLayout>;
nodes: Record<string, NodeLayout>;
}
const EMPTY: LayoutSnapshot = { nets: {}, nodes: {} };
const SAVE_DEBOUNCE_MS = 300;
function storageKey(topologyId: string): string {
return `mazenet.layout.${topologyId}`;
}
export function loadLayout(topologyId: string | null): LayoutSnapshot {
if (!topologyId) return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(topologyId));
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LayoutSnapshot>;
return {
nets: parsed.nets ?? {},
nodes: parsed.nodes ?? {},
};
} catch {
return EMPTY;
}
}
function saveLayout(topologyId: string, snap: LayoutSnapshot): void {
try {
window.localStorage.setItem(storageKey(topologyId), JSON.stringify(snap));
} catch {
/* quota exhausted or private mode — layout reverts to grid. */
}
}
/** Apply stored positions on top of grid-laid-out entities. Entities
* without a stored entry keep their grid position. */
export function applyLayout(
nets: Net[],
nodes: MazeNode[],
layout: LayoutSnapshot,
): { nets: Net[]; nodes: MazeNode[] } {
const adjustedNets = nets.map((n) => {
const saved = layout.nets[n.id];
return saved ? { ...n, x: saved.x, y: saved.y, w: saved.w, h: saved.h } : n;
});
const adjustedNodes = nodes.map((n) => {
const saved = layout.nodes[n.id];
return saved ? { ...n, x: saved.x, y: saved.y } : n;
});
return { nets: adjustedNets, nodes: adjustedNodes };
}
/** Debounced writer — every nets/nodes change is captured and flushed
* to localStorage after a short idle window. Also prunes entries for
* LANs / deckies that no longer exist in the current topology. */
export function useLayoutPersistor(
topologyId: string | null,
nets: Net[],
nodes: MazeNode[],
): void {
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!topologyId) return;
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
const snap: LayoutSnapshot = { nets: {}, nodes: {} };
for (const n of nets) {
if (n.kind === 'internet') continue;
snap.nets[n.id] = { x: n.x, y: n.y, w: n.w, h: n.h };
}
for (const n of nodes) {
snap.nodes[n.id] = { x: n.x, y: n.y };
}
saveLayout(topologyId, snap);
timerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [topologyId, nets, nodes]);
}
/** Clear the stored layout for a topology — call after delete so stale
* entries don't linger forever. */
export function clearLayout(topologyId: string): void {
try {
window.localStorage.removeItem(storageKey(topologyId));
} catch {
/* ignore */
}
}
/** Hook form for consumers that prefer a stable callback. */
export function useClearLayout(): (topologyId: string) => void {
return useCallback((id: string) => clearLayout(id), []);
}

View File

@@ -0,0 +1,220 @@
/**
* Status-aware topology editor — wraps {@link useMazeApi} so the MazeNET
* editor can call one set of primitives regardless of whether the
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
* queue via :func:`enqueueMutation`).
*
* Primitives return a tagged {@link PrimitiveResult}:
* ``{ kind: 'applied', data }`` — backend wrote synchronously; the
* caller may update local state.
* ``{ kind: 'enqueued', mutationId }`` — mutator will apply async;
* caller must NOT touch local state,
* SSE ``mutation.applied`` drives refetch.
*
* Name arguments (``deckyName``, ``lanName``) are required on every
* primitive because mutation ops are name-keyed while direct CRUD is
* uuid-keyed. Callers plumb both.
*/
import { useMemo } from 'react';
import type {
CreateDeckyBody,
CreateLanBody,
DeckyRow,
EdgeRow,
LANRow,
MazeApi,
} from './useMazeApi';
export interface UseTopologyEditorOptions {
api: MazeApi;
/** Current topology status from :func:`getTopology`. */
topoStatus: string;
/** Last-known topology version for optimistic concurrency. */
topoVersion: number;
}
export type PrimitiveResult<T> =
| { kind: 'applied'; data: T }
| { kind: 'enqueued'; mutationId: string };
export interface UseTopologyEditor {
createLan(topologyId: string, body: CreateLanBody): Promise<PrimitiveResult<LANRow>>;
updateLan(
topologyId: string,
lanId: string,
lanName: string,
patch: Partial<LANRow>,
): Promise<PrimitiveResult<LANRow>>;
deleteLan(
topologyId: string,
lanId: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
/** Composite: create a decky and attach it to its home LAN. On pending
* this is two CRUD calls; on active it's one ``add_decky`` enqueue.
* Callers should prefer this over ``createDecky`` + ``attachEdge`` so
* the active path doesn't 409 on the CRUD half. */
addDeckyToLan(
topologyId: string,
body: CreateDeckyBody,
lanId: string,
lanName: string,
opts?: { is_bridge?: boolean; forwards_l3?: boolean },
): Promise<PrimitiveResult<DeckyRow>>;
updateDecky(
topologyId: string,
uuid: string,
deckyName: string,
patch: Partial<DeckyRow>,
): Promise<PrimitiveResult<DeckyRow>>;
deleteDecky(
topologyId: string,
uuid: string,
deckyName: string,
): Promise<PrimitiveResult<void>>;
attachEdge(
topologyId: string,
body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean },
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<EdgeRow>>;
detachEdge(
topologyId: string,
edgeId: string,
deckyName: string,
lanName: string,
): Promise<PrimitiveResult<void>>;
}
export function useTopologyEditor(
opts: UseTopologyEditorOptions,
): UseTopologyEditor {
const { api, topoStatus, topoVersion } = opts;
const live = topoStatus === 'active' || topoStatus === 'degraded';
return useMemo<UseTopologyEditor>(() => ({
// ── LAN ────────────────────────────────────────────────────────────
async createLan(topologyId, body) {
if (!live) {
const data = await api.createLan(topologyId, body);
return { kind: 'applied', data };
}
// add_lan payload: {name, subnet?, is_dmz?, x?, y?}
const payload: Record<string, unknown> = { name: body.name };
if (body.subnet !== undefined) payload.subnet = body.subnet;
if (body.is_dmz !== undefined) payload.is_dmz = body.is_dmz;
if (body.x !== undefined) payload.x = body.x;
if (body.y !== undefined) payload.y = body.y;
const res = await api.enqueueMutation(topologyId, 'add_lan', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateLan(topologyId, lanId, lanName, patch) {
if (!live) {
const data = await api.updateLan(topologyId, lanId, patch);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { name: lanName };
const patchFields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'x' || k === 'y') payload[k] = v;
else patchFields[k] = v;
}
if (Object.keys(patchFields).length > 0) payload.patch = patchFields;
const res = await api.enqueueMutation(topologyId, 'update_lan', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async deleteLan(topologyId, lanId, lanName) {
if (!live) {
await api.deleteLan(topologyId, lanId);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'remove_lan', { name: lanName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
// ── Decky ──────────────────────────────────────────────────────────
async createDecky(topologyId, body) {
// Bare create — only valid on pending. On active callers should use
// addDeckyToLan() instead; the backend guard will 409 here.
const data = await api.createDecky(topologyId, body);
return { kind: 'applied', data };
},
async addDeckyToLan(topologyId, body, lanId, lanName, opts) {
if (!live) {
const data = await api.createDecky(topologyId, body);
await api.attachEdge(topologyId, {
decky_uuid: data.uuid,
lan_id: lanId,
is_bridge: opts?.is_bridge,
forwards_l3: opts?.forwards_l3,
});
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = {
name: body.name,
lan: lanName,
services: body.services,
};
const cfg = body.decky_config ?? {};
if (cfg.archetype !== undefined) payload.archetype = cfg.archetype;
const fwd = opts?.forwards_l3 ?? cfg.forwards_l3;
if (fwd !== undefined) payload.forwards_l3 = fwd;
if (body.x !== undefined) payload.x = body.x;
if (body.y !== undefined) payload.y = body.y;
const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async updateDecky(topologyId, uuid, deckyName, patch) {
if (!live) {
const data = await api.updateDecky(topologyId, uuid, patch);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { decky: deckyName };
const patchFields: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) {
if (k === 'services' || k === 'x' || k === 'y') payload[k] = v;
else patchFields[k] = v;
}
if (Object.keys(patchFields).length > 0) payload.patch = patchFields;
const res = await api.enqueueMutation(topologyId, 'update_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async deleteDecky(topologyId, uuid, deckyName) {
if (!live) {
await api.deleteDecky(topologyId, uuid);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'remove_decky', { decky: deckyName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
// ── Edges ──────────────────────────────────────────────────────────
async attachEdge(topologyId, body, deckyName, lanName) {
if (!live) {
const data = await api.attachEdge(topologyId, body);
return { kind: 'applied', data };
}
const payload: Record<string, unknown> = { decky: deckyName, lan: lanName };
if (body.forwards_l3 !== undefined) payload.forwards_l3 = body.forwards_l3;
const res = await api.enqueueMutation(topologyId, 'attach_decky', payload, topoVersion);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
async detachEdge(topologyId, edgeId, deckyName, lanName) {
if (!live) {
await api.detachEdge(topologyId, edgeId);
return { kind: 'applied', data: undefined };
}
const res = await api.enqueueMutation(
topologyId, 'detach_decky', { decky: deckyName, lan: lanName }, topoVersion,
);
return { kind: 'enqueued', mutationId: res.mutation_id };
},
}), [api, live, topoVersion]);
}

View File

@@ -0,0 +1,107 @@
/**
* Topology event stream — opens an SSE connection to
* `/topologies/{id}/events` and dispatches typed events to the caller.
*
* Mirrors the reconnect shape used by the dashboard's `/stream` consumer:
* on any error we close the current EventSource and retry after 3s. The
* hook is inert until `topologyId` is non-empty and `enabled` is true —
* typical usage is to gate on `topoStatus === 'active' || 'degraded'` so
* pending topologies don't open a useless channel.
*/
import { useEffect, useRef } from 'react';
export type TopologyStreamEventName =
| 'snapshot'
| 'mutation.enqueued'
| 'mutation.applying'
| 'mutation.applied'
| 'mutation.failed'
| 'status';
export interface TopologyStreamEvent {
name: TopologyStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseTopologyStreamOptions {
topologyId: string | null;
enabled: boolean;
onEvent: (event: TopologyStreamEvent) => void;
onError?: () => void;
}
const NAMED_EVENTS: TopologyStreamEventName[] = [
'snapshot',
'mutation.enqueued',
'mutation.applying',
'mutation.applied',
'mutation.failed',
'status',
];
export function useTopologyStream({
topologyId,
enabled,
onEvent,
onError,
}: UseTopologyStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep the latest callbacks in refs so reconnect logic doesn't tear
// down and rebuild the connection every time the consumer rerenders.
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
if (!enabled || !topologyId) return;
const connect = () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/topologies/${topologyId}/events?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<TopologyStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useTopologyStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [topologyId, enabled]);
}

View File

@@ -0,0 +1,24 @@
/* Drawer-right variant: reuses .modal-backdrop + .modal base from DeckyFleet.css
and shifts the panel to the right edge as a full-height side panel. */
.modal-backdrop.drawer {
justify-content: flex-end;
align-items: stretch;
}
.modal.modal-drawer-right {
width: 520px;
max-width: 96vw;
max-height: 100vh;
height: 100vh;
border-left: 1px solid var(--matrix);
border-top: none;
border-right: none;
border-bottom: none;
box-shadow: -8px 0 30px rgba(0, 0, 0, 0.6);
}
.modal.modal-drawer-right.violet {
border-left-color: var(--violet);
box-shadow: -8px 0 30px rgba(238, 130, 238, 0.25);
}

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import { X, type LucideIcon } from '../../icons';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import './Modal.css';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
icon?: LucideIcon;
footer?: React.ReactNode;
accent?: 'matrix' | 'violet';
width?: 'default' | 'wide';
variant?: 'center' | 'drawer-right';
children: React.ReactNode;
className?: string;
}
const Modal: React.FC<Props> = ({
open,
onClose,
title,
icon: Icon,
footer,
accent = 'matrix',
width = 'default',
variant = 'center',
children,
className = '',
}) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, open);
useFocusTrap(panelRef, open);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [open]);
if (!open) return null;
const panelClasses = [
'modal',
accent === 'violet' ? 'violet' : '',
width === 'wide' ? 'wide' : '',
variant === 'drawer-right' ? 'modal-drawer-right' : '',
className,
].filter(Boolean).join(' ');
const backdropClass = variant === 'drawer-right' ? 'modal-backdrop drawer' : 'modal-backdrop';
return (
<div className={backdropClass} onClick={onClose}>
<div
ref={panelRef}
className={panelClasses}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{title && (
<div className="modal-head">
<h3>
{Icon && <Icon size={14} />}
{title}
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
)}
{children}
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
};
export default Modal;

View File

@@ -0,0 +1,432 @@
/* Orchestrator — synthetic life-injection activity feed.
* Scoped under .orchestrator-root, mirrors the Bounty/DeckyFleet pattern. */
.orchestrator-root { display: flex; flex-direction: column; gap: 20px; }
/* Header */
.orchestrator-root .page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
gap: 24px;
}
.orchestrator-root .page-title-group { display: flex; flex-direction: column; gap: 6px; }
.orchestrator-root .page-header h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
margin: 0;
color: var(--matrix);
}
.orchestrator-root .page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; }
.orchestrator-root .dim { opacity: 0.5; }
.orchestrator-root .violet-accent { color: var(--violet); }
.orchestrator-root .matrix-text { color: var(--matrix); }
.orchestrator-root .alert-text { color: var(--alert); }
/* Header pills */
.orchestrator-root .header-line {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.orchestrator-root .status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
font-size: 0.65rem;
letter-spacing: 1.5px;
border: 1px solid var(--border);
background: var(--panel);
text-transform: uppercase;
}
.orchestrator-root .status-pill.live {
border-color: var(--matrix);
color: var(--matrix);
box-shadow: var(--matrix-glow);
}
.orchestrator-root .status-pill.live .dot {
background: var(--matrix);
box-shadow: 0 0 8px var(--matrix);
animation: orch-pulse 1.4s infinite alternate;
}
.orchestrator-root .status-pill.connecting { color: rgba(0, 255, 65, 0.55); }
.orchestrator-root .status-pill.connecting .dot {
background: rgba(0, 255, 65, 0.55);
animation: orch-blink 1s infinite;
}
.orchestrator-root .status-pill.error {
border-color: var(--alert);
color: var(--alert);
}
.orchestrator-root .status-pill.error .dot { background: var(--alert); }
.orchestrator-root .status-pill .dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
}
.orchestrator-root .failure-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
font-size: 0.65rem;
letter-spacing: 1.5px;
border: 1px solid var(--alert);
color: var(--alert);
background: rgba(255, 65, 65, 0.06);
text-transform: uppercase;
}
/* Controls row */
.orchestrator-root .controls-row {
display: flex;
gap: 12px;
align-items: center;
}
/* Segmented kind filter — mirrors DeckyFleet's fleet-filter-group */
.orchestrator-root .seg-group {
display: flex;
border: 1px solid var(--border);
background: var(--panel);
}
.orchestrator-root .seg-group button {
padding: 8px 16px;
font-size: 0.68rem;
letter-spacing: 1.5px;
border: 0;
border-right: 1px solid var(--border);
background: transparent;
color: rgba(0, 255, 65, 0.6);
font-family: inherit;
cursor: pointer;
text-transform: uppercase;
}
.orchestrator-root .seg-group button:last-child { border-right: none; }
.orchestrator-root .seg-group button.active {
background: var(--violet-tint-10);
color: var(--violet);
}
.orchestrator-root .seg-group button:hover:not(.active) { color: var(--matrix); }
/* Pause toggle — neutral .btn flavour */
.orchestrator-root .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.72rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
cursor: pointer;
transition: all 0.2s ease;
}
.orchestrator-root .btn:hover { border-color: var(--matrix); box-shadow: var(--matrix-glow); }
.orchestrator-root .btn.paused { border-color: var(--violet); color: var(--violet); }
.orchestrator-root .btn.paused:hover { box-shadow: var(--violet-glow); }
/* Section + table */
.orchestrator-root .logs-section {
border: 1px solid var(--border);
background: var(--panel);
}
.orchestrator-root .section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
.orchestrator-root .section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
letter-spacing: 1.5px;
opacity: 0.7;
}
/* Pagination */
.orchestrator-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; }
.orchestrator-root .pager button {
padding: 4px;
border: 1px solid var(--border);
background: transparent;
color: var(--matrix);
display: flex;
cursor: pointer;
}
.orchestrator-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; }
.orchestrator-root .pager button:hover:not(:disabled) { border-color: var(--accent); }
/* Table */
.orchestrator-root .logs-table-container { overflow-x: auto; }
.orchestrator-root .logs-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.orchestrator-root .logs-table th {
text-align: left;
padding: 9px 14px;
font-size: 0.62rem;
letter-spacing: 1.5px;
opacity: 0.55;
border-bottom: 1px solid var(--border);
}
.orchestrator-root .logs-table td {
padding: 9px 14px;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
vertical-align: middle;
}
.orchestrator-root .logs-table tr:hover td { background: rgba(0, 255, 65, 0.025); }
.orchestrator-root .logs-table tr.fail td {
background: rgba(255, 65, 65, 0.05);
}
.orchestrator-root .logs-table tr.fail:hover td { background: rgba(255, 65, 65, 0.08); }
/* Live-prepended row tint — fades back to neutral after a moment via opacity. */
.orchestrator-root .logs-table tr.fresh td {
background: rgba(238, 130, 238, 0.05);
}
/* Kind chip */
.orchestrator-root .kind-chip {
display: inline-block;
padding: 2px 8px;
font-size: 0.62rem;
letter-spacing: 1.5px;
border: 1px solid var(--border);
text-transform: uppercase;
}
.orchestrator-root .kind-chip.traffic { border-color: var(--matrix); color: var(--matrix); }
.orchestrator-root .kind-chip.file { border-color: var(--violet); color: var(--violet); }
/* Emailgen rows — distinct accent so the eye separates LLM-driven mail
from SSH/file activity at a glance. Falls back to --accent when the
theme doesn't define --amber. */
.orchestrator-root .kind-chip.email { border-color: var(--amber, var(--accent)); color: var(--amber, var(--accent)); }
/* OK indicator */
.orchestrator-root .ok-yes { color: var(--matrix); font-weight: 700; }
.orchestrator-root .ok-no { color: var(--alert); font-weight: 700; }
/* Mono cells */
.orchestrator-root .mono {
font-family: var(--font-mono);
font-size: 0.74rem;
}
.orchestrator-root .src-dst { font-family: var(--font-mono); font-size: 0.72rem; opacity: 0.7; }
.orchestrator-root .arrow { opacity: 0.4; padding: 0 4px; }
.orchestrator-root .payload-cell {
font-family: var(--font-mono);
font-size: 0.72rem;
opacity: 0.6;
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty state row */
.orchestrator-root .empty-row td { padding: 0; }
/* Animations */
@keyframes orch-pulse { from { opacity: 0.5; } to { opacity: 1; } }
@keyframes orch-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* ── Row interactivity ─────────────────────────────────── */
.orchestrator-root .logs-table tr.clickable { cursor: pointer; }
.orchestrator-root .logs-table tr.clickable:hover {
background: rgba(238, 130, 238, 0.04);
}
/* ── Inspector drawer ──────────────────────────────────── */
.orchestrator-root .orchestrator-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: flex-end;
z-index: 1000;
animation: od-fade 0.15s ease;
}
@keyframes od-fade { from { opacity: 0; } to { opacity: 1; } }
.orchestrator-root .orchestrator-drawer {
width: min(620px, 100%);
height: 100%;
background: var(--bg);
border-left: 1px solid var(--violet);
box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1);
overflow-y: auto;
display: flex;
flex-direction: column;
animation: od-slide 0.2s ease;
}
@keyframes od-slide {
from { transform: translateX(30px); opacity: 0.6; }
to { transform: none; opacity: 1; }
}
.orchestrator-root .orchestrator-drawer .bd-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.orchestrator-root .orchestrator-drawer .bd-head h3 {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
letter-spacing: 3px;
color: var(--violet);
margin: 0;
}
.orchestrator-root .orchestrator-drawer .close-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
display: flex;
padding: 4px;
cursor: pointer;
}
.orchestrator-root .orchestrator-drawer .close-btn:hover { border-color: var(--violet); }
.orchestrator-root .orchestrator-drawer .bd-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.orchestrator-root .orchestrator-drawer .kvs {
display: grid;
grid-template-columns: 130px 1fr;
gap: 10px 12px;
font-size: 0.8rem;
align-items: center;
}
.orchestrator-root .orchestrator-drawer .kvs .k {
opacity: 0.55;
font-size: 0.7rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
}
.orchestrator-root .orchestrator-drawer .kvs .v { word-break: break-all; }
.orchestrator-root .orchestrator-drawer .kvs .v.mono {
font-family: var(--font-mono);
font-size: 0.78rem;
}
/* Source-tag chips disambiguate the opaque dst id: bare UUIDs come from
topology_deckies, "host_uuid:name" composites come from the fleet
(host_uuid="local") or SWARM shards. */
.orchestrator-root .orchestrator-drawer .src-dst-cell {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.orchestrator-root .orchestrator-drawer .src-dst-cell .hash-text {
font-family: var(--font-mono);
font-size: 0.74rem;
color: var(--matrix);
}
.orchestrator-root .orchestrator-drawer .chip.src-topology {
border-color: var(--matrix);
color: var(--matrix);
}
.orchestrator-root .orchestrator-drawer .chip.src-fleet {
border-color: var(--violet);
color: var(--violet);
}
.orchestrator-root .orchestrator-drawer .chip.src-shard {
border-color: #ffaa00;
color: #ffaa00;
}
.orchestrator-root .orchestrator-drawer .type-label {
font-size: 0.68rem;
letter-spacing: 2px;
opacity: 0.6;
margin-bottom: 8px;
}
.orchestrator-root .orchestrator-drawer .code-block {
background: var(--panel);
border: 1px solid var(--border);
border-left: 2px solid var(--violet);
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--matrix);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.orchestrator-root .orchestrator-drawer .hash-row {
display: flex;
align-items: center;
gap: 8px;
}
.orchestrator-root .orchestrator-drawer .hash-row .hash-text {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--matrix);
word-break: break-all;
flex: 1;
}
.orchestrator-root .orchestrator-drawer .icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
padding: 4px 6px;
display: inline-flex;
cursor: pointer;
}
.orchestrator-root .orchestrator-drawer .icon-btn:hover { border-color: var(--violet); }
.orchestrator-root .orchestrator-drawer .bd-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.orchestrator-root .orchestrator-drawer .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
background: transparent;
border: 1px solid var(--border);
color: var(--matrix);
cursor: pointer;
transition: all 0.3s ease;
opacity: 0.8;
}
.orchestrator-root .orchestrator-drawer .btn.ghost:hover {
opacity: 1;
border-color: var(--matrix);
box-shadow: var(--matrix-glow);
}

View File

@@ -0,0 +1,350 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
ChevronLeft, ChevronRight, Filter, Cpu, AlertTriangle, Pause, Play,
} from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import OrchestratorInspector from './OrchestratorInspector';
import { useOrchestratorStream, type OrchestratorStreamEvent } from './useOrchestratorStream';
import './Orchestrator.css';
interface OrchestratorEntry {
uuid: string;
ts: string;
kind: 'traffic' | 'file' | 'email' | string;
protocol: string;
action: string;
src_decky_uuid: string | null;
dst_decky_uuid: string;
success: boolean;
payload: string;
// Email-only extras — populated when `kind === 'email'`, undefined
// for traffic/file rows. The renderer keys off `kind` to decide
// whether to read these.
subject?: string;
sender_email?: string;
recipient_email?: string;
language?: string;
thread_id?: string;
mail_decky_uuid?: string;
message_id?: string;
in_reply_to?: string | null;
}
type KindFilter = 'all' | 'traffic' | 'file' | 'email';
type StreamStatus = 'connecting' | 'live' | 'error';
const ROW_CAP = 500;
const HOUR_MS = 60 * 60 * 1000;
const FRESH_MS = 5_000;
const timeAgo = (dateStr: string | null): string => {
if (!dateStr) return '—';
const diff = Date.now() - new Date(dateStr).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
};
const Orchestrator: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '1');
const kindParam = (searchParams.get('kind') || 'all') as KindFilter;
const [rows, setRows] = useState<OrchestratorEntry[]>([]);
const [streamRows, setStreamRows] = useState<OrchestratorEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<StreamStatus>('connecting');
const [paused, setPaused] = useState(false);
const [now, setNow] = useState(Date.now());
const [selected, setSelected] = useState<OrchestratorEntry | null>(null);
const limit = 50;
const pausedRef = useRef(paused);
useEffect(() => { pausedRef.current = paused; }, [paused]);
// Tick to refresh the "Xs ago" labels and fade the fresh-row tint.
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 5_000);
return () => clearInterval(t);
}, []);
const fetchEvents = async () => {
setLoading(true);
try {
const offset = (page - 1) * limit;
const kindQ = kindParam !== 'all' ? `&kind=${kindParam}` : '';
const res = await api.get(
`/orchestrator/events?limit=${limit}&offset=${offset}${kindQ}`,
);
setRows(res.data.data ?? []);
setTotal(res.data.total ?? 0);
} catch (err) {
console.error('Failed to fetch orchestrator events', err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchEvents(); }, [page, kindParam]);
useOrchestratorStream({
enabled: true,
onStatus: setStatus,
onEvent: (ev: OrchestratorStreamEvent) => {
if (pausedRef.current) return;
if (ev.name !== 'traffic' && ev.name !== 'file' && ev.name !== 'email') return;
const p = ev.payload as Partial<OrchestratorEntry> & {
// Live email payloads come from worker._one_tick — see emailgen
// worker.py for the bus payload shape.
sender_email?: string;
recipient_email?: string;
subject?: string;
language?: string;
thread_id?: string;
mail_decky_uuid?: string;
message_id?: string;
in_reply_to?: string | null;
};
const isEmail = ev.name === 'email' || p.kind === 'email';
const row: OrchestratorEntry = isEmail
? {
uuid: `live-${ev.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: ev.ts ?? new Date().toISOString(),
kind: 'email',
protocol: 'smtp',
action: p.subject ?? '',
// Map sender/recipient onto src/dst so the existing inspector
// shows them naturally — the API does the same on REST reads.
src_decky_uuid: p.sender_email ?? null,
dst_decky_uuid: p.recipient_email ?? '',
success: Boolean(p.success),
payload: typeof p.payload === 'string' ? p.payload : JSON.stringify(p.payload ?? {}),
subject: p.subject,
sender_email: p.sender_email,
recipient_email: p.recipient_email,
language: p.language,
thread_id: p.thread_id,
mail_decky_uuid: p.mail_decky_uuid,
message_id: p.message_id,
in_reply_to: p.in_reply_to ?? null,
}
: {
uuid: `live-${ev.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
ts: ev.ts ?? new Date().toISOString(),
kind: (p.kind ?? ev.name) as OrchestratorEntry['kind'],
protocol: p.protocol ?? '?',
action: p.action ?? '',
src_decky_uuid: p.src_decky_uuid ?? null,
dst_decky_uuid: p.dst_decky_uuid ?? '',
success: Boolean(p.success),
payload: typeof p.payload === 'string' ? p.payload : JSON.stringify(p.payload ?? {}),
};
setStreamRows((prev) => [row, ...prev].slice(0, ROW_CAP));
},
});
const setPage = (p: number) =>
setSearchParams({ kind: kindParam, page: p.toString() });
const setKind = (k: KindFilter) =>
setSearchParams({ kind: k, page: '1' });
const totalPages = Math.max(1, Math.ceil(total / limit));
const visible = useMemo(() => {
const merged = [...streamRows, ...rows];
if (kindParam === 'all') return merged;
return merged.filter((r) => r.kind === kindParam);
}, [streamRows, rows, kindParam]);
const failuresLastHour = useMemo(() => {
const cutoff = now - HOUR_MS;
return [...streamRows, ...rows].filter(
(r) => !r.success && new Date(r.ts).getTime() >= cutoff,
).length;
}, [streamRows, rows, now]);
const statusLabel =
status === 'live' ? 'LIVE'
: status === 'connecting' ? 'CONNECTING'
: 'OFFLINE';
return (
<div className="orchestrator-root">
<div className="page-header">
<div className="page-title-group">
<div className="header-line">
<Cpu size={22} className="violet-accent" />
<h1>ORCHESTRATOR</h1>
<span className={`status-pill ${status}`}>
<span className="dot" />
{statusLabel}
</span>
{failuresLastHour > 0 && (
<span className="failure-pill">
<AlertTriangle size={12} />
{failuresLastHour} FAILURES / 1H
</span>
)}
</div>
<span className="page-sub">
{total.toLocaleString()} EVENTS · LIFE-INJECTION ACTIVITY
</span>
</div>
</div>
<div className="controls-row">
<div className="seg-group" role="tablist" aria-label="Filter by event kind">
{(['all', 'traffic', 'file', 'email'] as KindFilter[]).map((k) => (
<button
key={k}
className={kindParam === k ? 'active' : ''}
onClick={() => setKind(k)}
role="tab"
aria-selected={kindParam === k}
>
{k}
</button>
))}
</div>
<button
className={`btn ${paused ? 'paused' : ''}`}
onClick={() => setPaused((v) => !v)}
>
{paused ? <Play size={12} /> : <Pause size={12} />}
{paused ? 'RESUME STREAM' : 'PAUSE STREAM'}
</button>
</div>
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<Filter size={14} />
<span>{visible.length.toLocaleString()} EVENTS SHOWN</span>
</div>
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={14} />
</button>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)} aria-label="Next page">
<ChevronRight size={14} />
</button>
</div>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TS</th>
<th>KIND</th>
<th>ACTION</th>
<th>SRC DST</th>
<th>OK</th>
<th>PAYLOAD</th>
</tr>
</thead>
<tbody>
{visible.length > 0 ? visible.map((r) => {
const fresh = now - new Date(r.ts).getTime() < FRESH_MS;
const cls = !r.success ? 'fail' : fresh ? 'fresh' : '';
const kindCls =
r.kind === 'traffic' || r.kind === 'file' || r.kind === 'email'
? r.kind : '';
const isEmail = r.kind === 'email';
// FileAction and EditAction both write kind="file"; the
// discriminator lives in `action` ("file:create" vs
// "file:edit"). Surface the difference visually without
// widening the kind enum (which doubles as the bus
// topic family).
const isEdit = r.kind === 'file' && r.action?.startsWith('file:edit');
return (
<tr
key={r.uuid}
className={`${cls} clickable`}
onClick={() => setSelected(r)}
>
<td className="dim">{timeAgo(r.ts)}</td>
<td>
<span className={`kind-chip ${kindCls}`}>{r.kind}</span>
{isEdit && (
<span
className="chip dim-chip"
style={{ marginLeft: 4 }}
title="In-place edit of a previously planted file"
>
EDIT
</span>
)}
</td>
<td className="mono matrix-text">
{isEmail && r.language && (
<span
className="chip dim-chip"
style={{ marginRight: 6 }}
title={`Language: ${r.language}`}
>
{r.language.toUpperCase()}
</span>
)}
{r.action}
</td>
<td className="src-dst">
{/* Email rows show full sender / recipient addresses;
UUID rows stay truncated. */}
{isEmail
? (r.src_decky_uuid ?? '—')
: (r.src_decky_uuid ? `${r.src_decky_uuid.slice(0, 8)}` : '—')}
<span className="arrow"></span>
{isEmail
? (r.dst_decky_uuid || '—')
: (r.dst_decky_uuid ? `${r.dst_decky_uuid.slice(0, 8)}` : '—')}
</td>
<td>
<span className={r.success ? 'ok-yes' : 'ok-no'}>
{r.success ? '✓' : '✗'}
</span>
</td>
<td className="payload-cell">{r.payload}</td>
</tr>
);
}) : (
<tr className="empty-row">
<td colSpan={6}>
<EmptyState
icon={Cpu}
title={loading ? 'LOADING…' : 'NO ORCHESTRATOR ACTIVITY YET'}
hint={
loading
? undefined
: kindParam === 'email'
? 'start the worker with `decnet emailgen run`'
: 'start the worker with `decnet orchestrate`'
}
/>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{selected && (
<OrchestratorInspector
event={selected}
onClose={() => setSelected(null)}
/>
)}
</div>
);
};
export default Orchestrator;

View File

@@ -0,0 +1,216 @@
import React, { useMemo } from 'react';
import { X, Cpu, Copy, ArrowRight } from '../icons';
import { useToast } from './Toasts/useToast';
export interface OrchestratorInspectorEntry {
uuid: string;
ts: string;
kind: 'traffic' | 'file' | 'email' | string;
protocol: string;
action: string;
src_decky_uuid: string | null;
dst_decky_uuid: string;
success: boolean;
payload: string;
// Email-only extras populated when `kind === 'email'`.
subject?: string;
sender_email?: string;
recipient_email?: string;
language?: string;
thread_id?: string;
mail_decky_uuid?: string;
message_id?: string;
in_reply_to?: string | null;
}
interface Props {
event: OrchestratorInspectorEntry;
onClose: () => void;
}
const renderDeckyId = (id: string | null): string => id ?? '—';
const sourceTag = (id: string | null): 'topology' | 'fleet' | 'shard' | null => {
if (!id) return null;
// Composite "host_uuid:name" identifies fleet/shard rows;
// bare UUIDs (8-4-4-4-12) are MazeNET TopologyDecky.uuid.
if (id.includes(':')) return id.startsWith('local:') ? 'fleet' : 'shard';
return /^[0-9a-f]{8}-/i.test(id) ? 'topology' : null;
};
const OrchestratorInspector: React.FC<Props> = ({ event, onClose }) => {
const { push } = useToast();
const prettyPayload = useMemo(() => {
try {
return JSON.stringify(JSON.parse(event.payload), null, 2);
} catch {
return event.payload;
}
}, [event.payload]);
const copy = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
push({ text: `${label} COPIED`, tone: 'matrix', icon: 'copy' });
} catch {
push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' });
}
};
const copyEvent = () => copy(JSON.stringify(event, null, 2), 'EVENT JSON');
const copyPayload = () => copy(prettyPayload, 'PAYLOAD JSON');
const kindCls =
event.kind === 'traffic' || event.kind === 'file' || event.kind === 'email'
? event.kind : '';
const isEmail = event.kind === 'email';
const isEdit = event.kind === 'file' && event.action?.startsWith('file:edit');
const srcSrc = sourceTag(event.src_decky_uuid);
const dstSrc = sourceTag(event.dst_decky_uuid);
const isLive = event.uuid.startsWith('live-');
return (
<div
className="orchestrator-drawer-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="orchestrator-drawer">
<div className="bd-head">
<h3>
<Cpu size={14} />
<span>
{isLive ? 'LIVE EVENT' : `EVENT #${event.uuid.slice(0, 8)}`}
</span>
<span className={`kind-chip ${kindCls}`} style={{ marginLeft: 8 }}>
{event.kind.toUpperCase()}
</span>
{isEdit && (
<span
className="chip dim-chip"
style={{ marginLeft: 4 }}
title="In-place edit of a previously planted file"
>
EDIT
</span>
)}
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
</button>
</div>
<div className="bd-body">
<div className="kvs">
<div className="k">TS</div>
<div className="v">{new Date(event.ts).toLocaleString()}</div>
<div className="k">PROTOCOL</div>
<div className="v">
<span className="chip dim-chip">{event.protocol.toUpperCase()}</span>
</div>
<div className="k">{isEmail ? 'SUBJECT' : 'ACTION'}</div>
<div className="v mono matrix-text">{event.action}</div>
{isEmail && event.language && (
<>
<div className="k">LANGUAGE</div>
<div className="v">
<span className="chip dim-chip">{event.language.toUpperCase()}</span>
</div>
</>
)}
{isEmail && event.thread_id && (
<>
<div className="k">THREAD</div>
<div className="v">
<span className="hash-text">{event.thread_id}</span>
</div>
</>
)}
{isEmail && event.in_reply_to && (
<>
<div className="k">IN-REPLY-TO</div>
<div className="v">
<span className="hash-text">{event.in_reply_to}</span>
</div>
</>
)}
{isEmail && event.mail_decky_uuid && (
<>
<div className="k">MAIL DECKY</div>
<div className="v">
<span className="hash-text">{event.mail_decky_uuid}</span>
</div>
</>
)}
<div className="k">OUTCOME</div>
<div className="v">
<span className={event.success ? 'ok-yes' : 'ok-no'}>
{event.success ? '✓ SUCCESS' : '✗ FAILURE'}
</span>
</div>
<div className="k">SRC</div>
<div className="v">
{event.src_decky_uuid ? (
<span className="src-dst-cell">
<span className="hash-text">{renderDeckyId(event.src_decky_uuid)}</span>
{srcSrc && <span className={`chip src-${srcSrc}`}>{srcSrc.toUpperCase()}</span>}
</span>
) : (
<span className="dim"></span>
)}
</div>
<div className="k"><ArrowRight size={12} /></div>
<div className="v">
<span className="src-dst-cell">
<span className="hash-text">{renderDeckyId(event.dst_decky_uuid)}</span>
{dstSrc && <span className={`chip src-${dstSrc}`}>{dstSrc.toUpperCase()}</span>}
</span>
</div>
{!isLive && (
<>
<div className="k">EVENT UUID</div>
<div className="v">
<div className="hash-row">
<span className="hash-text">{event.uuid}</span>
<button
className="icon-btn"
onClick={() => copy(event.uuid, 'UUID')}
aria-label="Copy event UUID"
>
<Copy size={12} />
</button>
</div>
</div>
</>
)}
</div>
<div>
<div className="type-label">PAYLOAD</div>
<pre className="code-block">{prettyPayload}</pre>
</div>
<div>
<div className="type-label">EXPORT</div>
<div className="bd-actions">
<button className="btn ghost" onClick={copyEvent}>
<Copy size={12} /> COPY EVENT JSON
</button>
<button className="btn ghost" onClick={copyPayload}>
<Copy size={12} /> COPY PAYLOAD
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default OrchestratorInspector;

View File

@@ -0,0 +1,107 @@
/* Persona Generation — layered on top of DeckyFleet.css.
Only persona-specific bits live here: tone chips, info banner,
dirty pill, modal form inputs, empty state, mono helper. */
.persona-gen-root .mono { font-family: var(--font-mono); }
/* Info banner explaining scope (non-MazeNET) + showing pool path. */
.persona-gen-root .info-banner {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
padding: 10px 14px;
font-size: 0.78rem;
line-height: 1.5;
}
.persona-gen-root .info-banner em { color: var(--matrix); font-style: normal; }
.persona-gen-root .info-line { margin-top: 6px; font-size: 0.72rem; }
.persona-gen-root .dirty-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--amber, #f59e0b);
font-size: 0.62rem;
letter-spacing: 1.4px;
text-transform: uppercase;
border: 1px solid var(--amber, #f59e0b);
width: fit-content;
}
/* Tone chips — keep palette differentiation from the original page. */
.persona-gen-root .tone-chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--border);
font-size: 0.62rem;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.persona-gen-root .tone-chip.tone-formal { border-color: var(--violet); color: var(--violet); }
.persona-gen-root .tone-chip.tone-direct { border-color: var(--matrix); color: var(--matrix); }
.persona-gen-root .tone-chip.tone-casual { border-color: var(--amber, #f59e0b); color: var(--amber, #f59e0b); }
.persona-gen-root .tone-chip.tone-technical { border-color: var(--cyan, #22d3ee); color: var(--cyan, #22d3ee); }
.persona-gen-root .tone-chip.tone-custom { border-color: var(--alert, #ef4444); color: var(--alert, #ef4444); text-transform: none; letter-spacing: 0.5px; }
/* Card tweaks — wrap email if long. */
.persona-card .decky-ip {
font-size: 0.65rem;
letter-spacing: 0.4px;
word-break: break-all;
max-width: 60%;
text-align: right;
}
/* Empty state inside the grid. */
.fleet-root .fleet-empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 60px 24px;
border: 1px dashed var(--border);
background: rgba(0, 0, 0, 0.18);
text-align: center;
letter-spacing: 1.5px;
font-size: 0.78rem;
}
/* Modal form inputs — reuse the wizard's tweak-group / input language. */
.persona-gen-root .grid-2,
.modal .grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.modal .tweak-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal .tweak-group label {
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim);
text-transform: uppercase;
}
.modal .input {
background: var(--bg-elev, rgba(0, 0, 0, 0.3));
border: 1px solid var(--border);
color: var(--text);
padding: 7px 10px;
font-family: inherit;
font-size: 0.8rem;
outline: none;
transition: border-color 0.15s;
}
.modal .input:focus {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet);
}
.modal select.input { cursor: pointer; }

View File

@@ -0,0 +1,875 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download,
} from '../icons';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import Modal from './Modal/Modal';
import './DeckyFleet.css';
import './PersonaGeneration.css';
type Tone = 'formal' | 'direct' | 'casual' | 'technical' | 'custom';
type ReplyLatency = 'fast' | 'normal' | 'slow';
interface EmailPersona {
name: string;
email: string;
role: string;
tone: Tone;
tone_custom: string | null;
mannerisms: string[];
language: string | null;
signature: string | null;
active_hours: string;
reply_latency: ReplyLatency;
uses_llms_heavily: boolean;
}
interface PersonasResponse {
path?: string;
topology_name?: string;
language_default?: string;
personas: EmailPersona[];
}
const BLANK: EmailPersona = {
name: '',
email: '',
role: '',
tone: 'formal',
tone_custom: null,
mannerisms: [],
language: 'en',
signature: null,
active_hours: '09:00-18:00',
reply_latency: 'normal',
uses_llms_heavily: false,
};
const TONES: Tone[] = ['formal', 'direct', 'casual', 'technical', 'custom'];
const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow'];
type FilterKey = 'all' | Tone;
function extractErrorDetail(err: unknown, fallback: string): string {
const e = err as {
response?: { status?: number; data?: { detail?: string } };
message?: string;
};
if (e?.response?.data?.detail) return e.response.data.detail;
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
if (e?.response?.status === 401) return 'Session expired — please log in again';
if (e?.message) return e.message;
return fallback;
}
/** Light client-side validation — server re-validates with the same
* Pydantic schema the worker uses, this is just the early-warn UX. */
function validate(p: EmailPersona): string | null {
if (!p.name.trim()) return 'name is required';
if (!p.email.trim()) return 'email is required';
if (!p.email.includes('@') || !p.email.split('@')[1]?.includes('.')) {
return 'email must look like user@domain.tld';
}
if (!p.role.trim()) return 'role is required';
if (p.tone === 'custom' && !(p.tone_custom ?? '').trim()) {
return 'custom tone requires a description';
}
if (p.mannerisms.length > 12) return 'at most 12 mannerisms per persona';
return null;
}
// ─── Bulk upload helpers ──────────────────────────────────────────────────
const TEMPLATE: { personas: EmailPersona[] } = {
personas: [
{
name: 'Jane Operator',
email: 'jane@example.com',
role: 'Network Admin',
tone: 'formal',
tone_custom: null,
mannerisms: ["uses bullet points", "signs off with 'Best regards'"],
language: 'en',
signature: 'Jane Operator\nNetwork Admin',
active_hours: '09:00-18:00',
reply_latency: 'normal',
uses_llms_heavily: false,
},
],
};
/** Soft client-side normalizer for an uploaded persona entry.
* Mirrors the Pydantic rules in decnet/realism/personas.py.
* Server re-validates on save, so this is just early-warn UX. */
function coercePersona(raw: unknown): { ok: EmailPersona } | { error: string } {
if (!raw || typeof raw !== 'object') return { error: 'entry is not an object' };
const r = raw as Record<string, unknown>;
const name = typeof r.name === 'string' ? r.name.trim() : '';
const email = typeof r.email === 'string' ? r.email.trim() : '';
const role = typeof r.role === 'string' ? r.role.trim() : '';
if (!name) return { error: 'missing name' };
if (!email) return { error: 'missing email' };
if (!email.includes('@') || !email.split('@')[1]?.includes('.')) {
return { error: `invalid email "${email}"` };
}
if (!role) return { error: 'missing role' };
const tone = TONES.includes(r.tone as Tone) ? (r.tone as Tone) : 'formal';
const tone_custom = typeof r.tone_custom === 'string' && r.tone_custom.trim()
? r.tone_custom.slice(0, 128) : null;
if (tone === 'custom' && !tone_custom) {
return { error: 'tone="custom" requires a non-empty tone_custom' };
}
const reply_latency = LATENCIES.includes(r.reply_latency as ReplyLatency)
? (r.reply_latency as ReplyLatency) : 'normal';
const mannerisms = Array.isArray(r.mannerisms)
? r.mannerisms.filter((m): m is string => typeof m === 'string').slice(0, 12)
: [];
const language = typeof r.language === 'string' && r.language
? r.language.slice(0, 8) : null;
const signature = typeof r.signature === 'string' && r.signature
? r.signature : null;
const active_hours = typeof r.active_hours === 'string' && r.active_hours
? r.active_hours : '09:00-18:00';
return {
ok: {
name, email, role, tone, tone_custom, mannerisms, language, signature,
active_hours, reply_latency,
uses_llms_heavily: r.uses_llms_heavily === true,
},
};
}
interface MergeResult {
merged: EmailPersona[];
added: number;
replaced: number;
}
/** Dedupe by lowercased email; uploaded entries replace existing matches. */
function mergePersonas(current: EmailPersona[], incoming: EmailPersona[]): MergeResult {
const byEmail = new Map<string, EmailPersona>();
for (const p of current) byEmail.set(p.email.toLowerCase(), p);
let added = 0;
let replaced = 0;
for (const p of incoming) {
const key = p.email.toLowerCase();
if (byEmail.has(key)) replaced += 1;
else added += 1;
byEmail.set(key, p);
}
return { merged: Array.from(byEmail.values()), added, replaced };
}
// ─── Persona card ─────────────────────────────────────────────────────────
interface PersonaCardProps {
persona: EmailPersona;
onEdit: () => void;
onRemove: () => void;
}
const PersonaCard: React.FC<PersonaCardProps> = ({ persona: p, onEdit, onRemove }) => (
<div className="decky-card persona-card">
<div className="decky-head">
<div className="decky-name">
<span className={`status-dot ${p.uses_llms_heavily ? 'mutating' : 'active'}`} />
{p.name}
</div>
<span className="decky-ip">{p.email}</span>
</div>
<div className="decky-meta">
<div className="row">
<span className="label">ROLE</span>
<span>{p.role}</span>
</div>
<div className="row">
<span className="label">TONE</span>
<span
className={`tone-chip tone-${p.tone}`}
title={p.tone === 'custom' ? (p.tone_custom ?? '') : undefined}
>
{p.tone === 'custom' && p.tone_custom
? (p.tone_custom.length > 24 ? `${p.tone_custom.slice(0, 22)}` : p.tone_custom)
: p.tone}
</span>
</div>
<div className="row">
<span className="label">LANG</span>
<span className="dim">{(p.language ?? 'en').toUpperCase()}</span>
</div>
<div className="row">
<span className="label">HOURS</span>
<span className="mono">{p.active_hours}</span>
</div>
<div className="row">
<span className="label">REPLY</span>
<span className="violet-accent">{p.reply_latency}</span>
</div>
</div>
<div>
<div className="type-label" style={{ marginBottom: 6, opacity: 0.5, fontSize: '0.62rem', letterSpacing: 1 }}>
MANNERISMS
</div>
<div className="decky-services">
{p.mannerisms.length === 0 ? (
<span className="dim" style={{ fontSize: '0.7rem' }}></span>
) : (
p.mannerisms.map((m, i) => (
<span key={i} className="service-tag" title={m}>
{m.length > 24 ? `${m.slice(0, 22)}` : m}
</span>
))
)}
</div>
</div>
<div className="decky-footer">
<span className="decky-hits">
{p.uses_llms_heavily ? (
<span className="alert-text" style={{ fontWeight: 700 }} title="Em-dash suppression lifted">
LLM-HEAVY
</span>
) : (
<span className="dim">SUPPRESSED EM-DASH</span>
)}
</span>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn small" onClick={onEdit} title={`Edit ${p.name}`}>
<Pencil size={10} /> EDIT
</button>
<button className="btn alert small" onClick={onRemove} title={`Remove ${p.name}`}>
<Trash2 size={10} /> REMOVE
</button>
</div>
</div>
</div>
);
// ─── Editor modal ─────────────────────────────────────────────────────────
interface PersonaEditorProps {
open: boolean;
editing: boolean;
draft: EmailPersona;
setDraft: (p: EmailPersona) => void;
draftError: string | null;
mannerismDraft: string;
setMannerismDraft: (s: string) => void;
onClose: () => void;
onSave: () => void;
}
const PersonaEditor: React.FC<PersonaEditorProps> = ({
open, editing, draft, setDraft, draftError,
mannerismDraft, setMannerismDraft, onClose, onSave,
}) => {
const addMannerism = () => {
const t = mannerismDraft.trim();
if (!t) return;
if (draft.mannerisms.includes(t)) {
setMannerismDraft('');
return;
}
setDraft({ ...draft, mannerisms: [...draft.mannerisms, t] });
setMannerismDraft('');
};
const removeMannerism = (idx: number) => {
setDraft({
...draft,
mannerisms: draft.mannerisms.filter((_, i) => i !== idx),
});
};
return (
<Modal
open={open}
onClose={onClose}
title={editing ? 'EDIT PERSONA' : 'ADD PERSONA'}
icon={Mail}
accent="violet"
width="wide"
footer={
<>
<button className="btn ghost" onClick={onClose}>CANCEL</button>
<button className="btn violet" onClick={onSave}>
<Check size={12} /> {editing ? 'UPDATE' : 'ADD'}
</button>
</>
}
>
<div className="modal-body">
<div className="grid-2">
<div className="tweak-group">
<label>NAME *</label>
<input
className="input"
type="text"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="John Smith"
/>
</div>
<div className="tweak-group">
<label>EMAIL *</label>
<input
className="input"
type="email"
value={draft.email}
onChange={(e) => setDraft({ ...draft, email: e.target.value })}
placeholder="john.smith@corp.com"
/>
</div>
</div>
<div className="tweak-group">
<label>ROLE *</label>
<input
className="input"
type="text"
value={draft.role}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
placeholder="Chief Operating Officer"
/>
</div>
<div className="grid-2">
<div className="tweak-group">
<label>TONE</label>
<select
className="input"
value={draft.tone}
onChange={(e) => setDraft({ ...draft, tone: e.target.value as Tone })}
>
{TONES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{draft.tone === 'custom' && (
<input
className="input"
type="text"
maxLength={128}
style={{ marginTop: 6 }}
value={draft.tone_custom ?? ''}
onChange={(e) =>
setDraft({ ...draft, tone_custom: e.target.value || null })
}
placeholder="e.g. terse, deadpan, sarcastic-but-polite"
/>
)}
</div>
<div className="tweak-group">
<label>LANGUAGE</label>
<input
className="input"
type="text"
maxLength={8}
value={draft.language ?? ''}
onChange={(e) => setDraft({ ...draft, language: e.target.value || null })}
placeholder="en"
/>
</div>
<div className="tweak-group">
<label>REPLY LATENCY</label>
<select
className="input"
value={draft.reply_latency}
onChange={(e) =>
setDraft({ ...draft, reply_latency: e.target.value as ReplyLatency })
}
>
{LATENCIES.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div className="tweak-group">
<label>ACTIVE HOURS</label>
<input
className="input"
type="text"
value={draft.active_hours}
onChange={(e) => setDraft({ ...draft, active_hours: e.target.value })}
placeholder="09:00-18:00 (wraps OK)"
/>
</div>
</div>
<div className="tweak-group">
<label>MANNERISMS ({draft.mannerisms.length}/12)</label>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="input"
type="text"
style={{ flex: 1 }}
value={mannerismDraft}
onChange={(e) => setMannerismDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addMannerism();
}
}}
placeholder="opens with 'Hey' not 'Dear'"
/>
<button
type="button"
className="btn ghost"
onClick={addMannerism}
disabled={!mannerismDraft.trim() || draft.mannerisms.length >= 12}
>
<Plus size={12} /> ADD
</button>
</div>
{draft.mannerisms.length > 0 && (
<div className="decky-services" style={{ marginTop: 8 }}>
{draft.mannerisms.map((m, i) => (
<span
key={i}
className="service-tag"
style={{ cursor: 'pointer' }}
onClick={() => removeMannerism(i)}
title="click to remove"
>
{m}
</span>
))}
</div>
)}
</div>
<div className="tweak-group">
<label>SIGNATURE (optional)</label>
<textarea
className="input"
rows={3}
style={{ resize: 'vertical', fontFamily: 'var(--font-mono)' }}
value={draft.signature ?? ''}
onChange={(e) => setDraft({ ...draft, signature: e.target.value || null })}
placeholder="-- John&#10;COO, ACME Corp"
/>
</div>
<div
style={{
display: 'flex', gap: 10, alignItems: 'center',
padding: 14, border: '1px solid var(--border)',
}}
>
<input
id="llm-heavy"
type="checkbox"
checked={draft.uses_llms_heavily}
onChange={(e) => setDraft({ ...draft, uses_llms_heavily: e.target.checked })}
style={{ accentColor: 'var(--violet)' }}
/>
<label htmlFor="llm-heavy" style={{ fontSize: '0.78rem', letterSpacing: 1 }}>
<strong>USES LLMS HEAVILY</strong>
<span className="dim" style={{ marginLeft: 8, letterSpacing: 0 }}>
em-dash suppression lifted; output may contain natural em-dashes
</span>
</label>
</div>
{draftError && (
<div
style={{
border: '1px solid var(--alert)',
color: 'var(--alert)',
padding: '8px 12px',
fontSize: '0.75rem',
letterSpacing: 1,
display: 'inline-flex',
gap: 8,
alignItems: 'center',
}}
>
<AlertTriangle size={12} /> {draftError}
</div>
)}
</div>
</Modal>
);
};
// ─── Page ─────────────────────────────────────────────────────────────────
interface PersonaGenerationProps {
/** When set, the editor manages the personas attached to the given
* topology row (Topology.email_personas) instead of the global
* fleet/SWARM pool. The component negotiates this with two
* backend endpoints sharing the same wire shape. */
topologyId?: string;
}
const PersonaGeneration: React.FC<PersonaGenerationProps> = ({ topologyId }) => {
const { push } = useToast();
const isTopology = Boolean(topologyId);
const endpoint = isTopology
? `/topologies/${topologyId}/personas`
: '/realism/personas';
const [path, setPath] = useState<string>('');
const [topoName, setTopoName] = useState<string>('');
const [languageDefault, setLanguageDefault] = useState<string>('en');
const [personas, setPersonas] = useState<EmailPersona[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<FilterKey>('all');
const fileRef = useRef<HTMLInputElement>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [draft, setDraft] = useState<EmailPersona>(BLANK);
const [draftError, setDraftError] = useState<string | null>(null);
const [mannerismDraft, setMannerismDraft] = useState('');
const counts = useMemo(() => {
const c: Record<FilterKey, number> = {
all: personas.length,
formal: 0, direct: 0, casual: 0, technical: 0, custom: 0,
};
for (const p of personas) c[p.tone] += 1;
return c;
}, [personas]);
const visible = useMemo(
() => filter === 'all' ? personas : personas.filter((p) => p.tone === filter),
[personas, filter],
);
const fetchPersonas = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<PersonasResponse>(endpoint);
const list = res.data.personas ?? [];
setPersonas(list);
setPath(res.data.path ?? '');
setTopoName(res.data.topology_name ?? '');
setLanguageDefault(res.data.language_default ?? 'en');
} catch (err) {
setError(extractErrorDetail(err, 'Failed to load personas'));
} finally {
setLoading(false);
}
};
useEffect(() => { fetchPersonas(); /* eslint-disable-next-line */ }, [endpoint]);
const openAdd = () => {
setEditingIdx(null);
setDraft({ ...BLANK });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const openEdit = (idx: number) => {
setEditingIdx(idx);
setDraft({ ...personas[idx] });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setDraft(BLANK);
setEditingIdx(null);
setMannerismDraft('');
setDraftError(null);
};
/** PUT *next* and adopt the server's parsed result on success.
* Returns true if the write committed. Persona changes are saved
* eagerly (no SAVE/DISCARD staging) so each CRUD action is one round-
* trip; on failure we leave the local list untouched so the UI never
* shows phantom rows. */
const persistPersonas = async (
next: EmailPersona[],
successText: string,
): Promise<boolean> => {
setError(null);
try {
const res = await api.put<PersonasResponse>(endpoint, { personas: next });
const list = res.data.personas ?? [];
setPersonas(list);
setPath(res.data.path ?? path);
setTopoName(res.data.topology_name ?? topoName);
setLanguageDefault(res.data.language_default ?? languageDefault);
push({ text: successText, tone: 'matrix', icon: 'check' });
return true;
} catch (err) {
const msg = extractErrorDetail(err, 'Failed to save personas');
setError(msg);
push({ text: msg.toUpperCase(), tone: 'alert', icon: 'alert-triangle' });
return false;
}
};
const saveDraft = async () => {
const err = validate(draft);
if (err) { setDraftError(err); return; }
// Email uniqueness — same address across two personas would let
// the scheduler pick "John" as both sender and recipient.
const dupeIdx = personas.findIndex(
(p, i) => p.email === draft.email && i !== editingIdx,
);
if (dupeIdx !== -1) {
setDraftError(`email already used by "${personas[dupeIdx].name}"`);
return;
}
let next: EmailPersona[];
if (editingIdx === null) {
next = [...personas, draft];
} else {
next = personas.slice();
next[editingIdx] = draft;
}
const ok = await persistPersonas(
next,
editingIdx === null
? `ADDED ${draft.name.toUpperCase()}`
: `UPDATED ${draft.name.toUpperCase()}`,
);
if (ok) closeModal();
};
const removePersona = async (idx: number) => {
const target = personas[idx];
if (!confirm(`Remove ${target.name}?`)) return;
await persistPersonas(
personas.filter((_, i) => i !== idx),
`REMOVED ${target.name.toUpperCase()}`,
);
};
const downloadTemplate = () => {
const blob = new Blob(
[JSON.stringify(TEMPLATE, null, 2)],
{ type: 'application/json' },
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'email_personas_template.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleBulkFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
// Reset the input so picking the same file twice still fires onChange.
e.target.value = '';
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
setError(null);
let parsed: unknown;
try {
parsed = JSON.parse(String(reader.result));
} catch (err) {
setError(`Could not parse JSON: ${(err as Error).message}`);
return;
}
// Accept either a top-level array or { personas: [...] }.
let rawList: unknown[] | null = null;
if (Array.isArray(parsed)) {
rawList = parsed;
} else if (parsed && typeof parsed === 'object'
&& Array.isArray((parsed as { personas?: unknown }).personas)) {
rawList = (parsed as { personas: unknown[] }).personas;
}
if (!rawList) {
setError('Expected a JSON array or an object with a "personas" array');
return;
}
const accepted: EmailPersona[] = [];
const reasons: string[] = [];
for (let i = 0; i < rawList.length; i += 1) {
const r = coercePersona(rawList[i]);
if ('ok' in r) accepted.push(r.ok);
else reasons.push(`#${i + 1}: ${r.error}`);
}
if (accepted.length === 0) {
setError(
`No valid personas in ${f.name}.` +
(reasons.length ? ` First issue: ${reasons[0]}` : ''),
);
return;
}
const { merged, added, replaced } = mergePersonas(personas, accepted);
const skipped = reasons.length;
const parts = [`+${added} added`];
if (replaced) parts.push(`${replaced} replaced`);
if (skipped) parts.push(`${skipped} skipped`);
const summary = `IMPORTED ${accepted.length} PERSONA${accepted.length === 1 ? '' : 'S'} (${parts.join(', ')})`;
void persistPersonas(merged, summary).then((ok) => {
if (ok && skipped) {
// Persisted, but show *why* some were dropped so the operator
// can fix the source file.
setError(`Skipped ${skipped} invalid entr${skipped === 1 ? 'y' : 'ies'}: ${reasons.slice(0, 3).join('; ')}${reasons.length > 3 ? '…' : ''}`);
}
});
};
reader.readAsText(f);
};
if (loading) {
return (
<div className="fleet-root">
<div className="dim" style={{ padding: '40px', textAlign: 'center', letterSpacing: 2 }}>
LOADING PERSONAS...
</div>
</div>
);
}
const llmHeavyCount = personas.filter((p) => p.uses_llms_heavily).length;
return (
<div className="fleet-root persona-gen-root">
<div className="page-header">
<div className="page-title-group">
<h1>{isTopology ? 'TOPOLOGY PERSONAS' : 'PERSONA GENERATION'}</h1>
<span className="page-sub">
{personas.length} PERSONA{personas.length === 1 ? '' : 'S'} · {llmHeavyCount} LLM-HEAVY
{isTopology
? ` · TOPOLOGY ${topoName ? topoName.toUpperCase() : (topologyId ?? '').slice(0, 8)} · DEFAULT LANG ${languageDefault.toUpperCase()}`
: ' · GLOBAL POOL · FLEET (MACVLAN/IPVLAN) + SWARM-SHARD MAIL DECKIES'}
</span>
</div>
<div className="actions">
<div className="fleet-filter-group">
{([['all', 'ALL'], ['formal', 'FORMAL'], ['direct', 'DIRECT'],
['casual', 'CASUAL'], ['technical', 'TECHNICAL'],
['custom', 'CUSTOM']] as [FilterKey, string][]).map(
([v, l]) => (
<button
key={v}
onClick={() => setFilter(v)}
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
>
{l} {counts[v]}
</button>
),
)}
</div>
<button className="btn violet" onClick={openAdd}>
<Plus size={12} /> ADD PERSONA
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
onChange={handleBulkFile}
style={{ display: 'none' }}
/>
<button
className="btn"
onClick={() => fileRef.current?.click()}
title="Import personas from a JSON file"
>
<Upload size={12} /> BULK UPLOAD
</button>
<button
className="btn ghost"
onClick={downloadTemplate}
title="Download a JSON template you can fill out and re-upload"
>
<Download size={12} /> TEMPLATE
</button>
</div>
</div>
<div className="info-banner">
{isTopology ? (
<div>
<strong>Scope:</strong> personas listed here drive emailgen for the
mail deckies attached to <em>this MazeNET topology only</em>.
Unset <code>language</code> entries fall back to the topology's
default ({languageDefault.toUpperCase()}).
</div>
) : (
<div>
<strong>Scope:</strong> personas listed here drive emailgen against{' '}
<em>non-MazeNET</em> mail deckies (unihost MACVLAN/IPVLAN, SWARM
shards). MazeNET topologies have their own per-topology persona
list configured in the topology editor.
</div>
)}
{path && !isTopology && (
<div className="info-line">
<span className="dim">FILE</span>{' '}
<span className="mono matrix-text">{path}</span>
</div>
)}
{error && (
<div className="info-line alert-text" style={{ marginTop: 8 }}>
<AlertTriangle size={12} /> {error}
</div>
)}
</div>
<div className="grid-fleet">
{visible.length === 0 ? (
<div className="fleet-empty">
<Mail size={32} className="dim" />
<span className="dim">
{personas.length === 0
? (isTopology
? 'NO PERSONAS ON THIS TOPOLOGY ADD AT LEAST 2 SO THE EMAILGEN SCHEDULER CAN PICK SENDER+RECIPIENT'
: 'NO PERSONAS CONFIGURED ADD AT LEAST 2 TO START THE EMAILGEN WORKER')
: 'NO PERSONAS MATCH CURRENT FILTER'}
</span>
{personas.length === 0 && (
<button className="btn violet" onClick={openAdd}>
<Plus size={12} /> ADD PERSONA
</button>
)}
</div>
) : (
visible.map((p, idx) => {
const realIdx = personas.indexOf(p);
return (
<PersonaCard
key={`${p.email}-${idx}`}
persona={p}
onEdit={() => openEdit(realIdx)}
onRemove={() => removePersona(realIdx)}
/>
);
})
)}
</div>
<PersonaEditor
open={modalOpen}
editing={editingIdx !== null}
draft={draft}
setDraft={setDraft}
draftError={draftError}
mannerismDraft={mannerismDraft}
setMannerismDraft={setMannerismDraft}
onClose={closeModal}
onSave={saveDraft}
/>
</div>
);
};
export default PersonaGeneration;
// Topology-bound variant. Mounted at /topologies/:id/personas; the
// route component reads the id off the URL so callers can `<Link>`
// straight in from the topology list / MazeNET toolbar.
export const TopologyPersonaGeneration: React.FC = () => {
const { id } = useParams<{ id: string }>();
if (!id) return null;
return <PersonaGeneration topologyId={id} />;
};

View File

@@ -0,0 +1,122 @@
/* Realism Config — layered on DeckyFleet.css.
Adds: weight tables (label + numeric input + percent share),
canary-class accent, canary-probability slider row. */
.realism-config-root .mono { font-family: var(--font-mono); }
.realism-config-root .info-banner {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
padding: 10px 14px;
font-size: 0.78rem;
line-height: 1.5;
}
.realism-config-root .info-banner em { color: var(--matrix); font-style: normal; }
/* Section heading above each weight table. */
.realism-config-root .section-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.7rem;
letter-spacing: 1.5px;
color: var(--dim);
text-transform: uppercase;
margin: 0 0 8px;
}
.realism-config-root .section-head .total {
font-family: var(--font-mono);
color: var(--matrix);
letter-spacing: 1px;
}
.realism-config-root .section-help {
font-size: 0.7rem;
opacity: 0.45;
letter-spacing: 0.5px;
margin: -4px 0 8px;
}
/* Weight table: class label · raw enum · weight input · percent share. */
.realism-config-root .weight-table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
margin-bottom: 24px;
font-size: 0.82rem;
}
.realism-config-root .weight-table tr {
border-bottom: 1px solid var(--border);
}
.realism-config-root .weight-table tr:last-child { border-bottom: none; }
.realism-config-root .weight-table td {
padding: 8px 14px;
vertical-align: middle;
}
.realism-config-root .weight-table td.cls { width: 50%; }
.realism-config-root .weight-table td.cls .cls-label { font-weight: 600; }
.realism-config-root .weight-table td.cls .cls-enum {
margin-left: 10px;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--dim);
letter-spacing: 0.5px;
}
.realism-config-root .weight-table td.cls.canary .cls-label {
color: var(--amber, #f59e0b);
}
.realism-config-root .weight-table td.weight {
width: 130px;
}
.realism-config-root .weight-table input.weight-input {
width: 90px;
background: var(--bg-elev, rgba(0, 0, 0, 0.3));
border: 1px solid var(--border);
color: var(--matrix);
padding: 5px 9px;
font-family: var(--font-mono);
font-size: 0.85rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: right;
}
.realism-config-root .weight-table input.weight-input:focus {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet);
}
.realism-config-root .weight-table td.share {
font-family: var(--font-mono);
color: var(--dim);
width: 80px;
text-align: right;
}
/* Canary probability slider row. */
.realism-config-root .prob-row {
display: flex;
align-items: center;
gap: 16px;
background: var(--panel);
border: 1px solid var(--border);
padding: 12px 16px;
margin-bottom: 24px;
}
.realism-config-root .prob-row input[type="range"] {
flex: 1;
accent-color: var(--violet);
}
.realism-config-root .prob-row .prob-value {
font-family: var(--font-mono);
font-size: 1rem;
color: var(--matrix);
letter-spacing: 1px;
min-width: 70px;
text-align: right;
}
.realism-config-root .footer-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}

View File

@@ -0,0 +1,264 @@
import React, { useEffect, useMemo, useState } from 'react';
import api from '../../utils/api';
import { useToast } from '../Toasts/useToast';
import { Save, RotateCcw, AlertTriangle } from '../../icons';
import { contentClassLabel, isCanaryClass } from '../../realism/labels';
// Reuse the DeckyFleet shell (page-header / btn / fleet-* / dim / mono) and
// the persona-page tweaks (info-banner, .input) so the realism config panel
// reads the same as the rest of the realism nav group.
import '../DeckyFleet.css';
import '../PersonaGeneration.css';
import './RealismConfig.css';
// ─── Types ───────────────────────────────────────────────────────────────────
interface WeightEntry {
content_class: string;
weight: number;
}
interface ConfigPayload {
user_class_weights: WeightEntry[];
system_class_weights: WeightEntry[];
canary_class_weights: WeightEntry[];
canary_probability: number;
}
const DEFAULTS: ConfigPayload = {
user_class_weights: [
{ content_class: 'note', weight: 30 },
{ content_class: 'todo', weight: 20 },
{ content_class: 'draft', weight: 15 },
{ content_class: 'script', weight: 10 },
],
system_class_weights: [
{ content_class: 'log_cron', weight: 12 },
{ content_class: 'log_daemon', weight: 8 },
{ content_class: 'cache_tmp', weight: 5 },
],
canary_class_weights: [
{ content_class: 'canary_aws_creds', weight: 1 },
{ content_class: 'canary_env_file', weight: 1 },
{ content_class: 'canary_git_config', weight: 1 },
{ content_class: 'canary_ssh_key', weight: 1 },
{ content_class: 'canary_honeydoc', weight: 1 },
{ content_class: 'canary_honeydoc_docx', weight: 1 },
{ content_class: 'canary_honeydoc_pdf', weight: 1 },
{ content_class: 'canary_mysql_dump', weight: 1 },
],
canary_probability: 0.03,
};
// ─── Subcomponent ────────────────────────────────────────────────────────────
const WeightTable: React.FC<{
title: string;
help: string;
weights: WeightEntry[];
onChange: (next: WeightEntry[]) => void;
}> = ({ title, help, weights, onChange }) => {
const total = weights.reduce((s, w) => s + Math.max(0, w.weight), 0);
return (
<>
<div className="section-head">
<span>{title}</span>
<span className="total">TOTAL {total}</span>
</div>
<div className="section-help">{help}</div>
<table className="weight-table">
<tbody>
{weights.map((w, i) => {
const canary = isCanaryClass(w.content_class);
const share =
total === 0
? '—'
: `${((Math.max(0, w.weight) / total) * 100).toFixed(1)}%`;
return (
<tr key={w.content_class}>
<td className={`cls${canary ? ' canary' : ''}`}>
<span className="cls-label">{contentClassLabel(w.content_class)}</span>
<span className="cls-enum">{w.content_class}</span>
</td>
<td className="weight">
<input
type="number"
min={0}
step={1}
className="weight-input"
value={w.weight}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
const next = weights.slice();
next[i] = {
...next[i],
weight: Number.isFinite(v) ? Math.max(0, v) : 0,
};
onChange(next);
}}
/>
</td>
<td className="share">{share}</td>
</tr>
);
})}
</tbody>
</table>
</>
);
};
// ─── Page ────────────────────────────────────────────────────────────────────
const RealismConfig: React.FC = () => {
const { push } = useToast();
const [config, setConfig] = useState<ConfigPayload>(DEFAULTS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchConfig = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<ConfigPayload>('/realism/config');
setConfig(res.data);
} catch (err: any) {
setError(err?.response?.status === 401 ? 'Authentication required.' : 'Load failed.');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchConfig(); }, []);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const res = await api.put<ConfigPayload>('/realism/config', config);
setConfig(res.data);
push({ text: 'REALISM CONFIG SAVED', tone: 'matrix', icon: 'terminal' });
} catch (err: any) {
const detail = err?.response?.data?.detail;
const status = err?.response?.status;
if (status === 403) setError('Admin role required to save.');
else if (status === 400 && detail) setError(`Validation failed: ${detail}`);
else setError('Save failed.');
} finally {
setSaving(false);
}
};
const handleReset = () => {
if (!window.confirm(
'Reset to baked-in defaults? This will overwrite the saved config on next save.',
)) return;
setConfig(DEFAULTS);
};
const totals = useMemo(() => ({
user: config.user_class_weights.reduce((s, w) => s + Math.max(0, w.weight), 0),
system: config.system_class_weights.reduce((s, w) => s + Math.max(0, w.weight), 0),
canary: config.canary_class_weights.reduce((s, w) => s + Math.max(0, w.weight), 0),
}), [config]);
return (
<div className="fleet-root realism-config-root">
<div className="page-header">
<div className="page-title-group">
<h1>REALISM CONFIG</h1>
<span className="page-sub">
USER {totals.user} · SYSTEM {totals.system} · CANARY {totals.canary} ·
{' '}CANARY PROB {(config.canary_probability * 100).toFixed(1)}%
</span>
</div>
<div className="actions">
<button
className="btn ghost"
onClick={handleReset}
disabled={saving || loading}
title="Reset form fields to baked-in defaults (does not save until you press SAVE)"
>
<RotateCcw size={12} /> RESET
</button>
<button
className="btn violet"
onClick={handleSave}
disabled={saving || loading}
title="Persist current values to realism_config; orchestrator picks them up within one refresh tick (~5 min)."
>
<Save size={12} /> {saving ? 'SAVING…' : 'SAVE'}
</button>
</div>
</div>
<div className="info-banner">
<div>
<strong>Scope:</strong> tunes the orchestrator's <em>realism planner</em>
{' '}— how often each kind of synthetic file lands on a decky, and
how rare canary plants are. Persisted in the{' '}
<span className="mono matrix-text">realism_config</span> table; the
orchestrator refreshes from the DB every ~5 minutes.
</div>
{error && (
<div className="info-line alert-text" style={{ marginTop: 8 }}>
<AlertTriangle size={12} /> {error}
</div>
)}
</div>
{loading ? (
<div className="dim" style={{ padding: '24px 0' }}>Loading</div>
) : (
<>
<WeightTable
title="User Class Weights"
help="Files written by personas during their work hours. The realism win when a persona looks busy."
weights={config.user_class_weights}
onChange={(next) => setConfig({ ...config, user_class_weights: next })}
/>
<WeightTable
title="System Class Weights"
help="Plausible OS-side filler — rotated logs, daemon noise, ephemeral cache."
weights={config.system_class_weights}
onChange={(next) => setConfig({ ...config, system_class_weights: next })}
/>
<WeightTable
title="Canary Class Weights"
help="Callback-bearing artifacts. Uniform across generators by default; raise one to bias toward a specific bait flavour."
weights={config.canary_class_weights}
onChange={(next) => setConfig({ ...config, canary_class_weights: next })}
/>
<div className="section-head">
<span>Canary Probability</span>
<span className="total">{(config.canary_probability * 100).toFixed(1)}%</span>
</div>
<div className="section-help">
Share of file picks that materialise a canary. Each plant
creates a real canary token row + DNS slug or HTTP URL
keeping this rare prevents a noisy alert surface.
</div>
<div className="prob-row">
<input
type="range"
min={0}
max={1}
step={0.005}
value={config.canary_probability}
onChange={(e) => setConfig({
...config,
canary_probability: parseFloat(e.target.value),
})}
/>
<span className="prob-value">
{(config.canary_probability * 100).toFixed(1)}%
</span>
</div>
</>
)}
</div>
);
};
export default RealismConfig;

View File

@@ -0,0 +1,323 @@
import React, { useEffect, useState } from 'react';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css';
import {
Upload, RefreshCw, RotateCcw, Package, AlertTriangle, CheckCircle,
Wifi, WifiOff, Server,
} from '../icons';
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 swarm-root">
<div className="page-header">
<div className="page-title-group">
<h1><Package size={18} /> REMOTE UPDATES</h1>
<span className="page-sub">
push updater bundles to enrolled workers · {hosts.length} WORKER{hosts.length === 1 ? '' : 'S'}
</span>
</div>
<button
onClick={() => setShowFleetModal(true)}
disabled={fleetBusy || hosts.length === 0}
className="control-btn primary"
>
{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 ? (
<EmptyState
icon={Server}
title="NO UPDATER-ENABLED WORKERS"
hint="run `decnet swarm enroll --host <name> --updater` to add one"
/>
) : (
<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;

View File

@@ -0,0 +1,241 @@
import React, { useEffect, useRef, useState } from 'react';
import { X, AlertTriangle } from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { useFocusTrap } from '../hooks/useFocusTrap';
// @ts-expect-error -- ships without type defs; 3.x CJS build is used directly
import * as AsciinemaPlayer from 'asciinema-player';
import 'asciinema-player/dist/bundle/asciinema-player.css';
interface SessionDrawerProps {
decky: string;
sid: string;
fields: Record<string, any>;
onClose: () => void;
}
interface TranscriptPage {
sid: string;
service: string;
header: Record<string, any>;
events: [number, string, string][];
offset: number;
limit: number;
total: number;
has_more: boolean;
truncated: boolean;
}
const PAGE_SIZE = 500;
const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ minWidth: '140px', color: 'var(--dim-color)', fontSize: '0.75rem', textTransform: 'uppercase' }}>{label}</div>
<div style={{ flex: 1, fontSize: '0.85rem', wordBreak: 'break-all' }}>{value ?? <span style={{ opacity: 0.4 }}></span>}</div>
</div>
);
function buildCastBlob(header: Record<string, any>, events: [number, string, string][]): string {
const headerLine = JSON.stringify({
version: 2,
width: header.width ?? 80,
height: header.height ?? 24,
timestamp: header.timestamp,
env: header.env,
});
const eventLines = events.map(([t, ch, d]) => JSON.stringify([t, ch, d]));
return [headerLine, ...eventLines].join('\n') + '\n';
}
const SessionDrawer: React.FC<SessionDrawerProps> = ({ decky, sid, fields, onClose }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [header, setHeader] = useState<Record<string, any> | null>(null);
const [events, setEvents] = useState<[number, string, string][]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [truncated, setTruncated] = useState(false);
const playerContainer = useRef<HTMLDivElement | null>(null);
const playerInstance = useRef<any>(null);
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setLoading(true);
setError(null);
let offset = 0;
let hdr: Record<string, any> | null = null;
const allEvents: [number, string, string][] = [];
let truncFlag = false;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
const res = await api.get<TranscriptPage>(
`/transcripts/${encodeURIComponent(decky)}/${encodeURIComponent(sid)}`,
{ params: { offset, limit: PAGE_SIZE } },
);
if (cancelled) return;
if (!hdr) hdr = res.data.header;
truncFlag = truncFlag || res.data.truncated;
allEvents.push(...res.data.events);
if (offset === 0) {
setHeader(hdr);
setEvents([...allEvents]);
setLoading(false);
} else {
setEvents([...allEvents]);
}
if (!res.data.has_more) break;
offset += PAGE_SIZE;
setLoadingMore(true);
}
setTruncated(truncFlag);
setLoadingMore(false);
} catch (err: any) {
if (cancelled) return;
const status = err?.response?.status;
setError(
status === 403 ? 'Admin role required to view transcripts.' :
status === 404 ? 'Transcript not found (shard may have rotated).' :
'Failed to load transcript — see console.'
);
console.error('transcript fetch failed', err);
setLoading(false);
}
};
fetchAll();
return () => { cancelled = true; };
}, [decky, sid]);
// Re-mount the player whenever the event window grows. asciinema-player
// doesn't expose a public feed() API in v3, so we rebuild from the full
// in-memory cast each time — cheap for v1-scale sessions (≤ 10 MB cap).
//
// Pass the cast as {data: ...} directly rather than a Blob URL. The
// URL path silently fails when the browser's fetch for the blob races
// the createObjectURL revoke, or when the mime-type guess trips the
// player's loader — either way the user gets a play button that does
// nothing on click. Inline data skips the whole fetch detour.
useEffect(() => {
if (!header || !playerContainer.current) return;
// Asciicast v2 ch values: "o" (output), "i" (input), "r" (resize).
// Drop anything else so a stray malformed line can't derail parsing.
const playable = events.filter(([, ch]) => ch === 'o' || ch === 'i' || ch === 'r');
if (playable.length === 0) return;
if (playerInstance.current) {
try { playerInstance.current.dispose(); } catch { /* ignore */ }
playerInstance.current = null;
}
const cast = buildCastBlob(header, playable);
try {
playerInstance.current = AsciinemaPlayer.create(
{ data: cast },
playerContainer.current,
{ fit: 'width', terminalFontSize: '12px' },
);
} catch (err) {
console.error('asciinema-player failed to mount', err);
}
return () => {
if (playerInstance.current) {
try { playerInstance.current.dispose(); } catch { /* ignore */ }
playerInstance.current = null;
}
};
}, [header, events]);
const service = fields.service;
const srcIp = fields.src_ip;
const duration = fields.duration_s;
const bytes = fields.bytes;
return (
<div
// Close only on actual backdrop clicks. The previous design put
// onClick={onClose} here + onClick={stopPropagation} on the
// panel — but React's stopPropagation also aborts the NATIVE
// event, which broke asciinema-player's click-to-play because
// the player attaches its click handler via document-level
// delegation (the event never reached it).
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'flex-end',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(920px, 100%)', height: '100%',
backgroundColor: 'var(--bg-color, #0d1117)',
borderLeft: '1px solid var(--border-color, #30363d)',
padding: '24px', overflowY: 'auto',
color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div>
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', letterSpacing: '0.1em' }}>
SESSION TRANSCRIPT · {decky}
</div>
<div style={{ fontSize: '1rem', fontWeight: 'bold', marginTop: '4px', fontFamily: 'monospace' }}>
{sid}
</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
{truncated && (
<div style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 12px', marginBottom: '16px',
border: '1px solid rgba(255, 170, 0, 0.3)',
backgroundColor: 'rgba(255, 170, 0, 0.05)',
fontSize: '0.75rem', color: '#ffaa00',
}}>
<AlertTriangle size={14} />
Session exceeded 10 MB cap playback is truncated.
</div>
)}
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '16px' }}>{error}</div>
)}
<section style={{ marginBottom: '16px' }}>
<div ref={playerContainer} style={{ background: '#000', minHeight: '340px' }} />
{loading && <div style={{ opacity: 0.5, fontSize: '0.75rem', marginTop: '8px' }}>LOADING TRANSCRIPT</div>}
{loadingMore && <div style={{ opacity: 0.5, fontSize: '0.75rem', marginTop: '8px' }}>loading more events</div>}
</section>
<section>
<h3 style={{ fontSize: '0.8rem', letterSpacing: '0.1em', color: 'var(--dim-color)', marginBottom: '8px' }}>
METADATA
</h3>
<Row label="Service" value={service} />
<Row label="Src IP" value={srcIp} />
<Row label="Duration" value={duration ? `${duration}s` : null} />
<Row label="Bytes" value={bytes ? `${bytes}` : null} />
<Row label="Events" value={events.length} />
</section>
</div>
</div>
);
};
export default SessionDrawer;

View File

@@ -0,0 +1,59 @@
.shk-body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
padding: 18px 20px 24px;
}
.shk-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.shk-title {
font-size: 0.7rem;
letter-spacing: 0.14em;
color: var(--dim-color);
margin: 0 0 4px;
text-transform: uppercase;
}
.shk-rows {
display: flex;
flex-direction: column;
gap: 4px;
}
.shk-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
font-size: 0.8rem;
}
.shk-label {
color: var(--text-color);
opacity: 0.8;
}
.shk-keys {
display: inline-flex;
gap: 4px;
}
.shk-keys kbd {
font-family: inherit;
font-size: 0.7rem;
letter-spacing: 0.05em;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
color: var(--text-color);
border-radius: 3px;
min-width: 20px;
text-align: center;
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Keyboard } from '../../icons';
import Modal from '../Modal/Modal';
import './ShortcutsHelp.css';
interface Props {
open: boolean;
onClose: () => void;
}
interface Binding {
keys: string;
label: string;
}
const GLOBAL: Binding[] = [
{ keys: 'Alt+K', label: 'Open command palette' },
{ keys: '?', label: 'Show this cheatsheet' },
{ keys: '/', label: 'Focus page search' },
{ keys: 'Esc', label: 'Close modal / palette' },
];
const NAV: Binding[] = [
{ keys: 'G D', label: 'Dashboard' },
{ keys: 'G F', label: 'Decoy Fleet' },
{ keys: 'G M', label: 'MazeNET' },
{ keys: 'G L', label: 'Live Logs' },
{ keys: 'G B', label: 'Bounty Vault' },
{ keys: 'G A', label: 'Attackers' },
{ keys: 'G S', label: 'SWARM Hosts' },
{ keys: 'G U', label: 'Remote Updates' },
{ keys: 'G E', label: 'Agent Enrollment' },
{ keys: 'G C', label: 'Config' },
];
const PALETTE: Binding[] = [
{ keys: '↑ ↓', label: 'Navigate entries' },
{ keys: '⏎', label: 'Run selected entry' },
{ keys: 'Esc', label: 'Dismiss palette' },
];
const Group: React.FC<{ title: string; rows: Binding[] }> = ({ title, rows }) => (
<section className="shk-group">
<h4 className="shk-title">{title}</h4>
<div className="shk-rows">
{rows.map(r => (
<div className="shk-row" key={r.keys}>
<span className="shk-keys">
{r.keys.split(' ').map((k, i) => (
<kbd key={i}>{k}</kbd>
))}
</span>
<span className="shk-label">{r.label}</span>
</div>
))}
</div>
</section>
);
const ShortcutsHelp: React.FC<Props> = ({ open, onClose }) => (
<Modal
open={open}
onClose={onClose}
title="KEYBOARD SHORTCUTS"
icon={Keyboard}
accent="violet"
width="wide"
>
<div className="modal-body shk-body">
<Group title="GLOBAL" rows={GLOBAL} />
<Group title="NAVIGATION (G-CHORD)" rows={NAV} />
<Group title="COMMAND PALETTE" rows={PALETTE} />
</div>
</Modal>
);
export default ShortcutsHelp;

View File

@@ -0,0 +1,179 @@
.swarm-root .page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding-bottom: 16px;
margin-bottom: 24px;
border-bottom: 1px solid var(--panel-border);
}
.swarm-root .page-title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.swarm-root .page-header h1 {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
margin: 0;
color: var(--matrix);
}
.swarm-root .page-sub {
font-size: 0.7rem;
opacity: 0.5;
letter-spacing: 1px;
}
.panel {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 20px;
margin-bottom: 20px;
}
.panel h3 {
margin-top: 0;
margin-bottom: 12px;
}
.panel h3 small {
color: var(--accent-color);
font-weight: normal;
margin-left: 8px;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
text-align: left;
font-size: 0.88rem;
}
.data-table th {
color: var(--accent-color);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.75rem;
}
.data-table code {
font-family: monospace;
font-size: 0.8rem;
}
.control-btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
transition: all 0.3s;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.control-btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.control-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.control-btn.primary {
border-color: var(--violet);
color: var(--violet);
}
.control-btn.primary:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.control-btn.danger {
border-color: var(--alert, #ff4d4d);
color: var(--alert, #ff4d4d);
}
.control-btn.danger:hover {
background: var(--alert, #ff4d4d);
color: #000;
box-shadow: 0 0 10px rgba(255, 77, 77, 0.5);
}
.error-box {
background-color: rgba(255, 77, 77, 0.08);
border: 1px solid #ff4d4d;
color: #ff4d4d;
padding: 10px 14px;
margin: 10px 0;
display: flex;
align-items: center;
gap: 8px;
}
.form-stack {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 520px;
}
.form-stack label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.88rem;
color: var(--text-color);
}
.form-stack input[type="text"],
.form-stack input[type="file"] {
background: var(--background-color);
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 10px;
font-family: inherit;
}
.form-stack label.form-inline {
flex-direction: row;
align-items: center;
gap: 8px;
}
.form-stack small {
opacity: 0.7;
font-size: 0.75rem;
}
.field-warn {
color: #ffb347;
display: flex;
align-items: center;
gap: 4px;
}
.code-block {
background: var(--background-color);
border: 1px solid var(--border-color);
padding: 12px;
overflow-x: auto;
font-family: monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-break: break-all;
}
.button-row {
display: flex;
gap: 10px;
margin: 12px 0;
}

View File

@@ -0,0 +1,510 @@
import React, { useEffect, useRef, useState } from 'react';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import Modal from './Modal/Modal';
import './Dashboard.css';
import './Swarm.css';
import './DeckyFleet.css';
import {
AlertTriangle, Check, Copy, HardDrive, PowerOff, RefreshCw, RotateCcw,
Server, Trash2, UserPlus, Wifi, WifiOff,
} from '../icons';
interface SwarmHost {
uuid: string;
name: string;
address: string;
agent_port: number;
status: string;
last_heartbeat: string | null;
client_cert_fingerprint: string;
updater_cert_fingerprint: string | null;
enrolled_at: string;
notes: string | null;
}
interface BundleResult {
token: string;
host_uuid: string;
command: string;
expires_at: string;
}
const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—');
// ─── Enrollment wizard ────────────────────────────────────────────────────
interface EnrollmentWizardProps {
open: boolean;
onClose: () => void;
onEnrolled: () => void;
}
const EnrollmentWizard: React.FC<EnrollmentWizardProps> = ({ open, onClose, onEnrolled }) => {
const [step, setStep] = useState(0);
const [masterHost, setMasterHost] = useState(window.location.hostname);
const [agentName, setAgentName] = useState('');
const [withUpdater, setWithUpdater] = useState(true);
const [useIpvlan, setUseIpvlan] = useState(false);
const [servicesIni, setServicesIni] = useState<string | null>(null);
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<BundleResult | null>(null);
const [copied, setCopied] = useState(false);
const [now, setNow] = useState<number>(Date.now());
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setStep(0);
setMasterHost(window.location.hostname);
setAgentName('');
setWithUpdater(true);
setUseIpvlan(false);
setServicesIni(null);
setServicesIniName(null);
setSubmitting(false);
setError(null);
setResult(null);
setCopied(false);
if (fileRef.current) fileRef.current.value = '';
}, [open]);
useEffect(() => {
if (!result) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, [result]);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) {
setServicesIni(null);
setServicesIniName(null);
return;
}
const reader = new FileReader();
reader.onload = () => {
setServicesIni(String(reader.result));
setServicesIniName(f.name);
};
reader.readAsText(f);
};
const nameOk = /^[a-z0-9][a-z0-9-]{0,62}$/.test(agentName);
const generate = async () => {
setSubmitting(true);
setError(null);
try {
const res = await api.post('/swarm/enroll-bundle', {
master_host: masterHost,
agent_name: agentName,
with_updater: withUpdater,
use_ipvlan: useIpvlan,
services_ini: servicesIni,
});
setResult(res.data);
onEnrolled();
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } } };
setError(e?.response?.data?.detail || 'Enrollment bundle creation failed');
} finally {
setSubmitting(false);
}
};
const copyCmd = async () => {
if (!result) return;
await navigator.clipboard.writeText(result.command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const remainingSecs = result
? Math.max(0, Math.floor((new Date(result.expires_at).getTime() - now) / 1000))
: 0;
const mm = Math.floor(remainingSecs / 60).toString().padStart(2, '0');
const ss = (remainingSecs % 60).toString().padStart(2, '0');
const canNext = step === 0 ? (nameOk && !!masterHost) : true;
return (
<Modal
open={open}
onClose={onClose}
title="ENROLL SWARM HOST"
icon={UserPlus}
accent="violet"
width="wide"
footer={
<>
<button className="btn ghost" onClick={onClose}>
{result ? 'CLOSE' : 'CANCEL'}
</button>
<div style={{ display: 'flex', gap: 8 }}>
{step > 0 && !result && (
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}> BACK</button>
)}
{step < 2 && (
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
NEXT
</button>
)}
{step === 2 && !result && (
<button
className="btn violet"
disabled={submitting || !nameOk || !masterHost}
onClick={generate}
>
{submitting ? 'GENERATING…' : 'GENERATE BUNDLE'}
</button>
)}
{result && (
<button
className="btn"
onClick={() => {
setResult(null);
setAgentName('');
setStep(0);
}}
>
<RotateCcw size={12} /> NEW BUNDLE
</button>
)}
</div>
</>
}
>
<>
<div className="wizard-steps">
{['IDENTITY', 'OPTIONS', 'BUNDLE'].map((l, i) => (
<div key={l} className={`wizard-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
{i + 1}. {l}
</div>
))}
</div>
<div className="modal-body">
{step === 0 && (
<>
<div className="type-label">Who is this worker, and how does it reach the master?</div>
<div className="tweak-group">
<label>MASTER HOST (IP or DNS this agent can reach)</label>
<input
className="input"
type="text"
value={masterHost}
onChange={(e) => setMasterHost(e.target.value)}
/>
</div>
<div className="tweak-group">
<label>AGENT NAME (lowercase, digits, dashes)</label>
<input
className="input"
type="text"
value={agentName}
onChange={(e) => setAgentName(e.target.value.toLowerCase())}
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
data-autofocus
/>
{agentName && !nameOk && (
<small className="field-warn">
<AlertTriangle size={12} /> must match ^[a-z0-9][a-z0-9-]{'{0,62}'}$
</small>
)}
</div>
</>
)}
{step === 1 && (
<>
<div className="type-label">Bundle options tune for the target environment.</div>
<div
style={{
display: 'flex', gap: 10, alignItems: 'flex-start',
padding: 14, border: '1px solid var(--border)',
}}
>
<input
id="with-updater"
type="checkbox"
checked={withUpdater}
onChange={(e) => setWithUpdater(e.target.checked)}
style={{ accentColor: 'var(--matrix)', marginTop: 2 }}
/>
<label htmlFor="with-updater" style={{ fontSize: '0.8rem', letterSpacing: 1, flex: 1 }}>
INSTALL UPDATER DAEMON
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, marginTop: 4 }}>
Lets the master push code updates to this agent.
</div>
</label>
</div>
<div
style={{
display: 'flex', gap: 10, alignItems: 'flex-start',
padding: 14, border: '1px solid var(--border)',
}}
>
<input
id="use-ipvlan"
type="checkbox"
checked={useIpvlan}
onChange={(e) => setUseIpvlan(e.target.checked)}
style={{ accentColor: 'var(--matrix)', marginTop: 2 }}
/>
<label htmlFor="use-ipvlan" style={{ fontSize: '0.8rem', letterSpacing: 1, flex: 1 }}>
USE IPVLAN INSTEAD OF MACVLAN
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, marginTop: 4 }}>
Required for VirtualBox/VMware guests bridged over Wi-Fi Wi-Fi APs bind
one MAC per station, so MACVLAN rotates the VM's lease.
</div>
</label>
</div>
<div className="tweak-group">
<label>SERVICES INI (optional)</label>
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
{servicesIniName && (
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
loaded: {servicesIniName}
</div>
)}
</div>
</>
)}
{step === 2 && (
<>
{!result ? (
<>
<div className="type-label">
Review and generate a one-shot bootstrap URL valid for 5 minutes.
</div>
<div className="code-block">
<span className="comment"># enrollment bundle preview</span>{'\n'}
<span className="key"> master_host</span>{' '}<span className="str">{masterHost}</span>{'\n'}
<span className="key"> agent_name </span>{' '}<span className="str">{agentName}</span>{'\n'}
<span className="key"> updater </span>{' '}<span className="str">{withUpdater ? 'yes' : 'no'}</span>{'\n'}
<span className="key"> ipvlan </span>{' '}<span className="str">{useIpvlan ? 'yes' : 'no'}</span>{'\n'}
<span className="key"> services </span>{' '}<span className="str">{servicesIniName ?? ''}</span>
</div>
{error && (
<div
style={{
border: '1px solid var(--alert)', color: 'var(--alert)',
padding: '8px 12px', fontSize: '0.75rem', letterSpacing: 1,
}}
>
✖ {error}
</div>
)}
</>
) : (
<>
<div className="type-label">Paste this on the new worker (as root):</div>
<div className="code-block" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{result.command}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn" onClick={copyCmd}>
{copied ? <><Check size={12} /> COPIED</> : <><Copy size={12} /> COPY</>}
</button>
</div>
<div className="dim" style={{ fontSize: '0.7rem', letterSpacing: 1 }}>
Expires in <strong>{mm}:{ss}</strong> — one-shot, single download.
Host UUID: <code>{result.host_uuid}</code>
</div>
{remainingSecs === 0 && (
<div
style={{
border: '1px solid var(--alert)', color: 'var(--alert)',
padding: '8px 12px', fontSize: '0.75rem', letterSpacing: 1,
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<AlertTriangle size={14} /> This bundle has expired. Generate another.
</div>
)}
</>
)}
</>
)}
</div>
</>
</Modal>
);
};
// ─── Swarm hosts page ─────────────────────────────────────────────────────
const SwarmHosts: React.FC = () => {
const [hosts, setHosts] = useState<SwarmHost[]>([]);
const [loading, setLoading] = useState(true);
const [decommissioning, setDecommissioning] = useState<Set<string>>(new Set());
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [showEnroll, setShowEnroll] = useState(false);
// Two-click arm/commit replaces window.confirm(). Browsers silently
// suppress confirm() after the "prevent additional dialogs" opt-out,
// which manifests as a dead button — no network request, no console
// error. Key format: "<action>:<uuid>".
const [armed, setArmed] = useState<string | null>(null);
const arm = (key: string) => {
setArmed(key);
setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000);
};
const fetchHosts = async () => {
try {
const res = await api.get('/swarm/hosts');
setHosts(res.data);
setError(null);
} catch (err: any) {
setError(err?.response?.data?.detail || 'Failed to fetch swarm hosts');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchHosts();
const t = setInterval(fetchHosts, 10000);
return () => clearInterval(t);
}, []);
const addTo = (set: Set<string>, id: string) => { const n = new Set(set); n.add(id); return n; };
const removeFrom = (set: Set<string>, id: string) => { const n = new Set(set); n.delete(id); return n; };
const handleTeardownAll = async (host: SwarmHost) => {
const key = `teardown:${host.uuid}`;
if (armed !== key) { arm(key); return; }
setArmed(null);
setTearingDown((s) => addTo(s, host.uuid));
try {
// 202 Accepted — teardown runs async on the backend.
await api.post(`/swarm/hosts/${host.uuid}/teardown`, {});
await fetchHosts();
} catch (err: any) {
alert(err?.response?.data?.detail || 'Teardown failed');
} finally {
setTearingDown((s) => removeFrom(s, host.uuid));
}
};
const handleDecommission = async (host: SwarmHost) => {
const key = `decom:${host.uuid}`;
if (armed !== key) { arm(key); return; }
setArmed(null);
setDecommissioning((s) => addTo(s, host.uuid));
try {
await api.delete(`/swarm/hosts/${host.uuid}`);
await fetchHosts();
} catch (err: any) {
alert(err?.response?.data?.detail || 'Decommission failed');
} finally {
setDecommissioning((s) => removeFrom(s, host.uuid));
}
};
const online = hosts.filter((h) => h.status === 'online').length;
return (
<div className="dashboard swarm-root">
<div className="page-header">
<div className="page-title-group">
<h1><HardDrive size={18} /> SWARM HOSTS</h1>
<span className="page-sub">
{loading ? 'LOADING' : `${hosts.length} ENROLLED · ${online} ONLINE`}
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={fetchHosts} className="control-btn" disabled={loading}>
<RefreshCw size={14} /> REFRESH
</button>
<button onClick={() => setShowEnroll(true)} className="control-btn primary">
<UserPlus size={14} /> ENROLL HOST
</button>
</div>
</div>
{error && <div className="error-box">{error}</div>}
<div className="panel">
{loading ? (
<p>Loading hosts…</p>
) : hosts.length === 0 ? (
<EmptyState
icon={Server}
title="NO SWARM HOSTS ENROLLED"
hint="onboard an agent to expand the fleet"
cta={{ label: 'ENROLL HOST', onClick: () => setShowEnroll(true) }}
/>
) : (
<table className="data-table">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>Address</th>
<th>Last heartbeat</th>
<th>Client cert</th>
<th>Enrolled</th>
<th></th>
</tr>
</thead>
<tbody>
{hosts.map((h) => (
<tr key={h.uuid}>
<td>
{h.status === 'active' ? <Wifi size={16} /> : <WifiOff size={16} />} {h.status}
</td>
<td>{h.name}</td>
<td>{h.address ? `${h.address}:${h.agent_port}` : <em>pending first connect</em>}</td>
<td>{h.last_heartbeat ? new Date(h.last_heartbeat).toLocaleString() : ''}</td>
<td title={h.client_cert_fingerprint}><code>{shortFp(h.client_cert_fingerprint)}</code></td>
<td>{new Date(h.enrolled_at).toLocaleString()}</td>
<td>
<button
className={`control-btn${armed === `teardown:${h.uuid}` ? ' danger' : ''}`}
disabled={tearingDown.has(h.uuid) || h.status !== 'active'}
onClick={() => handleTeardownAll(h)}
title="Stop all deckies on this host (keeps it enrolled)"
>
<PowerOff size={14} />{' '}
{tearingDown.has(h.uuid)
? 'Tearing down'
: armed === `teardown:${h.uuid}`
? 'Click again to confirm'
: 'Teardown all'}
</button>
<button
className="control-btn danger"
disabled={decommissioning.has(h.uuid)}
onClick={() => handleDecommission(h)}
>
<Trash2 size={14} />{' '}
{decommissioning.has(h.uuid)
? 'Decommissioning'
: armed === `decom:${h.uuid}`
? 'Click again to confirm'
: 'Decommission'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<EnrollmentWizard
open={showEnroll}
onClose={() => setShowEnroll(false)}
onEnrolled={fetchHosts}
/>
</div>
);
};
export default SwarmHosts;

View File

@@ -0,0 +1,214 @@
/* Synthetic Files — layered on DeckyFleet.css.
Adds: filter row of label+select pairs, the file table itself, the
right-side detail drawer, and a TRUNCATED chip for capped bodies. */
.synthetic-files-root .mono { font-family: var(--font-mono); }
.synthetic-files-root .info-banner {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
padding: 10px 14px;
font-size: 0.78rem;
line-height: 1.5;
}
.synthetic-files-root .info-banner em { color: var(--matrix); font-style: normal; }
/* Filter row — three label+select pairs. */
.synthetic-files-root .filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
}
.synthetic-files-root .filter-group { display: flex; flex-direction: column; gap: 4px; }
.synthetic-files-root .filter-group label {
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim);
text-transform: uppercase;
}
.synthetic-files-root select.filter-input {
background: var(--bg-elev, rgba(0, 0, 0, 0.3));
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
font-family: inherit;
font-size: 0.8rem;
outline: none;
cursor: pointer;
min-width: 180px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.synthetic-files-root select.filter-input:focus {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet);
}
/* File table. */
.synthetic-files-root .files-table-wrap {
border: 1px solid var(--border);
background: var(--panel);
overflow-x: auto;
}
.synthetic-files-root .files-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.synthetic-files-root .files-table thead tr {
border-bottom: 1px solid var(--border);
}
.synthetic-files-root .files-table th {
padding: 10px 12px;
text-align: left;
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim);
font-weight: 600;
text-transform: uppercase;
}
.synthetic-files-root .files-table tbody tr {
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
cursor: pointer;
transition: background 0.1s;
}
.synthetic-files-root .files-table tbody tr:hover {
background: var(--matrix-tint-10, rgba(0, 255, 65, 0.04));
}
.synthetic-files-root .files-table td {
padding: 8px 12px;
vertical-align: middle;
}
.synthetic-files-root .files-table td.path { font-family: var(--font-mono); word-break: break-all; }
.synthetic-files-root .files-table td.cls.canary { color: var(--amber, #f59e0b); }
.synthetic-files-root .files-table td.hash {
font-family: var(--font-mono);
color: var(--dim);
}
.synthetic-files-root .files-table td.dim-time {
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.synthetic-files-root .files-table .empty-row td {
padding: 24px;
text-align: center;
opacity: 0.5;
font-size: 0.78rem;
letter-spacing: 1px;
text-transform: uppercase;
}
/* Pagination row. */
.synthetic-files-root .pager {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.synthetic-files-root .pager .page-counter {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--dim);
letter-spacing: 1px;
}
/* Drawer — right-side detail panel. */
.synthetic-files-root .drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: flex-end;
z-index: 1000;
}
.synthetic-files-root .drawer {
width: min(720px, 100%);
height: 100%;
background: var(--bg-color, #0d1117);
border-left: 1px solid var(--border);
padding: 24px;
overflow-y: auto;
color: var(--text);
}
.synthetic-files-root .drawer-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
gap: 16px;
}
.synthetic-files-root .drawer-eyebrow {
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim);
text-transform: uppercase;
}
.synthetic-files-root .drawer-title {
font-family: var(--font-mono);
font-size: 0.95rem;
font-weight: 700;
margin-top: 4px;
word-break: break-all;
color: var(--matrix);
}
.synthetic-files-root .drawer-close {
background: none;
border: none;
color: var(--text);
cursor: pointer;
padding: 0;
}
/* Drawer meta grid — label / value rows. */
.synthetic-files-root .meta-grid {
display: grid;
grid-template-columns: 140px 1fr;
row-gap: 6px;
font-size: 0.85rem;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.synthetic-files-root .meta-grid .label {
color: var(--dim);
font-size: 0.62rem;
letter-spacing: 1.5px;
text-transform: uppercase;
align-self: center;
}
.synthetic-files-root .meta-grid .value-canary { color: var(--amber, #f59e0b); }
/* Body preview block. */
.synthetic-files-root .body-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim);
text-transform: uppercase;
}
.synthetic-files-root .body-pre {
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
padding: 12px;
font-size: 0.78rem;
max-height: 60vh;
overflow-y: auto;
}
.synthetic-files-root .truncated-chip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border: 1px solid var(--amber, #f59e0b);
color: var(--amber, #f59e0b);
font-size: 0.6rem;
letter-spacing: 1.5px;
text-transform: uppercase;
}

View File

@@ -0,0 +1,376 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import api from '../../utils/api';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { X } from '../../icons';
import { contentClassLabel, isCanaryClass } from '../../realism/labels';
// Reuse the DeckyFleet shell + the persona-page tweaks so this page reads
// the same as the rest of the realism nav group.
import '../DeckyFleet.css';
import '../PersonaGeneration.css';
import './SyntheticFiles.css';
// ─── Types ───────────────────────────────────────────────────────────────────
interface SyntheticFileRow {
uuid: string;
decky_uuid: string;
path: string;
persona: string;
content_class: string;
created_at: string;
last_modified: string;
edit_count: number;
content_hash: string;
}
interface SyntheticFileDetail extends SyntheticFileRow {
last_body: string;
truncated: boolean;
}
interface PaginatedResponse {
total: number;
limit: number;
offset: number;
data: SyntheticFileRow[];
}
interface DeckyOption {
uuid: string;
name: string;
}
const PAGE_SIZE = 50;
// Fixed list of content_class values mirroring decnet/realism/taxonomy.py.
// A static dropdown beats free-text — the operator sees what's actually
// available without a typo path failing silently.
const CONTENT_CLASSES = [
'note', 'todo', 'draft', 'script',
'log_cron', 'log_daemon', 'cache_tmp',
'canary_aws_creds', 'canary_env_file', 'canary_git_config',
'canary_ssh_key', 'canary_honeydoc', 'canary_honeydoc_docx',
'canary_honeydoc_pdf', 'canary_mysql_dump',
] as const;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmt(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function deckyLabel(uuid: string, deckies: DeckyOption[]): string {
const d = deckies.find((d) => d.uuid === uuid);
return d ? d.name : `${uuid.slice(0, 8)}`;
}
// ─── Drawer ──────────────────────────────────────────────────────────────────
interface DrawerProps {
uuid: string;
deckies: DeckyOption[];
onClose: () => void;
}
const SyntheticFileDrawer: React.FC<DrawerProps> = ({ uuid, deckies, onClose }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const [row, setRow] = useState<SyntheticFileDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
api.get<SyntheticFileDetail>(`/realism/synthetic-files/${encodeURIComponent(uuid)}`)
.then((res) => { if (!cancelled) setRow(res.data); })
.catch((err: any) => {
if (cancelled) return;
setError(err?.response?.status === 404 ? 'File no longer exists.' : 'Load failed.');
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [uuid]);
const canary = row ? isCanaryClass(row.content_class) : false;
return (
<div
className="drawer-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div ref={panelRef} role="dialog" aria-modal="true" className="drawer">
<div className="drawer-head">
<div>
<div className="drawer-eyebrow">
SYNTHETIC FILE{row ? ` · ${deckyLabel(row.decky_uuid, deckies)}` : ''}
</div>
<div className="drawer-title">{row?.path ?? uuid}</div>
</div>
<button onClick={onClose} aria-label="Close" className="drawer-close">
<X size={18} />
</button>
</div>
{loading && <div className="dim">Loading</div>}
{error && <div className="alert-text">{error}</div>}
{row && (
<>
<div className="meta-grid">
<div className="label">Persona</div>
<div>{row.persona}</div>
<div className="label">Content Class</div>
<div>
<span className={canary ? 'value-canary' : ''}>
{contentClassLabel(row.content_class)}
</span>
<span className="mono dim" style={{ marginLeft: 8, fontSize: '0.75rem' }}>
{row.content_class}
</span>
</div>
<div className="label">Edit Count</div>
<div className="mono">{row.edit_count}</div>
<div className="label">Created</div>
<div className="mono dim">{fmt(row.created_at)}</div>
<div className="label">Last Modified</div>
<div className="mono">{fmt(row.last_modified)}</div>
<div className="label">Content Hash</div>
<div className="mono dim" style={{ wordBreak: 'break-all' }}>
{row.content_hash}
</div>
</div>
<div className="body-head">
<span>BODY PREVIEW · {(row.last_body?.length ?? 0).toLocaleString()} BYTES</span>
{row.truncated && (
<span
className="truncated-chip"
title="Body is at the 64KB cap; the decky filesystem holds the canonical bytes."
>
TRUNCATED
</span>
)}
</div>
<pre className="body-pre">
{row.last_body || <span className="dim"></span>}
</pre>
</>
)}
</div>
</div>
);
};
// ─── Page ────────────────────────────────────────────────────────────────────
const SyntheticFiles: React.FC = () => {
const [rows, setRows] = useState<SyntheticFileRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deckies, setDeckies] = useState<DeckyOption[]>([]);
const [deckyFilter, setDeckyFilter] = useState<string>(''); // '' = all
const [personaFilter, setPersonaFilter] = useState<string>('');
const [classFilter, setClassFilter] = useState<string>('');
const [selectedUuid, setSelectedUuid] = useState<string | null>(null);
useEffect(() => {
api.get<DeckyOption[]>('/deckies')
.then((res) => setDeckies(Array.isArray(res.data) ? res.data : []))
.catch(() => setDeckies([]));
}, []);
const personaOptions = useMemo(() => {
const set = new Set<string>();
rows.forEach((r) => set.add(r.persona));
return Array.from(set).sort();
}, [rows]);
const fetchRows = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(page * PAGE_SIZE));
if (deckyFilter) params.set('decky_uuid', deckyFilter);
if (personaFilter) params.set('persona', personaFilter);
if (classFilter) params.set('content_class', classFilter);
const res = await api.get<PaginatedResponse>(
`/realism/synthetic-files?${params.toString()}`,
);
setRows(res.data.data);
setTotal(res.data.total);
} catch (err: any) {
setError(err?.response?.status === 401 ? 'Authentication required.' : 'Load failed.');
} finally {
setLoading(false);
}
};
useEffect(() => { fetchRows(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [
page, deckyFilter, personaFilter, classFilter,
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const filtersActive = !!(deckyFilter || personaFilter || classFilter);
return (
<div className="fleet-root synthetic-files-root">
<div className="page-header">
<div className="page-title-group">
<h1>SYNTHETIC FILES</h1>
<span className="page-sub">
{total} TOTAL · PAGE {page + 1} / {totalPages}
{filtersActive ? ' · FILTERED' : ''}
</span>
</div>
<div className="actions filters">
<div className="filter-group">
<label>Decky</label>
<select
className="filter-input"
value={deckyFilter}
onChange={(e) => { setDeckyFilter(e.target.value); setPage(0); }}
>
<option value="">All</option>
{deckies.map((d) => (
<option key={d.uuid} value={d.uuid}>{d.name}</option>
))}
</select>
</div>
<div className="filter-group">
<label>Persona</label>
<select
className="filter-input"
value={personaFilter}
onChange={(e) => { setPersonaFilter(e.target.value); setPage(0); }}
>
<option value="">All</option>
{personaOptions.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div className="filter-group">
<label>Content Class</label>
<select
className="filter-input"
value={classFilter}
onChange={(e) => { setClassFilter(e.target.value); setPage(0); }}
>
<option value="">All</option>
{CONTENT_CLASSES.map((c) => (
<option key={c} value={c}>{contentClassLabel(c)}</option>
))}
</select>
</div>
</div>
</div>
<div className="info-banner">
<div>
<strong>Scope:</strong> read-only inventory of files the realism
worker has grown across the fleet. The orchestrator is the sole
writer; rows persist in the{' '}
<span className="mono matrix-text">synthetic_files</span> table.
Click any row for the body preview and lineage detail.
</div>
{error && (
<div className="info-line alert-text" style={{ marginTop: 8 }}>{error}</div>
)}
</div>
<div className="files-table-wrap">
<table className="files-table">
<thead>
<tr>
<th>Decky</th>
<th>Path</th>
<th>Persona</th>
<th>Class</th>
<th>Last Modified</th>
<th>Edits</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
{loading && (
<tr className="empty-row"><td colSpan={7}>Loading</td></tr>
)}
{!loading && rows.length === 0 && (
<tr className="empty-row"><td colSpan={7}>
No files match the current filters.
</td></tr>
)}
{!loading && rows.map((r) => {
const canary = isCanaryClass(r.content_class);
return (
<tr key={r.uuid} onClick={() => setSelectedUuid(r.uuid)}>
<td>{deckyLabel(r.decky_uuid, deckies)}</td>
<td className="path">{r.path}</td>
<td>{r.persona}</td>
<td className={`cls${canary ? ' canary' : ''}`}>
{contentClassLabel(r.content_class)}
</td>
<td className="dim-time">{fmt(r.last_modified)}</td>
<td className="mono">{r.edit_count}</td>
<td className="hash">{r.content_hash.slice(0, 12)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="pager">
<button
className="btn ghost small"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
PREV
</button>
<span className="page-counter">PAGE {page + 1} / {totalPages}</span>
<button
className="btn ghost small"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
NEXT
</button>
</div>
{selectedUuid && (
<SyntheticFileDrawer
uuid={selectedUuid}
deckies={deckies}
onClose={() => setSelectedUuid(null)}
/>
)}
</div>
);
};
export default SyntheticFiles;

View File

@@ -0,0 +1,33 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Toasts from './Toasts';
import { ToastContext } from './toast-context';
import type { Toast, ToastInput } from './toast-context';
const DISMISS_MS = 3200;
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [items, setItems] = useState<Toast[]>([]);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const push = useCallback((t: ToastInput) => {
const id = Date.now() + Math.random();
setItems(prev => [...prev, { ...t, id }]);
const timer = setTimeout(() => {
setItems(prev => prev.filter(x => x.id !== id));
timers.current.delete(id);
}, DISMISS_MS);
timers.current.set(id, timer);
}, []);
useEffect(() => {
const map = timers.current;
return () => { map.forEach(clearTimeout); map.clear(); };
}, []);
return (
<ToastContext.Provider value={{ push }}>
{children}
<Toasts items={items} />
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,33 @@
.toast-stack {
position: fixed;
bottom: 24px;
left: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 70;
pointer-events: none;
}
.toast {
background: var(--panel);
border: 1px solid var(--matrix);
padding: 10px 14px;
font-size: 0.72rem;
letter-spacing: 1px;
min-width: 260px;
display: flex;
gap: 10px;
align-items: center;
color: var(--matrix);
animation: toast-in 0.25s var(--ease);
}
.toast.matrix { border-color: var(--matrix); color: var(--matrix); }
.toast.violet { border-color: var(--violet); color: var(--violet); }
.toast.alert { border-color: var(--alert); color: var(--alert); }
@keyframes toast-in {
from { transform: translateX(-20px); opacity: 0; }
to { transform: none; opacity: 1; }
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import {
CheckCircle, RefreshCw, Download, Upload, Pause, Play, AlertTriangle,
Info, Terminal, Activity, ShieldAlert,
} from '../../icons';
import type { Toast } from './toast-context';
import './Toasts.css';
const ICON_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
'check-circle': CheckCircle,
'refresh-cw': RefreshCw,
'download': Download,
'upload': Upload,
'pause': Pause,
'play': Play,
'alert-triangle': AlertTriangle,
'info': Info,
'terminal': Terminal,
'activity': Activity,
'shield-alert': ShieldAlert,
};
interface Props {
items: Toast[];
}
const Toasts: React.FC<Props> = ({ items }) => {
if (items.length === 0) return null;
return (
<div className="toast-stack">
{items.map(t => {
const Icon = ICON_MAP[t.icon ?? 'check-circle'] ?? CheckCircle;
return (
<div key={t.id} className={`toast ${t.tone ?? ''}`.trim()}>
<Icon size={14} />
<span>{t.text}</span>
</div>
);
})}
</div>
);
};
export default Toasts;

View File

@@ -0,0 +1,19 @@
import { createContext } from 'react';
export type ToastTone = 'matrix' | 'violet' | 'alert';
export interface ToastInput {
text: string;
icon?: string;
tone?: ToastTone;
}
export interface Toast extends ToastInput {
id: number;
}
export interface ToastContextValue {
push: (t: ToastInput) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);

View File

@@ -0,0 +1,9 @@
import { useContext } from 'react';
import { ToastContext } from './toast-context';
import type { ToastContextValue } from './toast-context';
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx;
}

View File

@@ -0,0 +1,241 @@
/* Mirrors the design-handoff DeployWizard: backdrop + bordered modal,
* wizard-step tabs, two-column card picker, matrix/violet palette.
* Scoped with .ctw- prefix so nothing leaks into other views. */
.ctw-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(1px);
}
.ctw-modal {
width: 880px;
max-width: 96vw;
max-height: 90vh;
background: var(--panel);
border: 1px solid var(--violet);
box-shadow: 0 0 30px rgba(238, 130, 238, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--font-mono);
color: var(--text-color);
}
.ctw-head {
padding: 14px 22px;
border-bottom: 1px solid var(--border, var(--panel-border));
display: flex;
justify-content: space-between;
align-items: center;
}
.ctw-head h3 {
margin: 0;
font-size: 0.82rem;
letter-spacing: 3px;
display: inline-flex;
align-items: center;
}
.ctw-close {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
}
.ctw-close:hover { color: var(--violet); }
.ctw-steps {
display: flex;
border-bottom: 1px solid var(--border, var(--panel-border));
}
.ctw-step {
flex: 1;
padding: 12px 14px;
font-size: 0.65rem;
letter-spacing: 1.5px;
opacity: 0.4;
border-bottom: 2px solid transparent;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.ctw-step.active {
opacity: 1;
border-bottom-color: var(--violet);
color: var(--violet);
}
.ctw-step.done { opacity: 0.8; color: var(--matrix, #33ff66); }
.ctw-body {
padding: 20px 22px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.ctw-label {
font-size: 0.75rem;
letter-spacing: 1.5px;
color: var(--dim-color);
}
.ctw-violet { color: var(--violet); }
.ctw-dim { color: var(--dim-color); }
.ctw-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.ctw-grid-3 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
}
.ctw-card {
padding: 14px;
border: 1px solid var(--border, var(--panel-border));
background: var(--panel);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color 0.15s, background 0.15s;
}
.ctw-card:hover { border-color: var(--violet); }
.ctw-card.selected {
border-color: var(--violet);
background: var(--violet-tint-10, rgba(238, 130, 238, 0.1));
}
.ctw-card.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ctw-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.ctw-card-name {
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 1px;
}
.ctw-card-sub {
font-size: 0.62rem;
letter-spacing: 1px;
color: var(--dim-color);
text-transform: uppercase;
}
.ctw-card-desc {
font-size: 0.72rem;
line-height: 1.4;
color: var(--text-color);
opacity: 0.85;
}
.ctw-card-note {
grid-column: 1 / -1;
padding: 14px;
border: 1px dashed var(--border, var(--panel-border));
color: var(--dim-color);
font-size: 0.72rem;
letter-spacing: 1px;
}
.ctw-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.ctw-field label {
font-size: 0.62rem;
letter-spacing: 1.5px;
color: var(--dim-color);
}
.ctw-field input[type='text'] {
padding: 8px 10px;
background: #000;
border: 1px solid var(--border, var(--panel-border));
color: var(--text-color);
font-family: var(--font-mono);
font-size: 0.8rem;
}
.ctw-field input[type='text']:focus {
outline: none;
border-color: var(--violet);
}
.ctw-field input[type='range'] {
accent-color: var(--violet);
}
.ctw-note {
padding: 10px 12px;
border: 1px dashed var(--violet);
color: var(--text-color);
font-size: 0.7rem;
line-height: 1.5;
letter-spacing: 0.3px;
background: var(--violet-tint-10, rgba(238, 130, 238, 0.08));
}
.ctw-note strong {
color: var(--violet);
letter-spacing: 1px;
margin-right: 6px;
}
.ctw-note code {
background: #000;
padding: 1px 5px;
font-family: var(--font-mono);
color: var(--matrix, #33ff66);
}
.ctw-error {
padding: 10px 12px;
border: 1px solid var(--alert, #e74c3c);
color: var(--alert, #e74c3c);
font-size: 0.72rem;
letter-spacing: 0.5px;
}
.ctw-foot {
padding: 14px 22px;
border-top: 1px solid var(--border, var(--panel-border));
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.ctw-foot-right {
display: flex;
gap: 8px;
}
.ctw-btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--violet);
color: var(--violet);
padding: 7px 14px;
transition: all 0.3s;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.ctw-btn:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.ctw-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.ctw-btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.ctw-btn.ghost:hover {
background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow);
}

View File

@@ -0,0 +1,385 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { X, Server, Cpu, FileText, Sparkles, Check } from '../../icons';
import api from '../../utils/api';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import './CreateTopologyWizard.css';
/* Shape of GET /swarm/hosts rows (mirrors SwarmHostView). */
interface SwarmHost {
uuid: string;
name: string;
address: string;
agent_port: number;
status: string;
last_heartbeat: string | null;
}
interface TopologySummary {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
needs_resync?: boolean;
created_at: string;
status_changed_at: string | null;
}
type Kind = 'blank' | 'seeded';
interface Props {
open: boolean;
onClose: () => void;
onCreated: (row: TopologySummary) => void;
}
const LOCAL_CARD_ID = '__local__';
const CreateTopologyWizard: React.FC<Props> = ({ open, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, open);
useFocusTrap(panelRef, open);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [open]);
const [step, setStep] = useState<0 | 1>(0);
const [targetId, setTargetId] = useState<string | null>(null); // LOCAL_CARD_ID or host uuid
const [kind, setKind] = useState<Kind | null>(null);
const [name, setName] = useState('');
const [depth, setDepth] = useState(2);
const [branchingFactor, setBranchingFactor] = useState(2);
const [minDeckies, setMinDeckies] = useState(1);
const [maxDeckies, setMaxDeckies] = useState(3);
const [seed, setSeed] = useState<string>('');
const [hosts, setHosts] = useState<SwarmHost[]>([]);
const [hostsLoaded, setHostsLoaded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
/* Reset state whenever the modal opens so a cancelled run doesn't
* leak into the next attempt. */
useEffect(() => {
if (!open) return;
setStep(0);
setTargetId(null);
setKind(null);
setName('');
setDepth(2);
setBranchingFactor(2);
setMinDeckies(1);
setMaxDeckies(3);
setSeed('');
setErr(null);
setSubmitting(false);
}, [open]);
const fetchHosts = useCallback(async () => {
try {
const { data } = await api.get<SwarmHost[]>('/swarm/hosts');
setHosts(data ?? []);
} catch (e) {
/* Non-fatal: the user can still pick LOCAL. */
setHosts([]);
} finally {
setHostsLoaded(true);
}
}, []);
useEffect(() => {
if (open) fetchHosts();
}, [open, fetchHosts]);
const selectedHost = useMemo(
() => (targetId && targetId !== LOCAL_CARD_ID ? hosts.find((h) => h.uuid === targetId) ?? null : null),
[targetId, hosts],
);
const canNext = step === 0 ? !!targetId : !!kind && name.trim().length > 0;
const handleCreate = async () => {
if (!targetId || !kind) return;
setSubmitting(true);
setErr(null);
const isAgent = targetId !== LOCAL_CARD_ID;
const targetHostUuid = isAgent ? targetId : null;
const mode = isAgent ? 'agent' : 'unihost';
try {
if (kind === 'blank') {
const { data } = await api.post<TopologySummary>('/topologies/blank', {
name: name.trim(),
mode,
target_host_uuid: targetHostUuid,
});
onCreated(data);
} else {
const body: Record<string, unknown> = {
name: name.trim(),
mode,
target_host_uuid: targetHostUuid,
depth,
branching_factor: branchingFactor,
deckies_per_lan_min: minDeckies,
deckies_per_lan_max: maxDeckies,
randomize_services: true,
};
const parsedSeed = seed.trim();
if (parsedSeed !== '') {
const n = Number(parsedSeed);
if (Number.isFinite(n) && n >= 0) body.seed = Math.floor(n);
}
const { data } = await api.post<TopologySummary>('/topologies/', body);
onCreated(data);
}
} catch (e) {
const msg =
// axios response shape
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((e as any)?.response?.data?.detail as string | undefined) ??
(e as Error)?.message ??
'create failed';
setErr(msg);
} finally {
setSubmitting(false);
}
};
if (!open) return null;
/* The two cards in step-0 grid: LOCAL first, then each enrolled agent. */
const step0Cards = (
<>
<div
onClick={() => setTargetId(LOCAL_CARD_ID)}
className={`ctw-card ${targetId === LOCAL_CARD_ID ? 'selected' : ''}`}
>
<div className="ctw-card-head">
<Cpu size={16} className="ctw-violet" />
<span className="ctw-card-name">RUN LOCALLY</span>
</div>
<div className="ctw-card-sub">master</div>
<div className="ctw-card-desc">Topology materialises on this master host via the local docker daemon.</div>
</div>
{hosts.map((h) => {
const routable = h.status === 'active' || h.status === 'enrolled';
return (
<div
key={h.uuid}
onClick={() => routable && setTargetId(h.uuid)}
className={`ctw-card ${targetId === h.uuid ? 'selected' : ''} ${routable ? '' : 'disabled'}`}
title={routable ? undefined : `host is ${h.status}`}
>
<div className="ctw-card-head">
<Server size={16} className="ctw-violet" />
<span className="ctw-card-name">{h.name}</span>
</div>
<div className="ctw-card-sub">
{h.address}:{h.agent_port} · {h.status}
</div>
<div className="ctw-card-desc">
Topology pushed over mTLS to this swarm worker.
{h.last_heartbeat && (
<>
<br />
<span className="ctw-dim">last seen {new Date(h.last_heartbeat).toLocaleTimeString()}</span>
</>
)}
</div>
</div>
);
})}
{hostsLoaded && hosts.length === 0 && (
<div className="ctw-card-note">
No agents enrolled yet. Only local deployment is available.
</div>
)}
</>
);
const step1Cards = (
<>
<div
onClick={() => setKind('blank')}
className={`ctw-card ${kind === 'blank' ? 'selected' : ''}`}
>
<div className="ctw-card-head">
<FileText size={16} className="ctw-violet" />
<span className="ctw-card-name">BLANK</span>
</div>
<div className="ctw-card-sub">start from scratch</div>
<div className="ctw-card-desc">
Creates an empty topology with a single DMZ LAN and its gateway decky. Build out the rest in the editor.
</div>
</div>
<div
onClick={() => setKind('seeded')}
className={`ctw-card ${kind === 'seeded' ? 'selected' : ''}`}
>
<div className="ctw-card-head">
<Sparkles size={16} className="ctw-violet" />
<span className="ctw-card-name">SEED-BASED</span>
</div>
<div className="ctw-card-sub">procedurally generated</div>
<div className="ctw-card-desc">
Runs the MazeNET generator with depth/branching/deckies parameters. Seed is optional omit for a fresh roll.
</div>
</div>
</>
);
const targetLabel =
targetId === LOCAL_CARD_ID ? 'RUN LOCALLY' : selectedHost ? selectedHost.name : '—';
return (
<div className="ctw-backdrop" onClick={onClose}>
<div className="ctw-modal" ref={panelRef} role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="ctw-head">
<h3>
<Sparkles size={14} style={{ marginRight: 8 }} /> NEW TOPOLOGY
</h3>
<button className="ctw-close" onClick={onClose} aria-label="close">
<X size={16} />
</button>
</div>
<div className="ctw-steps">
{['TARGET', 'TYPE'].map((label, i) => (
<div
key={label}
className={`ctw-step ${i === step ? 'active' : i < step ? 'done' : ''}`}
>
{i + 1}. {label}
{i < step && <Check size={11} style={{ marginLeft: 6 }} />}
</div>
))}
</div>
<div className="ctw-body">
{step === 0 && (
<>
<div className="ctw-label">Where should this topology run?</div>
<div className="ctw-grid-3">{step0Cards}</div>
<div className="ctw-note">
<strong>HEADS UP:</strong> the gateway decky publishes its
service ports on the target host (e.g. <code>0.0.0.0:22</code>{' '}
for SSH). Move any host-side daemons off collision ports
BEFORE deploying otherwise docker will fail with{' '}
<code>address already in use</code>. On a fresh VPS this
usually means relocating sshd to <code>2222</code>.
</div>
</>
)}
{step === 1 && (
<>
<div className="ctw-label">
Target: <span className="ctw-violet">{targetLabel}</span> · pick a starting point.
</div>
<div className="ctw-grid-2">{step1Cards}</div>
<div className="ctw-field">
<label>NAME</label>
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. honeynet-dev"
maxLength={64}
/>
</div>
{kind === 'seeded' && (
<div className="ctw-grid-2">
<div className="ctw-field">
<label>DEPTH ({depth})</label>
<input type="range" min={1} max={6} value={depth} onChange={(e) => setDepth(+e.target.value)} />
</div>
<div className="ctw-field">
<label>BRANCHING ({branchingFactor})</label>
<input
type="range"
min={1}
max={4}
value={branchingFactor}
onChange={(e) => setBranchingFactor(+e.target.value)}
/>
</div>
<div className="ctw-field">
<label>DECKIES / LAN MIN ({minDeckies})</label>
<input
type="range"
min={0}
max={8}
value={minDeckies}
onChange={(e) => {
const v = +e.target.value;
setMinDeckies(v);
if (v > maxDeckies) setMaxDeckies(v);
}}
/>
</div>
<div className="ctw-field">
<label>DECKIES / LAN MAX ({maxDeckies})</label>
<input
type="range"
min={Math.max(1, minDeckies)}
max={12}
value={maxDeckies}
onChange={(e) => setMaxDeckies(+e.target.value)}
/>
</div>
<div className="ctw-field" style={{ gridColumn: '1 / -1' }}>
<label>SEED (optional, integer)</label>
<input
type="text"
value={seed}
onChange={(e) => setSeed(e.target.value)}
placeholder="leave blank for random"
/>
</div>
</div>
)}
</>
)}
{err && <div className="ctw-error">{err}</div>}
</div>
<div className="ctw-foot">
<button className="ctw-btn ghost" onClick={onClose}>
CANCEL
</button>
<div className="ctw-foot-right">
{step > 0 && !submitting && (
<button className="ctw-btn ghost" onClick={() => setStep(0)}>
BACK
</button>
)}
{step === 0 && (
<button className="ctw-btn" disabled={!canNext} onClick={() => setStep(1)}>
NEXT
</button>
)}
{step === 1 && (
<button className="ctw-btn" disabled={!canNext || submitting} onClick={handleCreate}>
{submitting ? 'CREATING…' : 'CREATE'}
</button>
)}
</div>
</div>
</div>
</div>
);
};
export default CreateTopologyWizard;

View File

@@ -0,0 +1,122 @@
.tlist-page {
color: var(--text-color);
font-family: var(--font-mono);
display: flex;
flex-direction: column;
gap: 24px;
min-height: calc(100vh - 128px);
}
.tlist-root .page-header { gap: 24px; }
.tlist-root .page-title-group { display: flex; flex-direction: column; gap: 6px; }
.tlist-root .page-header h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
margin: 0;
color: var(--matrix);
}
.tlist-root .page-sub { font-size: 0.7rem; opacity: 0.5; letter-spacing: 1px; }
.tlist-actions { display: flex; gap: 8px; align-items: center; }
.tlist-empty-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.tlist-empty-wrap .empty-state {
min-height: 0;
padding: 60px 24px;
gap: 14px;
}
.tlist-empty-wrap .empty-state-icon { width: 48px; height: 48px; }
.tlist-empty-wrap .empty-state-title { font-size: 1rem; letter-spacing: 3px; }
.tlist-empty-wrap .empty-state-hint { font-size: 0.75rem; letter-spacing: 1.5px; }
.tlist-btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--violet);
color: var(--violet);
padding: 7px 14px;
transition: all 0.3s;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.tlist-btn:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.tlist-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.tlist-btn.ghost { border-color: var(--matrix); color: var(--matrix); }
.tlist-btn.ghost:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); }
.tlist-btn.small { padding: 4px 10px; font-size: 0.68rem; }
.tlist-btn.danger { border-color: var(--alert, #e74c3c); color: var(--alert, #e74c3c); }
.tlist-btn.danger:hover { background: var(--alert, #e74c3c); color: #000; box-shadow: 0 0 10px rgba(231, 76, 60, 0.5); }
.tlist-btn.danger.armed { background: var(--alert, #e74c3c); color: #000; }
.tlist-btn.warn { border-color: var(--warn, #e0a040); color: var(--warn, #e0a040); }
.tlist-btn.warn:hover { background: var(--warn, #e0a040); color: #000; box-shadow: 0 0 10px rgba(224, 160, 64, 0.5); }
.tlist-btn.warn.armed { background: var(--warn, #e0a040); color: #000; }
.tlist-create-row {
display: flex; gap: 8px; margin-bottom: 12px;
}
.tlist-create-row input {
flex: 1; padding: 6px 10px; font-family: var(--font-mono); font-size: 12px;
background: var(--panel); color: var(--text-color);
border: 1px solid var(--panel-border);
}
.tlist-create-row input:focus { outline: none; border-color: var(--violet); }
.tlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.tlist-card {
border: 1px solid var(--panel-border);
background: var(--panel);
padding: 12px;
cursor: pointer;
transition: border-color 0.15s;
}
.tlist-card:hover { border-color: var(--violet); }
.tlist-card-top {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px;
}
.tlist-card-name { font-weight: 700; flex: 1; font-size: 13px; }
.tlist-card-meta {
display: flex; gap: 12px; flex-wrap: wrap;
font-size: 10px; color: var(--dim-color);
margin-bottom: 4px;
}
.tlist-card-id {
font-size: 9px; color: var(--dim-color);
margin-bottom: 10px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tlist-card-actions {
display: flex; gap: 6px; justify-content: flex-end;
}
.tlist-pill {
padding: 2px 8px; font-size: 10px; letter-spacing: 1px;
text-transform: uppercase;
border: 1px solid currentColor;
}
.tlist-pill.pill-ok { color: var(--matrix, #33ff66); }
.tlist-pill.pill-warn { color: #f39c12; }
.tlist-pill.pill-bad { color: var(--alert, #e74c3c); }
.tlist-pill.pill-dim { color: var(--dim-color); }
.tlist-empty {
grid-column: 1 / -1;
padding: 40px;
text-align: center;
color: var(--dim-color);
border: 1px dashed var(--panel-border);
}

View File

@@ -0,0 +1,276 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw, Skull, Server, Cpu, Mail } from '../../icons';
import api from '../../utils/api';
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
import { clearLayout } from '../MazeNET/useMazeLayoutStore';
import CreateTopologyWizard from './CreateTopologyWizard';
import EmptyState from '../EmptyState/EmptyState';
import './TopologyList.css';
interface TopologySummary {
id: string;
name: string;
mode: string;
target_host_uuid: string | null;
status: string;
version: number;
needs_resync?: boolean;
created_at: string;
status_changed_at: string | null;
}
interface ListResponse {
total: number;
limit: number | null;
offset: number | null;
data: TopologySummary[];
}
const statusClass = (s: string): string => {
switch (s) {
case 'active': return 'pill-ok';
case 'pending': return 'pill-dim';
case 'deploying':
case 'tearing_down': return 'pill-warn';
case 'degraded': return 'pill-warn';
case 'failed':
case 'teardown_failed': return 'pill-bad';
case 'torn_down': return 'pill-dim';
default: return 'pill-dim';
}
};
const TopologyList: React.FC = () => {
const navigate = useNavigate();
const { byUuid: hostsByUuid } = useSwarmHosts();
const [rows, setRows] = useState<TopologySummary[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [armed, setArmed] = useState<string | null>(null);
const [reaping, setReaping] = useState(false);
const [reapMsg, setReapMsg] = useState<string | null>(null);
const arm = (key: string) => {
setArmed(key);
setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000);
};
const fetchRows = useCallback(async () => {
try {
const { data } = await api.get<ListResponse>('/topologies/');
setRows(data.data ?? []);
setErr(null);
} catch (e) {
setErr((e as Error)?.message ?? 'failed to list topologies');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
let cancelled = false;
const tick = async () => { if (!cancelled) await fetchRows(); };
tick();
const iv = setInterval(tick, 5000);
return () => { cancelled = true; clearInterval(iv); };
}, [fetchRows]);
const onCreated = (row: TopologySummary) => {
setCreating(false);
navigate(`/mazenet?topology=${row.id}`);
};
const onDelete = async (id: string) => {
setBusy(id);
try {
await api.delete(`/topologies/${id}`);
clearLayout(id);
await fetchRows();
} catch (e) {
setErr((e as Error)?.message ?? 'delete failed');
} finally {
setBusy(null);
setArmed(null);
}
};
const onReapOrphans = async () => {
setReaping(true);
setReapMsg(null);
try {
const { data } = await api.post<{
orphan_prefixes: string[];
containers_removed: string[];
networks_removed: string[];
errors: string[];
}>('/topologies/reap-orphans', {});
const c = data.containers_removed.length;
const n = data.networks_removed.length;
const e = data.errors.length;
if (c === 0 && n === 0 && e === 0) {
setReapMsg('no orphans found');
} else {
setReapMsg(`removed ${c} container(s), ${n} network(s)${e ? `, ${e} error(s)` : ''}`);
}
await fetchRows();
} catch (e) {
setReapMsg((e as Error)?.message ?? 'reap failed');
} finally {
setReaping(false);
setArmed(null);
setTimeout(() => setReapMsg(null), 6000);
}
};
const onDeploy = async (id: string) => {
setBusy(id);
try {
await api.post(`/topologies/${id}/deploy`, {});
await fetchRows();
} catch (e) {
setErr((e as Error)?.message ?? 'deploy failed');
} finally {
setBusy(null);
}
};
const onTeardown = async (id: string) => {
setBusy(id);
try {
await api.post(`/topologies/${id}/teardown`, {});
await fetchRows();
} catch (e) {
setErr((e as Error)?.message ?? 'teardown failed');
} finally {
setBusy(null);
setArmed(null);
}
};
return (
<div className="tlist-root tlist-page">
<div className="page-header">
<div className="page-title-group">
<h1>TOPOLOGIES</h1>
<span className="page-sub">
{loading ? 'LOADING…' : `${rows.length} ${rows.length === 1 ? 'TOPOLOGY' : 'TOPOLOGIES'}`}
{err && <span className="alert-text"> · {err}</span>}
{reapMsg && <span className="alert-text"> · reap: {reapMsg}</span>}
</span>
</div>
<div className="tlist-actions">
<button type="button" className="tlist-btn ghost" onClick={fetchRows} title="Refresh">
<RefreshCw size={12} /> REFRESH
</button>
<button
type="button"
className={`tlist-btn ghost warn ${armed === 'reap' ? 'armed' : ''}`}
disabled={reaping}
onClick={() => armed === 'reap' ? onReapOrphans() : arm('reap')}
title={armed === 'reap'
? 'Click again to force-remove Docker resources for deleted topologies'
: 'Reap orphan Docker resources (admin)'}
>
<Skull size={12} /> {reaping ? 'REAPING…' : armed === 'reap' ? 'CONFIRM?' : 'REAP ORPHANS'}
</button>
<button type="button" className="tlist-btn" onClick={() => setCreating(true)}>
<Plus size={12} /> NEW TOPOLOGY
</button>
</div>
</div>
<CreateTopologyWizard
open={creating}
onClose={() => setCreating(false)}
onCreated={onCreated}
/>
{!loading && rows.length === 0 ? (
<div className="tlist-empty-wrap">
<EmptyState
icon={Network}
title="NO TOPOLOGIES YET"
hint="spin one up to deploy a honeynet"
cta={{ label: 'NEW TOPOLOGY', icon: Plus, onClick: () => setCreating(true) }}
/>
</div>
) : (
<div className="tlist-grid">
{rows.map((r) => (
<div key={r.id} className="tlist-card" onClick={() => navigate(`/mazenet?topology=${r.id}`)}>
<div className="tlist-card-top">
<Network size={14} className="violet-accent" />
<div className="tlist-card-name">{r.name}</div>
<span className={`tlist-pill ${statusClass(r.status)}`}>{r.status}</span>
</div>
<div className="tlist-card-meta">
{r.mode === 'agent' && r.target_host_uuid ? (
<span title={r.target_host_uuid}>
<Server size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
{hostsByUuid.get(r.target_host_uuid)?.name ?? `host:${r.target_host_uuid.slice(0, 8)}`}
</span>
) : (
<span>
<Cpu size={11} style={{ marginRight: 4, verticalAlign: '-1px' }} />
master
</span>
)}
<span>v{r.version}</span>
<span>{new Date(r.created_at).toLocaleString()}</span>
</div>
<div className="tlist-card-id">{r.id}</div>
<div className="tlist-card-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="tlist-btn small"
onClick={() => navigate(`/topologies/${r.id}/personas`)}
title="Edit email personas for this topology"
>
<Mail size={10} /> PERSONAS
</button>
{r.status === 'pending' && (
<button
type="button"
className="tlist-btn small"
disabled={busy === r.id}
onClick={() => onDeploy(r.id)}
title="Deploy this topology"
>
<UploadCloud size={10} /> DEPLOY
</button>
)}
{['active', 'degraded', 'failed', 'deploying'].includes(r.status) && (
<button
type="button"
className={`tlist-btn small warn ${armed === `td:${r.id}` ? 'armed' : ''}`}
disabled={busy === r.id}
onClick={() => armed === `td:${r.id}` ? onTeardown(r.id) : arm(`td:${r.id}`)}
title={armed === `td:${r.id}` ? 'Click again to confirm teardown' : 'Teardown this topology'}
>
<Power size={10} /> {armed === `td:${r.id}` ? 'CONFIRM?' : 'TEARDOWN'}
</button>
)}
{!['active', 'degraded', 'deploying'].includes(r.status) && (
<button
type="button"
className={`tlist-btn small danger ${armed === r.id ? 'armed' : ''}`}
disabled={busy === r.id}
onClick={() => armed === r.id ? onDelete(r.id) : arm(r.id)}
title={armed === r.id ? 'Click again to confirm' : 'Delete'}
>
<Trash2 size={10} /> {armed === r.id ? 'CONFIRM?' : 'DELETE'}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default TopologyList;

View File

@@ -0,0 +1,341 @@
/* Webhooks page — mirrors the .logs-root / .fleet-root / .swarm-root shape. */
.webhooks-root {
display: flex;
flex-direction: column;
gap: 24px;
}
.webhooks-root .page-title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.webhooks-root .page-header h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
margin: 0;
color: var(--matrix);
}
.webhooks-root .page-sub {
font-size: 0.7rem;
opacity: 0.5;
letter-spacing: 1px;
}
.webhooks-root .page-header .actions {
display: flex;
gap: 10px;
align-items: center;
}
/* Canonical buttons (copy LiveLogs' scoped rules so theme/accent behaves). */
.webhooks-root .btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.webhooks-root .btn:hover {
background: var(--matrix);
color: #000;
box-shadow: var(--matrix-glow);
}
.webhooks-root .btn.violet { border-color: var(--violet); color: var(--violet); }
.webhooks-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.webhooks-root .btn.alert { border-color: var(--alert, #ff4d4d); color: var(--alert, #ff4d4d); }
.webhooks-root .btn.alert:hover { background: var(--alert, #ff4d4d); color: #000; box-shadow: 0 0 10px rgba(255, 77, 77, 0.5); }
.webhooks-root .btn.warn { border-color: var(--warn, #e0a040); color: var(--warn, #e0a040); }
.webhooks-root .btn.warn:hover { background: var(--warn, #e0a040); color: #000; box-shadow: 0 0 10px rgba(224, 160, 64, 0.5); }
.webhooks-root .btn.ghost { border-color: var(--border-color); color: var(--matrix); opacity: 0.7; }
.webhooks-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); }
.webhooks-root .btn:disabled { opacity: 0.3; cursor: not-allowed; }
.webhooks-root .webhooks-error {
margin: 0;
}
.webhooks-root .webhooks-warning-banner {
background: rgba(224, 160, 64, 0.08);
border: 1px solid var(--warn, #e0a040);
color: var(--warn, #e0a040);
padding: 10px 14px;
font-size: 0.7rem;
letter-spacing: 1.5px;
display: flex;
align-items: center;
gap: 10px;
}
/* Table container — reuse the .logs-section shell from Dashboard.css. */
.webhooks-root .webhooks-empty {
padding: 40px;
text-align: center;
opacity: 0.5;
font-size: 0.78rem;
letter-spacing: 1.5px;
}
.webhooks-root .webhooks-table-wrap {
overflow-x: auto;
}
.webhooks-root .webhooks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.webhooks-root .webhooks-table thead {
font-size: 0.62rem;
letter-spacing: 1.5px;
opacity: 0.6;
}
.webhooks-root .webhooks-table th,
.webhooks-root .webhooks-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
vertical-align: middle;
}
.webhooks-root .webhooks-table tbody tr:hover {
background: rgba(0, 255, 65, 0.03);
}
.webhooks-root .webhooks-table .col-check { width: 28px; }
.webhooks-root .webhooks-table .col-actions { width: 180px; }
.webhooks-root .action-btn.fire {
border-color: var(--violet);
color: var(--violet);
letter-spacing: 1.5px;
}
.webhooks-root .action-btn.fire:hover {
background: var(--violet);
color: #000;
box-shadow: var(--violet-glow);
}
.webhooks-root .wh-url-cell {
font-family: var(--font-mono);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Chips — mirror the canonical chip spec from UI-Things.md. */
.webhooks-root .wh-chip {
font-size: 0.66rem;
padding: 2px 7px;
border: 1px solid var(--accent-tint-30, rgba(0, 255, 65, 0.3));
background: var(--accent-tint-10, rgba(0, 255, 65, 0.1));
color: var(--accent);
letter-spacing: 0.5px;
font-family: var(--font-mono);
margin-right: 4px;
display: inline-block;
}
.webhooks-root .wh-chip.status-disabled {
border-color: var(--border-color);
color: var(--text-color);
background: transparent;
opacity: 0.6;
}
.webhooks-root .wh-chip.status-fail {
border-color: var(--alert, #ff4d4d);
background: rgba(255, 77, 77, 0.12);
color: var(--alert, #ff4d4d);
}
.webhooks-root .wh-chip.status-warn {
border-color: var(--warn, #e0a040);
background: rgba(224, 160, 64, 0.1);
color: var(--warn, #e0a040);
}
.webhooks-root .wh-actions {
display: flex;
gap: 6px;
}
/* Inline form row (create + edit). */
.webhooks-root .wh-form-row td {
padding: 20px 20px;
background: rgba(0, 0, 0, 0.25);
}
.webhooks-root .wh-form-grid {
display: grid;
grid-template-columns: 160px 1fr;
gap: 14px 16px;
max-width: 920px;
}
.webhooks-root .wh-form-grid label {
font-size: 0.62rem;
letter-spacing: 1.5px;
opacity: 0.6;
align-self: center;
text-transform: uppercase;
}
.webhooks-root .wh-form-title {
grid-column: 1 / -1;
font-size: 0.7rem;
color: var(--violet);
letter-spacing: 2px;
opacity: 1;
margin: 0 0 4px 0;
}
.webhooks-root .wh-form-hint {
opacity: 0.5;
font-weight: normal;
letter-spacing: 0.5px;
text-transform: none;
}
.webhooks-root .wh-form-grid input[type="text"],
.webhooks-root .wh-form-grid input[type="url"],
.webhooks-root .wh-form-grid input[type="password"],
.webhooks-root .wh-form-grid textarea {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 12px;
font-family: inherit;
font-size: 0.82rem;
width: 100%;
box-sizing: border-box;
}
.webhooks-root .wh-form-grid textarea {
min-height: 72px;
font-family: var(--font-mono);
}
.webhooks-root .wh-form-grid input:focus,
.webhooks-root .wh-form-grid textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--accent-glow, 0 0 10px rgba(0, 255, 65, 0.5));
}
.webhooks-root .wh-checkbox-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.webhooks-root .wh-checkbox-group label {
font-size: 0.72rem;
letter-spacing: 1px;
opacity: 1;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
text-transform: none;
}
.webhooks-root .wh-form-buttons {
grid-column: 1 / -1;
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 10px;
border-top: 1px dashed var(--border-color);
}
/* Secret-modal — one-shot display after create. */
.wh-secret-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.wh-secret-modal {
background: var(--secondary-color);
border: 1px solid var(--violet);
box-shadow: 0 0 24px rgba(238, 130, 238, 0.4);
padding: 28px;
max-width: 560px;
width: 100%;
font-family: var(--font-mono);
}
.wh-secret-modal h3 {
margin: 0 0 12px 0;
color: var(--violet);
letter-spacing: 2px;
font-size: 0.9rem;
}
.wh-secret-modal .wh-secret-warn {
color: var(--warn, #e0a040);
font-size: 0.7rem;
letter-spacing: 1px;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.wh-secret-modal .wh-secret-value {
background: #0d1117;
border: 1px solid var(--border-color);
padding: 10px 12px;
font-size: 0.85rem;
word-break: break-all;
margin-bottom: 16px;
}
.wh-secret-modal .wh-secret-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.wh-secret-modal .btn {
cursor: pointer;
background: transparent;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.wh-secret-modal .btn.violet { border-color: var(--violet); color: var(--violet); }
.wh-secret-modal .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.wh-secret-modal .btn.ghost { border-color: var(--border-color); color: var(--matrix); opacity: 0.7; }
.wh-secret-modal .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); }

View File

@@ -0,0 +1,642 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
Check, Webhook as WebhookIcon,
} from '../icons';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import './Dashboard.css';
import './Config.css';
import './Webhooks.css';
type SimpleEvent = 'AttackerDetail' | 'DeckyStatus' | 'SystemStatus';
// Server-side canonical expansions (mirrors decnet/webhook/enums.py). Kept
// in sync manually; this is the sugar layer, not the source of truth.
const SIMPLE_PRESETS: Record<SimpleEvent, string[]> = {
AttackerDetail: ['attacker.>'],
DeckyStatus: ['decky.*.state', 'decky.*.traffic'],
SystemStatus: ['system.>'],
};
interface WebhookRow {
uuid: string;
name: string;
url: string;
topic_patterns: string[];
enabled: boolean;
consecutive_failures: number;
last_success_at: string | null;
last_failure_at: string | null;
last_error: string | null;
auto_disabled_at: string | null;
created_at: string;
updated_at: string;
warnings: string[];
}
interface FormState {
name: string;
url: string;
secret: string; // blank = server auto-generates (create) / keep existing (edit)
simple_events: SimpleEvent[];
topic_patterns: string; // textarea: one per line
enabled: boolean;
}
const BLANK_FORM: FormState = {
name: '',
url: '',
secret: '',
simple_events: [],
topic_patterns: '',
enabled: true,
};
function extractErrorDetail(err: unknown, fallback: string): string {
const e = err as {
response?: { status?: number; data?: { detail?: string } };
message?: string;
};
if (e?.response?.data?.detail) return e.response.data.detail;
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
if (e?.response?.status === 401) return 'Session expired — please log in again';
if (e?.message) return e.message;
return fallback;
}
/** Derive which simple-event checkboxes should show as ticked for a given
* persisted pattern list. Only ticks when the intersection is exact —
* mixed custom + preset leaves everything unticked and the textarea is
* the source of truth. */
function deriveSimpleEvents(patterns: string[]): SimpleEvent[] {
const ticked: SimpleEvent[] = [];
const remaining = new Set(patterns);
for (const [name, preset] of Object.entries(SIMPLE_PRESETS) as [SimpleEvent, string[]][]) {
if (preset.every((p) => remaining.has(p))) {
ticked.push(name);
preset.forEach((p) => remaining.delete(p));
}
}
// If anything outside the presets remains, don't tick — user sees raw.
if (remaining.size > 0) return [];
return ticked;
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const Webhooks: React.FC = () => {
const [webhooks, setWebhooks] = useState<WebhookRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { push } = useToast();
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(BLANK_FORM);
const [saving, setSaving] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleteArmed, setDeleteArmed] = useState(false);
const [newSecret, setNewSecret] = useState<{ name: string; secret: string } | null>(null);
const insecureCount = useMemo(
() => webhooks.filter((w) => w.warnings.some((msg) => msg.startsWith('insecure_url'))).length,
[webhooks],
);
const enabledCount = useMemo(() => webhooks.filter((w) => w.enabled).length, [webhooks]);
const failCount = useMemo(
() => webhooks.filter((w) => w.consecutive_failures > 0).length,
[webhooks],
);
const trippedCount = useMemo(
() => webhooks.filter((w) => w.auto_disabled_at).length,
[webhooks],
);
const fetchWebhooks = async () => {
try {
const res = await api.get('/webhooks/');
setWebhooks(res.data);
setError(null);
} catch (err) {
setError(extractErrorDetail(err, 'Failed to load webhooks'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchWebhooks();
}, []);
const closeForm = () => {
setCreating(false);
setEditingId(null);
setForm(BLANK_FORM);
};
const openCreate = () => {
setEditingId(null);
setForm(BLANK_FORM);
setCreating(true);
};
const openEdit = (w: WebhookRow) => {
setCreating(false);
setEditingId(w.uuid);
const ticked = deriveSimpleEvents(w.topic_patterns);
const remaining = ticked.length
? w.topic_patterns.filter((p) =>
!ticked.some((s) => SIMPLE_PRESETS[s].includes(p)))
: w.topic_patterns;
setForm({
name: w.name,
url: w.url,
secret: '',
simple_events: ticked,
topic_patterns: remaining.join('\n'),
enabled: w.enabled,
});
};
const toggleSimpleEvent = (name: SimpleEvent) => {
setForm((f) => ({
...f,
simple_events: f.simple_events.includes(name)
? f.simple_events.filter((s) => s !== name)
: [...f.simple_events, name],
}));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) return;
const rawPatterns = form.topic_patterns
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (form.simple_events.length === 0 && rawPatterns.length === 0) {
push({ text: 'SELECT AT LEAST ONE EVENT OR PATTERN', tone: 'violet', icon: 'alert-triangle' });
return;
}
setSaving(true);
try {
if (editingId) {
await api.patch(`/webhooks/${editingId}`, {
name: form.name.trim(),
url: form.url.trim(),
secret: form.secret ? form.secret : undefined,
simple_events: form.simple_events,
topic_patterns: rawPatterns,
enabled: form.enabled,
});
push({ text: 'WEBHOOK UPDATED', tone: 'violet', icon: 'check-circle' });
} else {
const res = await api.post('/webhooks/', {
name: form.name.trim(),
url: form.url.trim(),
secret: form.secret ? form.secret : undefined,
simple_events: form.simple_events,
topic_patterns: rawPatterns,
enabled: form.enabled,
});
push({ text: 'WEBHOOK CREATED', tone: 'violet', icon: 'check-circle' });
if (res.data?.secret) {
setNewSecret({ name: res.data.name, secret: res.data.secret });
}
}
closeForm();
await fetchWebhooks();
} catch (err) {
const msg = extractErrorDetail(err, 'Save failed');
push({ text: `SAVE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
} finally {
setSaving(false);
}
};
const handleTestOne = async (uuid: string, name: string) => {
try {
const res = await api.post(`/webhooks/${uuid}/test`);
const { delivered, status_code, error: err } = res.data;
if (delivered) {
push({ text: `${name.toUpperCase()} · DELIVERED · ${status_code}`, tone: 'violet', icon: 'zap' });
} else {
push({ text: `${name.toUpperCase()} · FAILED · ${(err || 'unknown').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
fetchWebhooks();
} catch (err) {
const msg = extractErrorDetail(err, 'Test failed');
push({ text: `TEST FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
};
const handleDeleteOne = async (uuid: string, name: string) => {
try {
await api.delete(`/webhooks/${uuid}`);
push({ text: `${name.toUpperCase()} · DELETED`, tone: 'violet', icon: 'trash' });
setSelected((s) => {
const n = new Set(s);
n.delete(uuid);
return n;
});
fetchWebhooks();
} catch (err) {
const msg = extractErrorDetail(err, 'Delete failed');
push({ text: `DELETE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
};
const handleDeleteSelected = async () => {
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => api.delete(`/webhooks/${id}`)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const bad = results.length - ok;
push({
text: bad === 0
? `DELETED · ${ok}`
: `DELETED · ${ok} · FAILED · ${bad}`,
tone: 'violet',
icon: bad ? 'alert-triangle' : 'trash',
});
setSelected(new Set());
setDeleteArmed(false);
fetchWebhooks();
};
const toggleSelect = (uuid: string) => {
setSelected((s) => {
const n = new Set(s);
if (n.has(uuid)) n.delete(uuid);
else n.add(uuid);
return n;
});
};
const toggleSelectAll = () => {
if (selected.size === webhooks.length) setSelected(new Set());
else setSelected(new Set(webhooks.map((w) => w.uuid)));
};
return (
<div className="webhooks-root">
<div className="page-header">
<div className="page-title-group">
<h1>WEBHOOKS</h1>
<span className="page-sub">
{webhooks.length} CONFIGURED · {enabledCount} ENABLED
{trippedCount > 0 && ` · ${trippedCount} TRIPPED`}
{failCount > 0 && ` · ${failCount} FAILING`}
{insecureCount > 0 && ` · ${insecureCount} INSECURE`}
</span>
</div>
<div className="actions">
{selected.size > 0 && (
deleteArmed ? (
<>
<button className="btn alert" onClick={handleDeleteSelected}>
<Check size={12} /> CONFIRM DELETE {selected.size}
</button>
<button className="btn ghost" onClick={() => setDeleteArmed(false)}>
<X size={12} /> CANCEL
</button>
</>
) : (
<button className="btn warn" onClick={() => setDeleteArmed(true)}>
<Trash2 size={12} /> DELETE SELECTED ({selected.size})
</button>
)
)}
<button
className="btn violet"
onClick={openCreate}
disabled={creating || editingId !== null}
>
<Plus size={12} /> CREATE WEBHOOK
</button>
</div>
</div>
{error && <div className="config-error webhooks-error">{error}</div>}
{insecureCount > 0 && !error && (
<div className="webhooks-warning-banner">
<AlertTriangle size={14} />
<span>
{insecureCount === 1
? '1 WEBHOOK USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.'
: `${insecureCount} WEBHOOKS USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.`}
</span>
</div>
)}
<div className="logs-section">
<div className="section-header">
<div className="section-title">
<WebhookIcon size={14} />
<span>SUBSCRIPTIONS</span>
</div>
<div className="section-actions">
<span>SHOWING {webhooks.length}</span>
</div>
</div>
{loading ? (
<div className="webhooks-empty">LOADING WEBHOOKS</div>
) : webhooks.length === 0 && !creating ? (
<div className="webhooks-empty">
NO WEBHOOKS CONFIGURED CLICK CREATE WEBHOOK TO ADD ONE.
</div>
) : (
<div className="webhooks-table-wrap">
<table className="webhooks-table users-table">
<thead>
<tr>
<th className="col-check">
<input
type="checkbox"
checked={webhooks.length > 0 && selected.size === webhooks.length}
onChange={toggleSelectAll}
/>
</th>
<th>NAME</th>
<th>URL</th>
<th>PATTERNS</th>
<th>STATUS</th>
<th>LAST FIRED</th>
<th className="col-actions">ACTIONS</th>
</tr>
</thead>
<tbody>
{creating && (
<FormRow
title="NEW WEBHOOK"
form={form}
setForm={setForm}
onSave={handleSave}
onCancel={closeForm}
saving={saving}
isEdit={false}
onToggleSimple={toggleSimpleEvent}
/>
)}
{webhooks.map((w) => (
editingId === w.uuid ? (
<FormRow
key={w.uuid}
title={`EDIT · ${w.name.toUpperCase()}`}
form={form}
setForm={setForm}
onSave={handleSave}
onCancel={closeForm}
saving={saving}
isEdit
onToggleSimple={toggleSimpleEvent}
/>
) : (
<tr key={w.uuid}>
<td className="col-check">
<input
type="checkbox"
checked={selected.has(w.uuid)}
onChange={() => toggleSelect(w.uuid)}
/>
</td>
<td>{w.name}</td>
<td className="wh-url-cell" title={w.url}>
{w.url}
</td>
<td>
{w.topic_patterns.slice(0, 2).map((p) => (
<span key={p} className="wh-chip">{p}</span>
))}
{w.topic_patterns.length > 2 && (
<span className="wh-chip" title={w.topic_patterns.slice(2).join(', ')}>
+{w.topic_patterns.length - 2}
</span>
)}
</td>
<td>
<span className={`wh-chip ${w.enabled ? '' : 'status-disabled'}`}>
{w.enabled ? 'ENABLED' : 'DISABLED'}
</span>
{w.auto_disabled_at && (
<span
className="wh-chip status-fail"
title={`Circuit tripped at ${formatDate(w.auto_disabled_at)}. Re-enable via Edit to reset.`}
>
TRIPPED · {formatDate(w.auto_disabled_at)}
</span>
)}
{w.consecutive_failures > 0 && (
<span className="wh-chip status-fail" title={w.last_error || ''}>
FAIL · {w.consecutive_failures}
</span>
)}
{w.warnings.some((m) => m.startsWith('insecure_url')) && (
<span className="wh-chip status-warn" title="URL uses http://">
HTTP
</span>
)}
</td>
<td>{formatDate(w.last_success_at)}</td>
<td>
<div className="wh-actions">
<button
className="action-btn fire"
onClick={() => handleTestOne(w.uuid, w.name)}
title="Fire a synthetic test event to this webhook (POST /webhooks/{uuid}/test)"
>
<Zap size={12} />
FIRE
</button>
<button
className="action-btn"
onClick={() => openEdit(w)}
title="Edit"
disabled={creating || editingId !== null}
>
<Pencil size={12} />
</button>
<button
className="action-btn danger"
onClick={() => handleDeleteOne(w.uuid, w.name)}
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
</div>
)}
</div>
{newSecret && (
<SecretModal
name={newSecret.name}
secret={newSecret.secret}
onClose={() => setNewSecret(null)}
/>
)}
</div>
);
};
interface FormRowProps {
title: string;
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
onSave: (e: React.FormEvent) => void;
onCancel: () => void;
saving: boolean;
isEdit: boolean;
onToggleSimple: (n: SimpleEvent) => void;
}
const FormRow: React.FC<FormRowProps> = ({
title, form, setForm, onSave, onCancel, saving, isEdit, onToggleSimple,
}) => (
<tr className="wh-form-row">
<td colSpan={7}>
<form className="wh-form-grid" onSubmit={onSave}>
<label className="wh-form-title">{title}</label>
<label>NAME</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="shuffle-prod"
required
maxLength={64}
/>
<label>URL</label>
<input
type="url"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://shuffle.example.com/api/v1/hooks/webhook_xxx"
required
/>
<label>
SECRET {isEdit && <span className="wh-form-hint">(blank = keep existing)</span>}
</label>
<input
type="password"
value={form.secret}
onChange={(e) => setForm((f) => ({ ...f, secret: e.target.value }))}
placeholder={isEdit ? '—' : 'leave blank to auto-generate'}
minLength={16}
maxLength={256}
/>
<label>SIMPLE EVENTS</label>
<div className="wh-checkbox-group">
{(['AttackerDetail', 'DeckyStatus', 'SystemStatus'] as const).map((name) => (
<label key={name}>
<input
type="checkbox"
checked={form.simple_events.includes(name)}
onChange={() => onToggleSimple(name)}
/>
{name}
</label>
))}
</div>
<label>
ADVANCED PATTERNS
<br />
<span className="wh-form-hint">(one per line, NATS-style)</span>
</label>
<textarea
value={form.topic_patterns}
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
placeholder={'attacker.>\ndecky.*.state'}
/>
<label>ENABLED</label>
<div className="wh-checkbox-group">
<label>
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
Receive events
</label>
</div>
<div className="wh-form-buttons">
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
<X size={12} /> CANCEL
</button>
<button type="submit" className="btn violet" disabled={saving}>
<Save size={12} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'}
</button>
</div>
</form>
</td>
</tr>
);
interface SecretModalProps {
name: string;
secret: string;
onClose: () => void;
}
const SecretModal: React.FC<SecretModalProps> = ({ name, secret, onClose }) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(secret);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* no-op — browsers without clipboard perms will just see no feedback */
}
};
return (
<div
className="wh-secret-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="wh-secret-modal">
<h3>WEBHOOK SECRET · {name.toUpperCase()}</h3>
<div className="wh-secret-warn">
<AlertTriangle size={14} />
<span>COPY THIS NOW IT WILL NOT BE SHOWN AGAIN. THE HMAC ON EVERY DELIVERY IS SIGNED WITH THIS VALUE.</span>
</div>
<div className="wh-secret-value">{secret}</div>
<div className="wh-secret-actions">
<button className="btn ghost" onClick={copy}>
<Copy size={12} /> {copied ? 'COPIED' : 'COPY'}
</button>
<button className="btn violet" onClick={onClose}>
<Check size={12} /> DONE
</button>
</div>
</div>
</div>
);
};
export default Webhooks;

View File

@@ -0,0 +1,100 @@
/**
* Campaign-clustering event stream — opens an SSE connection to
* `/campaigns/events` and dispatches typed events to the caller.
*
* Mirror of `useIdentityStream` for the layer above. CampaignDetail
* subscribes to refresh its own row + linked-identity list when
* `campaign.identity.assigned` / `campaign.merged` / `campaign.unmerged`
* fires.
*/
import { useEffect, useRef } from 'react';
export type CampaignStreamEventName =
| 'snapshot'
| 'formed'
| 'identity.assigned'
| 'merged'
| 'unmerged';
export interface CampaignStreamEvent {
name: CampaignStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseCampaignStreamOptions {
enabled: boolean;
onEvent: (event: CampaignStreamEvent) => void;
onError?: () => void;
}
const NAMED_EVENTS: CampaignStreamEventName[] = [
'snapshot',
'formed',
'identity.assigned',
'merged',
'unmerged',
];
export function useCampaignStream({
enabled,
onEvent,
onError,
}: UseCampaignStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
if (!enabled) return;
const connect = () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/campaigns/events?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<CampaignStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useCampaignStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [enabled]);
}

View File

@@ -0,0 +1,113 @@
/**
* Identity-resolution event stream — opens an SSE connection to
* `/identities/events` and dispatches typed events to the caller.
*
* Mirrors `useTopologyStream` (reconnect on error after 3s, callbacks
* stashed in refs so the connection isn't torn down on every consumer
* rerender). The stream is broadly scoped — every identity event, not
* per-uuid — because both AttackerDetail and IdentityDetail want the
* same firehose:
*
* * AttackerDetail watches for `identity.formed` events whose payload
* references its observation uuid (the badge appears once the
* clusterer binds the row), plus `merged` / `unmerged` so the
* badge link updates if the row's identity gets re-pointed.
* * IdentityDetail watches for `observation.linked` / `merged` /
* `unmerged` against the identity it's rendering.
*
* Each consumer applies its own filter inside `onEvent`; the hook
* itself is dumb glue.
*/
import { useEffect, useRef } from 'react';
export type IdentityStreamEventName =
| 'snapshot'
| 'formed'
| 'observation.linked'
| 'merged'
| 'unmerged'
| 'campaign.assigned';
export interface IdentityStreamEvent {
name: IdentityStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseIdentityStreamOptions {
enabled: boolean;
onEvent: (event: IdentityStreamEvent) => void;
onError?: () => void;
}
const NAMED_EVENTS: IdentityStreamEventName[] = [
'snapshot',
'formed',
'observation.linked',
'merged',
'unmerged',
'campaign.assigned',
];
export function useIdentityStream({
enabled,
onEvent,
onError,
}: UseIdentityStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onEventRef = useRef(onEvent);
const onErrorRef = useRef(onError);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onErrorRef.current = onError; }, [onError]);
useEffect(() => {
if (!enabled) return;
const connect = () => {
if (esRef.current) esRef.current.close();
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/identities/events?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<IdentityStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useIdentityStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onErrorRef.current?.();
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [enabled]);
}

View File

@@ -0,0 +1,98 @@
/**
* Orchestrator event stream — opens an SSE connection to
* `/orchestrator/events/stream` and dispatches typed events to the
* caller. Mirror of `useCampaignStream`.
*/
import { useEffect, useRef } from 'react';
export type OrchestratorStreamEventName =
| 'snapshot'
| 'traffic'
| 'file'
| 'email';
export interface OrchestratorStreamEvent {
name: OrchestratorStreamEventName | string;
topic?: string;
type?: string;
ts?: string;
payload: Record<string, unknown>;
}
export interface UseOrchestratorStreamOptions {
enabled: boolean;
onEvent: (event: OrchestratorStreamEvent) => void;
onStatus?: (status: 'connecting' | 'live' | 'error') => void;
}
// Must include every leaf the SSE endpoint emits — the EventSource
// silently drops frames whose `event:` name has no listener registered.
// New leaves on the bus need a corresponding entry here or the
// dashboard ignores them despite the SSE pipe carrying them through.
const NAMED_EVENTS: OrchestratorStreamEventName[] = [
'snapshot', 'traffic', 'file', 'email',
];
export function useOrchestratorStream({
enabled,
onEvent,
onStatus,
}: UseOrchestratorStreamOptions): void {
const esRef = useRef<EventSource | null>(null);
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onEventRef = useRef(onEvent);
const onStatusRef = useRef(onStatus);
useEffect(() => { onEventRef.current = onEvent; }, [onEvent]);
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
useEffect(() => {
if (!enabled) return;
const connect = () => {
if (esRef.current) esRef.current.close();
onStatusRef.current?.('connecting');
const token = localStorage.getItem('token') ?? '';
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const url = `${baseUrl}/orchestrator/events/stream?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
esRef.current = es;
es.onopen = () => onStatusRef.current?.('live');
const dispatch = (name: string) => (event: MessageEvent) => {
try {
const parsed = JSON.parse(event.data) as Partial<OrchestratorStreamEvent>;
onEventRef.current({
name,
topic: parsed.topic,
type: parsed.type,
ts: parsed.ts,
payload: (parsed.payload ?? {}) as Record<string, unknown>,
});
} catch (err) {
console.error('useOrchestratorStream: parse failed', err);
}
};
for (const name of NAMED_EVENTS) {
es.addEventListener(name, dispatch(name) as EventListener);
}
es.onerror = () => {
es.close();
esRef.current = null;
onStatusRef.current?.('error');
reconnectRef.current = setTimeout(connect, 3000);
};
};
connect();
return () => {
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (esRef.current) esRef.current.close();
esRef.current = null;
};
}, [enabled]);
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
export function useEscapeKey(onEscape: () => void, active: boolean = true): void {
useEffect(() => {
if (!active) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onEscape();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onEscape, active]);
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import type { RefObject } from 'react';
/**
* Focus the given input when the global `decnet:focus-search` event fires
* (dispatched by the `/` hotkey in useGlobalHotkeys).
*/
export function useFocusSearch(ref: RefObject<HTMLInputElement | null>): void {
useEffect(() => {
const handler = () => {
const el = ref.current;
if (!el) return;
el.focus();
try { el.select(); } catch { /* ignore */ }
};
window.addEventListener('decnet:focus-search', handler);
return () => window.removeEventListener('decnet:focus-search', handler);
}, [ref]);
}

View File

@@ -0,0 +1,49 @@
import { useEffect, type RefObject } from 'react';
const FOCUSABLE =
'input:not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
): void {
useEffect(() => {
if (!active || !ref.current) return;
const root = ref.current;
const previouslyFocused = document.activeElement as HTMLElement | null;
const focusables = () =>
Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
);
const autoFocus =
root.querySelector<HTMLElement>('[data-autofocus]') ?? focusables()[0];
autoFocus?.focus();
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement as HTMLElement | null;
if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
root.addEventListener('keydown', handler);
return () => {
root.removeEventListener('keydown', handler);
previouslyFocused?.focus?.();
};
}, [ref, active]);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface Options {
cmdOpen: boolean;
setCmdOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
helpOpen: boolean;
setHelpOpen: (v: boolean | ((prev: boolean) => boolean)) => void;
}
const G_NAV: Record<string, string> = {
d: '/',
f: '/fleet',
m: '/mazenet',
l: '/live-logs',
b: '/bounty',
a: '/attackers',
c: '/config',
s: '/swarm/hosts',
u: '/swarm-updates',
};
const G_TIMEOUT_MS = 800;
function isEditable(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
export function useGlobalHotkeys({ cmdOpen, setCmdOpen, helpOpen, setHelpOpen }: Options): void {
const navigate = useNavigate();
const pendingG = useRef(false);
const gTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const clearG = () => {
pendingG.current = false;
if (gTimer.current) { clearTimeout(gTimer.current); gTimer.current = null; }
};
const onKey = (e: KeyboardEvent) => {
if (e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
setCmdOpen(v => !v);
clearG();
return;
}
if (e.key === 'Escape' && cmdOpen) {
setCmdOpen(false);
clearG();
return;
}
if (cmdOpen || helpOpen) return;
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
// `?` (Shift+/) — open shortcuts cheatsheet
if (e.key === '?') {
e.preventDefault();
setHelpOpen(true);
clearG();
return;
}
// `/` — focus page search (page listens for the event)
if (e.key === '/') {
e.preventDefault();
window.dispatchEvent(new CustomEvent('decnet:focus-search'));
clearG();
return;
}
const k = e.key.toLowerCase();
if (pendingG.current && G_NAV[k]) {
e.preventDefault();
navigate(G_NAV[k]);
clearG();
return;
}
if (k === 'g') {
pendingG.current = true;
if (gTimer.current) clearTimeout(gTimer.current);
gTimer.current = setTimeout(clearG, G_TIMEOUT_MS);
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
if (gTimer.current) clearTimeout(gTimer.current);
};
}, [cmdOpen, setCmdOpen, helpOpen, setHelpOpen, navigate]);
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from 'react';
import api from '../utils/api';
export interface SwarmHost {
uuid: string;
name: string;
address: string;
agent_port: number;
status: string;
last_heartbeat: string | null;
}
/**
* Lookup of enrolled swarm hosts. One-shot fetch on mount, with a manual
* refresh callback. Used to resolve `target_host_uuid` → display name in
* places where we don't already have a host name in hand (topology list,
* war-map header).
*
* Failure is treated as "no agents enrolled" — callers fall back to the
* uuid prefix or a generic label rather than blocking on this lookup.
*/
export function useSwarmHosts(): {
hosts: SwarmHost[];
byUuid: Map<string, SwarmHost>;
refresh: () => Promise<void>;
} {
const [hosts, setHosts] = useState<SwarmHost[]>([]);
const refresh = useCallback(async () => {
try {
const { data } = await api.get<SwarmHost[]>('/swarm/hosts');
setHosts(data ?? []);
} catch {
setHosts([]);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const byUuid = new Map(hosts.map((h) => [h.uuid, h]));
return { hosts, byUuid, refresh };
}

104
decnet_web/src/icons.ts Normal file
View File

@@ -0,0 +1,104 @@
/* Centralised lucide re-export.
*
* Per-icon paths instead of the 'lucide-react' barrel: Vite's dep
* optimiser would otherwise pre-bundle the full barrel in dev
* (slower HMR), and prod tree-shaking is marginally tighter when
* each icon is its own module. Adding a new icon: add one line
* below, keep sorted. */
export type { LucideIcon } from 'lucide-react';
export { default as Activity } from 'lucide-react/dist/esm/icons/activity';
export { default as AlertTriangle } from 'lucide-react/dist/esm/icons/triangle-alert';
export { default as Archive } from 'lucide-react/dist/esm/icons/archive';
export { default as ArrowLeft } from 'lucide-react/dist/esm/icons/arrow-left';
export { default as ArrowRight } from 'lucide-react/dist/esm/icons/arrow-right';
export { default as AtSign } from 'lucide-react/dist/esm/icons/at-sign';
export { default as Ban } from 'lucide-react/dist/esm/icons/ban';
export { default as BarChart3 } from 'lucide-react/dist/esm/icons/chart-column';
export { default as Bell } from 'lucide-react/dist/esm/icons/bell';
export { default as Box } from 'lucide-react/dist/esm/icons/box';
export { default as Check } from 'lucide-react/dist/esm/icons/check';
export { default as CheckCircle } from 'lucide-react/dist/esm/icons/circle-check-big';
export { default as ChevronDown } from 'lucide-react/dist/esm/icons/chevron-down';
export { default as ChevronLeft } from 'lucide-react/dist/esm/icons/chevron-left';
export { default as ChevronRight } from 'lucide-react/dist/esm/icons/chevron-right';
export { default as ChevronUp } from 'lucide-react/dist/esm/icons/chevron-up';
export { default as Circle } from 'lucide-react/dist/esm/icons/circle';
export { default as Clock } from 'lucide-react/dist/esm/icons/clock';
export { default as Copy } from 'lucide-react/dist/esm/icons/copy';
export { default as Cpu } from 'lucide-react/dist/esm/icons/cpu';
export { default as Crosshair } from 'lucide-react/dist/esm/icons/crosshair';
export { default as Database } from 'lucide-react/dist/esm/icons/database';
export { default as Download } from 'lucide-react/dist/esm/icons/download';
export { default as Eye } from 'lucide-react/dist/esm/icons/eye';
export { default as FileKey } from 'lucide-react/dist/esm/icons/file-key';
export { default as FileText } from 'lucide-react/dist/esm/icons/file-text';
export { default as Filter } from 'lucide-react/dist/esm/icons/funnel';
export { default as Fingerprint } from 'lucide-react/dist/esm/icons/fingerprint-pattern';
export { default as Flame } from 'lucide-react/dist/esm/icons/flame';
export { default as Folder } from 'lucide-react/dist/esm/icons/folder';
export { default as GitMerge } from 'lucide-react/dist/esm/icons/git-merge';
export { default as Globe } from 'lucide-react/dist/esm/icons/globe';
export { default as HardDrive } from 'lucide-react/dist/esm/icons/hard-drive';
export { default as Info } from 'lucide-react/dist/esm/icons/info';
export { default as Key } from 'lucide-react/dist/esm/icons/key';
export { default as KeyRound } from 'lucide-react/dist/esm/icons/key-round';
export { default as Keyboard } from 'lucide-react/dist/esm/icons/keyboard';
export { default as LayoutDashboard } from 'lucide-react/dist/esm/icons/layout-dashboard';
export { default as LayoutGrid } from 'lucide-react/dist/esm/icons/layout-grid';
export { default as Lock } from 'lucide-react/dist/esm/icons/lock';
export { default as LogOut } from 'lucide-react/dist/esm/icons/log-out';
export { default as Mail } from 'lucide-react/dist/esm/icons/mail';
export { default as Maximize2 } from 'lucide-react/dist/esm/icons/maximize-2';
export { default as Menu } from 'lucide-react/dist/esm/icons/menu';
export { default as Minimize2 } from 'lucide-react/dist/esm/icons/minimize-2';
export { default as Monitor } from 'lucide-react/dist/esm/icons/monitor';
export { default as MousePointer2 } from 'lucide-react/dist/esm/icons/mouse-pointer-2';
export { default as Network } from 'lucide-react/dist/esm/icons/network';
export { default as Package } from 'lucide-react/dist/esm/icons/package';
export { default as Palette } from 'lucide-react/dist/esm/icons/palette';
export { default as PanelLeftClose } from 'lucide-react/dist/esm/icons/panel-left-close';
export { default as PanelLeftOpen } from 'lucide-react/dist/esm/icons/panel-left-open';
export { default as PanelRightClose } from 'lucide-react/dist/esm/icons/panel-right-close';
export { default as PanelRightOpen } from 'lucide-react/dist/esm/icons/panel-right-open';
export { default as Paperclip } from 'lucide-react/dist/esm/icons/paperclip';
export { default as Pause } from 'lucide-react/dist/esm/icons/pause';
export { default as Pencil } from 'lucide-react/dist/esm/icons/pencil';
export { default as Phone } from 'lucide-react/dist/esm/icons/phone';
export { default as Play } from 'lucide-react/dist/esm/icons/play';
export { default as Plus } from 'lucide-react/dist/esm/icons/plus';
export { default as PlusCircle } from 'lucide-react/dist/esm/icons/circle-plus';
export { default as Power } from 'lucide-react/dist/esm/icons/power';
export { default as PowerOff } from 'lucide-react/dist/esm/icons/power-off';
export { default as Radio } from 'lucide-react/dist/esm/icons/radio';
export { default as RefreshCw } from 'lucide-react/dist/esm/icons/refresh-cw';
export { default as RotateCcw } from 'lucide-react/dist/esm/icons/rotate-ccw';
export { default as Save } from 'lucide-react/dist/esm/icons/save';
export { default as Search } from 'lucide-react/dist/esm/icons/search';
export { default as SearchX } from 'lucide-react/dist/esm/icons/search-x';
export { default as Send } from 'lucide-react/dist/esm/icons/send';
export { default as Server } from 'lucide-react/dist/esm/icons/server';
export { default as Settings } from 'lucide-react/dist/esm/icons/settings';
export { default as Shield } from 'lucide-react/dist/esm/icons/shield';
export { default as ShieldAlert } from 'lucide-react/dist/esm/icons/shield-alert';
export { default as ShieldOff } from 'lucide-react/dist/esm/icons/shield-off';
export { default as Skull } from 'lucide-react/dist/esm/icons/skull';
export { default as Sliders } from 'lucide-react/dist/esm/icons/sliders-vertical';
export { default as Sparkles } from 'lucide-react/dist/esm/icons/sparkles';
export { default as Square } from 'lucide-react/dist/esm/icons/square';
export { default as Target } from 'lucide-react/dist/esm/icons/target';
export { default as Terminal } from 'lucide-react/dist/esm/icons/terminal';
export { default as Timer } from 'lucide-react/dist/esm/icons/timer';
export { default as Trash2 } from 'lucide-react/dist/esm/icons/trash-2';
export { default as Upload } from 'lucide-react/dist/esm/icons/upload';
export { default as UploadCloud } from 'lucide-react/dist/esm/icons/cloud-upload';
export { default as UserPlus } from 'lucide-react/dist/esm/icons/user-plus';
export { default as Users } from 'lucide-react/dist/esm/icons/users';
export { default as Webhook } from 'lucide-react/dist/esm/icons/webhook';
export { default as Wifi } from 'lucide-react/dist/esm/icons/wifi';
export { default as WifiOff } from 'lucide-react/dist/esm/icons/wifi-off';
export { default as X } from 'lucide-react/dist/esm/icons/x';
export { default as Zap } from 'lucide-react/dist/esm/icons/zap';
export { default as ZoomIn } from 'lucide-react/dist/esm/icons/zoom-in';
export { default as ZoomOut } from 'lucide-react/dist/esm/icons/zoom-out';

View File

@@ -1,27 +1,127 @@
@import url('https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
:root {
--background-color: #000000;
--text-color: #00ff41;
--accent-color: #ee82ee;
--secondary-color: #0d1117;
--border-color: #30363d;
--matrix-green-glow: 0 0 10px rgba(0, 255, 65, 0.5);
--violet-glow: 0 0 10px rgba(238, 130, 238, 0.5);
/* ── Brand ─────────────────────────────────────── */
--bg: #000000;
--matrix: #00ff41;
--violet: #ee82ee;
--panel: #0d1117;
--border: #30363d;
--alert: #ff4141;
/* Back-compat names used across the codebase */
--background-color: var(--bg);
--text-color: var(--matrix);
--accent-color: var(--violet);
--secondary-color: var(--panel);
--border-color: var(--border);
/* ── Foreground opacities ──────────────────────── */
--fg-1: var(--matrix);
--fg-2: rgba(0, 255, 65, 0.80);
--fg-3: rgba(0, 255, 65, 0.60);
--fg-4: rgba(0, 255, 65, 0.40);
/* ── Tinted surfaces ───────────────────────────── */
--matrix-tint-5: rgba(0, 255, 65, 0.05);
--matrix-tint-10: rgba(0, 255, 65, 0.10);
--matrix-tint-30: rgba(0, 255, 65, 0.30);
--violet-tint-10: rgba(238, 130, 238, 0.10);
--alert-tint-10: rgba(255, 65, 65, 0.10);
/* ── Glows ─────────────────────────────────────── */
--matrix-glow: 0 0 10px rgba(0, 255, 65, 0.5);
--matrix-green-glow: var(--matrix-glow); /* back-compat */
--violet-glow: 0 0 10px rgba(238, 130, 238, 0.5);
--matrix-glow-lg: 0 0 20px rgba(0, 255, 65, 0.4);
--shadow-panel: 0 0 20px rgba(0, 0, 0, 0.5);
/* ── Grid texture ──────────────────────────────── */
--grid-line: rgba(0, 255, 65, 0.05);
--grid-size: 20px;
/* ── Type ──────────────────────────────────────── */
--font-mono: 'Ubuntu Mono', 'SF Mono', Menlo, Consolas, monospace;
--fs-micro: 0.6rem;
--fs-mini: 0.7rem;
--fs-tiny: 0.75rem;
--fs-small: 0.8rem;
--fs-body: 0.85rem;
--fs-ui: 0.9rem;
--fs-base: 1rem;
--fs-head: 1.2rem;
--fs-page: 1.5rem;
--fs-hero: 1.8rem;
--fs-display: 2.5rem;
--lh-default: 1.5;
--ls-tight: 0;
--ls-label: 1px;
--ls-nav: 2px;
--ls-title: 4px;
--ls-brand: 10px;
/* ── Spacing ───────────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
/* ── Radii ─────────────────────────────────────── */
--radius-0: 0;
--radius-1: 2px;
--radius-2: 4px;
--radius-pill: 999px;
/* ── Layout ────────────────────────────────────── */
--sidebar-open: 240px;
--sidebar-closed: 70px;
--topbar-h: 64px;
/* ── Motion ────────────────────────────────────── */
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--dur-quick: 0.2s;
--dur-base: 0.3s;
--dur-slow: 1s;
--blink-dur: 2s;
--pulse-dur: 1s;
--spin-dur: 1.5s;
/* ── Accent swap (matrix default) ──────────────── */
--accent: var(--matrix);
--accent-tint-10: var(--matrix-tint-10);
--accent-tint-30: var(--matrix-tint-30);
--accent-glow: var(--matrix-glow);
}
* {
html[data-accent="violet"] {
--accent: var(--violet);
--accent-tint-10: var(--violet-tint-10);
--accent-tint-30: rgba(238, 130, 238, 0.30);
--accent-glow: var(--violet-glow);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Ubuntu Mono', monospace;
background-color: var(--background-color);
color: var(--text-color);
line-height: 1.5;
font-family: var(--font-mono);
background-color: var(--bg);
color: var(--matrix);
line-height: var(--lh-default);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
input, button, textarea, select {
@@ -31,45 +131,92 @@ input, button, textarea, select {
button {
cursor: pointer;
background: transparent;
border: 1px solid var(--text-color);
color: var(--text-color);
padding: 8px 16px;
border: 1px solid var(--matrix);
color: var(--matrix);
padding: 7px 14px;
font-family: inherit;
font-size: 0.78rem;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
button:hover {
background: var(--text-color);
color: var(--background-color);
box-shadow: var(--matrix-green-glow);
background: var(--matrix);
color: var(--bg);
box-shadow: var(--matrix-glow);
}
button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Shared .btn variants (unscoped) */
.btn.violet { border-color: var(--violet); color: var(--violet); }
.btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); }
.btn.alert { border-color: var(--alert); color: var(--alert); }
.btn.alert:hover { background: var(--alert); color: #000; box-shadow: 0 0 10px rgba(255, 65, 65, 0.5); }
.btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; }
.btn.ghost:hover {
background: transparent; color: var(--matrix); opacity: 1;
border-color: var(--matrix); box-shadow: var(--matrix-glow);
}
.btn.small { padding: 4px 10px; font-size: 0.68rem; }
input {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
background: var(--panel);
border: 1px solid var(--border);
color: var(--matrix);
padding: 8px 12px;
}
input:focus {
outline: none;
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
border-color: var(--matrix);
box-shadow: var(--matrix-glow);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
/* ── Primitive animations ──────────────────────── */
@keyframes decnet-blink {
0%, 100% { opacity: 1; text-shadow: var(--matrix-glow); }
50% { opacity: 0.5; }
}
@keyframes decnet-pulse {
from { opacity: 0.5; }
to { opacity: 1; }
}
@keyframes decnet-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
::-webkit-scrollbar-track {
background: var(--background-color);
.fx-blink { animation: decnet-blink var(--blink-dur) infinite; }
.fx-pulse { animation: decnet-pulse var(--pulse-dur) infinite alternate; }
.fx-spin { animation: decnet-spin var(--spin-dur) linear infinite; }
.fx-matrix-text { color: var(--matrix); }
.fx-violet-text { color: var(--violet); filter: drop-shadow(var(--violet-glow)); }
.fx-matrix-glow { text-shadow: var(--matrix-glow); }
.fx-dim { opacity: 0.5; }
.bg-scangrid {
background-color: var(--bg);
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
::-webkit-scrollbar-thumb {
background: var(--secondary-color);
border: 1px solid var(--border-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
/* ── Scrollbar ─────────────────────────────────── */
* {
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--accent); border: none; border-radius: 2px; opacity: 0.6; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); opacity: 1; }
::-webkit-scrollbar-corner { background: transparent; }

16
decnet_web/src/lucide-icons.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/* Ambient typings for lucide-react's per-icon module paths.
*
* lucide-react ships .d.ts only for the barrel entry point; the
* per-icon files (dist/esm/icons/<name>.js) have no sibling .d.ts.
* We import each icon from its own file to keep the dep-optimiser
* from pre-bundling the whole barrel, so the compiler needs a
* declaration that covers the wildcard path.
*
* Every icon exposes the same default-exported component shape,
* so one module wildcard is enough. */
declare module 'lucide-react/dist/esm/icons/*' {
import type { LucideIcon } from 'lucide-react';
const icon: LucideIcon;
export default icon;
}

View File

@@ -0,0 +1,39 @@
/* Human-readable labels for realism content classes.
*
* Source of truth for the enum values is decnet/realism/taxonomy.py.
* This module is the only place display text lives — every UI surface
* that renders a content_class should call ``contentClassLabel(value)``
* so the label vocabulary stays consistent across the dashboard. */
const LABELS: Record<string, string> = {
// User classes — files written by personas during work hours.
note: 'Note',
todo: 'TODO List',
draft: 'Draft Document',
script: 'Shell / Python Script',
// System classes — plausible OS-side filler.
log_cron: 'Cron Log',
log_daemon: 'Daemon Log',
cache_tmp: 'Cache / Temp File',
// Canary classes — callback-bearing artifacts.
canary_aws_creds: 'Canary · AWS Credentials',
canary_env_file: 'Canary · .env File',
canary_git_config: 'Canary · git config',
canary_ssh_key: 'Canary · SSH Private Key',
canary_honeydoc: 'Canary · HTML Honeydoc',
canary_honeydoc_docx: 'Canary · DOCX Honeydoc',
canary_honeydoc_pdf: 'Canary · PDF Honeydoc',
canary_mysql_dump: 'Canary · MySQL Dump',
};
export function contentClassLabel(value: string): string {
return LABELS[value] ?? value;
}
/* Returns true when the value is a canary class. Used to style canary
* rows differently in tables (subtle red accent, etc). */
export function isCanaryClass(value: string): boolean {
return value.startsWith('canary_');
}

View File

@@ -0,0 +1,40 @@
/* Prefetch-on-intent for lazy-loaded routes.
*
* Each key is a route path; each value is the same dynamic import()
* used by React.lazy() in App.tsx. The bundler dedups by specifier
* string, so a hover-triggered import here warms the exact chunk
* React.lazy resolves on click — no double fetch, no separate chunk.
*
* A Set of already-fired paths prevents redundant imports on repeat
* hovers; the module cache would short-circuit anyway, but skipping
* the call avoids a microtask and makes intent obvious in devtools. */
type Loader = () => Promise<unknown>;
const loaders: Record<string, Loader> = {
'/fleet': () => import('./components/DeckyFleet'),
'/mazenet': () => import('./components/MazeNET/MazeNET'),
'/topologies': () => import('./components/TopologyList/TopologyList'),
'/live-logs': () => import('./components/LiveLogs'),
'/webhooks': () => import('./components/Webhooks'),
'/bounty': () => import('./components/Bounty'),
'/attackers': () => import('./components/Attackers'),
'/config': () => import('./components/Config'),
'/swarm-updates': () => import('./components/RemoteUpdates'),
'/swarm/hosts': () => import('./components/SwarmHosts'),
'/orchestrator': () => import('./components/Orchestrator'),
};
const fired = new Set<string>();
export function prefetchRoute(path: string): void {
const loader = loaders[path];
if (!loader || fired.has(path)) return;
fired.add(path);
loader().catch(() => {
// Network hiccup on a speculative prefetch is a non-event —
// React.lazy will re-try on actual navigation and surface the
// real error there if it persists.
fired.delete(path);
});
}

View File

@@ -12,4 +12,15 @@ api.interceptors.request.use((config) => {
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.dispatchEvent(new Event('auth:logout'));
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,44 @@
// Some producers (notably the SSH PROMPT_COMMAND hook via rsyslog) emit
// k=v pairs inside the syslog MSG body instead of RFC5424 structured-data.
// When the backend's `fields` is empty we salvage those pairs here so the
// UI renders consistent pills regardless of where the structure was set.
//
// A leading non-"key=" token is returned as `head` (e.g. "CMD"). The final
// key consumes the rest of the line so values like `cmd=ls -lah` stay intact.
export interface ParsedBody {
head: string | null;
fields: Record<string, string>;
tail: string | null;
}
export function parseEventBody(msg: string | null | undefined): ParsedBody {
const empty: ParsedBody = { head: null, fields: {}, tail: null };
if (!msg) return empty;
const body = msg.trim();
if (!body || body === '-') return empty;
const keyRe = /([A-Za-z_][A-Za-z0-9_]*)=/g;
const firstKv = body.search(/(^|\s)[A-Za-z_][A-Za-z0-9_]*=/);
if (firstKv < 0) return { head: null, fields: {}, tail: body };
const headEnd = firstKv === 0 ? 0 : firstKv;
const head = headEnd > 0 ? body.slice(0, headEnd).trim() : null;
const rest = body.slice(headEnd).replace(/^\s+/, '');
const pairs: Array<{ key: string; valueStart: number }> = [];
let m: RegExpExecArray | null;
while ((m = keyRe.exec(rest)) !== null) {
pairs.push({ key: m[1], valueStart: m.index + m[0].length });
}
const fields: Record<string, string> = {};
for (let i = 0; i < pairs.length; i++) {
const { key, valueStart } = pairs[i];
const end = i + 1 < pairs.length
? pairs[i + 1].valueStart - pairs[i + 1].key.length - 1
: rest.length;
fields[key] = rest.slice(valueStart, end).trim();
}
return { head: head && head !== '-' ? head : null, fields, tail: null };
}

View File

@@ -4,4 +4,38 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
build: {
// Split heavy third-party libs into their own chunks so the main
// bundle stays small and the rarely-changing vendor code stays
// cacheable across deploys. Recharts + asciinema-player + lucide
// together made up most of the weight that was tripping the 500kB
// warning.
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (!id.includes('node_modules')) return undefined
// d3-* ships alongside recharts as its plotting engine —
// grouping them keeps tree-shaken subsets together.
if (id.includes('recharts') || id.includes('/d3-')) return 'charts'
if (id.includes('asciinema-player')) return 'player'
if (id.includes('lucide-react')) return 'icons'
if (id.includes('react-router')) return 'router'
if (id.includes('react-dom')) return 'react-dom'
if (id.includes('/react/') || id.endsWith('/react')) return 'react'
return 'vendor'
},
},
},
// Legitimate ceiling for any single chunk after splitting; anything
// larger is a real bloat regression worth investigating.
chunkSizeWarningLimit: 600,
},
})