merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
101
decnet_web/package-lock.json
generated
101
decnet_web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
200
decnet_web/src/components/ArtifactDrawer.tsx
Normal file
200
decnet_web/src/components/ArtifactDrawer.tsx
Normal 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;
|
||||
2221
decnet_web/src/components/AttackerDetail.tsx
Normal file
2221
decnet_web/src/components/AttackerDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
200
decnet_web/src/components/Attackers.css
Normal file
200
decnet_web/src/components/Attackers.css
Normal 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); }
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
238
decnet_web/src/components/Bounty.css
Normal file
238
decnet_web/src/components/Bounty.css
Normal 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); }
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
100
decnet_web/src/components/BountyInspector.tsx
Normal file
100
decnet_web/src/components/BountyInspector.tsx
Normal 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;
|
||||
289
decnet_web/src/components/CampaignDetail.tsx
Normal file
289
decnet_web/src/components/CampaignDetail.tsx
Normal 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;
|
||||
205
decnet_web/src/components/Campaigns.tsx
Normal file
205
decnet_web/src/components/Campaigns.tsx
Normal 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;
|
||||
315
decnet_web/src/components/CanaryTokenDrawer.tsx
Normal file
315
decnet_web/src/components/CanaryTokenDrawer.tsx
Normal 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;
|
||||
731
decnet_web/src/components/CanaryTokens.tsx
Normal file
731
decnet_web/src/components/CanaryTokens.tsx
Normal 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;
|
||||
116
decnet_web/src/components/CommandPalette/CommandPalette.css
Normal file
116
decnet_web/src/components/CommandPalette/CommandPalette.css
Normal 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;
|
||||
}
|
||||
159
decnet_web/src/components/CommandPalette/CommandPalette.tsx
Normal file
159
decnet_web/src/components/CommandPalette/CommandPalette.tsx
Normal 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;
|
||||
282
decnet_web/src/components/Config.css
Normal file
282
decnet_web/src/components/Config.css
Normal 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;
|
||||
}
|
||||
@@ -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: <number><unit> — 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> < 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>
|
||||
);
|
||||
};
|
||||
|
||||
170
decnet_web/src/components/CredentialReuseInspector.tsx
Normal file
170
decnet_web/src/components/CredentialReuseInspector.tsx
Normal 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;
|
||||
274
decnet_web/src/components/Credentials.css
Normal file
274
decnet_web/src/components/Credentials.css
Normal 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); }
|
||||
429
decnet_web/src/components/Credentials.tsx
Normal file
429
decnet_web/src/components/Credentials.tsx
Normal 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;
|
||||
142
decnet_web/src/components/CredentialsInspector.tsx
Normal file
142
decnet_web/src/components/CredentialsInspector.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
344
decnet_web/src/components/DeckyFleet.css
Normal file
344
decnet_web/src/components/DeckyFleet.css
Normal 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
63
decnet_web/src/components/EmptyState/EmptyState.css
Normal file
63
decnet_web/src/components/EmptyState/EmptyState.css
Normal 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);
|
||||
}
|
||||
46
decnet_web/src/components/EmptyState/EmptyState.tsx
Normal file
46
decnet_web/src/components/EmptyState/EmptyState.tsx
Normal 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;
|
||||
223
decnet_web/src/components/Identities.tsx
Normal file
223
decnet_web/src/components/Identities.tsx
Normal 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;
|
||||
299
decnet_web/src/components/IdentityDetail.tsx
Normal file
299
decnet_web/src/components/IdentityDetail.tsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
182
decnet_web/src/components/LiveLogs.css
Normal file
182
decnet_web/src/components/LiveLogs.css
Normal 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); }
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
216
decnet_web/src/components/MailDrawer.tsx
Normal file
216
decnet_web/src/components/MailDrawer.tsx
Normal 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;
|
||||
281
decnet_web/src/components/MazeNET/Canvas.tsx
Normal file
281
decnet_web/src/components/MazeNET/Canvas.tsx
Normal 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;
|
||||
98
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal file
98
decnet_web/src/components/MazeNET/ContextMenu.tsx
Normal 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;
|
||||
303
decnet_web/src/components/MazeNET/Inspector.tsx
Normal file
303
decnet_web/src/components/MazeNET/Inspector.tsx
Normal 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;
|
||||
590
decnet_web/src/components/MazeNET/MazeNET.css
Normal file
590
decnet_web/src/components/MazeNET/MazeNET.css
Normal 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);
|
||||
}
|
||||
816
decnet_web/src/components/MazeNET/MazeNET.tsx
Normal file
816
decnet_web/src/components/MazeNET/MazeNET.tsx
Normal 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;
|
||||
91
decnet_web/src/components/MazeNET/NetBox.tsx
Normal file
91
decnet_web/src/components/MazeNET/NetBox.tsx
Normal 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);
|
||||
103
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal file
103
decnet_web/src/components/MazeNET/NodeCard.tsx
Normal 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);
|
||||
117
decnet_web/src/components/MazeNET/Palette.tsx
Normal file
117
decnet_web/src/components/MazeNET/Palette.tsx
Normal 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;
|
||||
98
decnet_web/src/components/MazeNET/data.ts
Normal file
98
decnet_web/src/components/MazeNET/data.ts
Normal 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' },
|
||||
];
|
||||
|
||||
64
decnet_web/src/components/MazeNET/types.ts
Normal file
64
decnet_web/src/components/MazeNET/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
415
decnet_web/src/components/MazeNET/useMazeApi.ts
Normal file
415
decnet_web/src/components/MazeNET/useMazeApi.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
424
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal file
424
decnet_web/src/components/MazeNET/useMazeInteraction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal file
111
decnet_web/src/components/MazeNET/useMazeLayoutStore.ts
Normal 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), []);
|
||||
}
|
||||
220
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal file
220
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal 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]);
|
||||
}
|
||||
107
decnet_web/src/components/MazeNET/useTopologyStream.ts
Normal file
107
decnet_web/src/components/MazeNET/useTopologyStream.ts
Normal 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]);
|
||||
}
|
||||
24
decnet_web/src/components/Modal/Modal.css
Normal file
24
decnet_web/src/components/Modal/Modal.css
Normal 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);
|
||||
}
|
||||
85
decnet_web/src/components/Modal/Modal.tsx
Normal file
85
decnet_web/src/components/Modal/Modal.tsx
Normal 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;
|
||||
432
decnet_web/src/components/Orchestrator.css
Normal file
432
decnet_web/src/components/Orchestrator.css
Normal 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);
|
||||
}
|
||||
350
decnet_web/src/components/Orchestrator.tsx
Normal file
350
decnet_web/src/components/Orchestrator.tsx
Normal 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;
|
||||
216
decnet_web/src/components/OrchestratorInspector.tsx
Normal file
216
decnet_web/src/components/OrchestratorInspector.tsx
Normal 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;
|
||||
107
decnet_web/src/components/PersonaGeneration.css
Normal file
107
decnet_web/src/components/PersonaGeneration.css
Normal 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; }
|
||||
875
decnet_web/src/components/PersonaGeneration.tsx
Normal file
875
decnet_web/src/components/PersonaGeneration.tsx
Normal 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 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} />;
|
||||
};
|
||||
122
decnet_web/src/components/RealismConfig/RealismConfig.css
Normal file
122
decnet_web/src/components/RealismConfig/RealismConfig.css
Normal 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;
|
||||
}
|
||||
264
decnet_web/src/components/RealismConfig/RealismConfig.tsx
Normal file
264
decnet_web/src/components/RealismConfig/RealismConfig.tsx
Normal 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;
|
||||
323
decnet_web/src/components/RemoteUpdates.tsx
Normal file
323
decnet_web/src/components/RemoteUpdates.tsx
Normal 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;
|
||||
241
decnet_web/src/components/SessionDrawer.tsx
Normal file
241
decnet_web/src/components/SessionDrawer.tsx
Normal 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;
|
||||
59
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.css
Normal file
59
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.css
Normal 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;
|
||||
}
|
||||
77
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.tsx
Normal file
77
decnet_web/src/components/ShortcutsHelp/ShortcutsHelp.tsx
Normal 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;
|
||||
179
decnet_web/src/components/Swarm.css
Normal file
179
decnet_web/src/components/Swarm.css
Normal 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;
|
||||
}
|
||||
510
decnet_web/src/components/SwarmHosts.tsx
Normal file
510
decnet_web/src/components/SwarmHosts.tsx
Normal 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;
|
||||
214
decnet_web/src/components/SyntheticFiles/SyntheticFiles.css
Normal file
214
decnet_web/src/components/SyntheticFiles/SyntheticFiles.css
Normal 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;
|
||||
}
|
||||
376
decnet_web/src/components/SyntheticFiles/SyntheticFiles.tsx
Normal file
376
decnet_web/src/components/SyntheticFiles/SyntheticFiles.tsx
Normal 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;
|
||||
33
decnet_web/src/components/Toasts/ToastProvider.tsx
Normal file
33
decnet_web/src/components/Toasts/ToastProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
decnet_web/src/components/Toasts/Toasts.css
Normal file
33
decnet_web/src/components/Toasts/Toasts.css
Normal 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; }
|
||||
}
|
||||
44
decnet_web/src/components/Toasts/Toasts.tsx
Normal file
44
decnet_web/src/components/Toasts/Toasts.tsx
Normal 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;
|
||||
19
decnet_web/src/components/Toasts/toast-context.ts
Normal file
19
decnet_web/src/components/Toasts/toast-context.ts
Normal 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);
|
||||
9
decnet_web/src/components/Toasts/useToast.ts
Normal file
9
decnet_web/src/components/Toasts/useToast.ts
Normal 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;
|
||||
}
|
||||
241
decnet_web/src/components/TopologyList/CreateTopologyWizard.css
Normal file
241
decnet_web/src/components/TopologyList/CreateTopologyWizard.css
Normal 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);
|
||||
}
|
||||
385
decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx
Normal file
385
decnet_web/src/components/TopologyList/CreateTopologyWizard.tsx
Normal 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;
|
||||
122
decnet_web/src/components/TopologyList/TopologyList.css
Normal file
122
decnet_web/src/components/TopologyList/TopologyList.css
Normal 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);
|
||||
}
|
||||
276
decnet_web/src/components/TopologyList/TopologyList.tsx
Normal file
276
decnet_web/src/components/TopologyList/TopologyList.tsx
Normal 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;
|
||||
341
decnet_web/src/components/Webhooks.css
Normal file
341
decnet_web/src/components/Webhooks.css
Normal 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); }
|
||||
642
decnet_web/src/components/Webhooks.tsx
Normal file
642
decnet_web/src/components/Webhooks.tsx
Normal 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;
|
||||
100
decnet_web/src/components/useCampaignStream.ts
Normal file
100
decnet_web/src/components/useCampaignStream.ts
Normal 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]);
|
||||
}
|
||||
113
decnet_web/src/components/useIdentityStream.ts
Normal file
113
decnet_web/src/components/useIdentityStream.ts
Normal 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]);
|
||||
}
|
||||
98
decnet_web/src/components/useOrchestratorStream.ts
Normal file
98
decnet_web/src/components/useOrchestratorStream.ts
Normal 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]);
|
||||
}
|
||||
15
decnet_web/src/hooks/useEscapeKey.ts
Normal file
15
decnet_web/src/hooks/useEscapeKey.ts
Normal 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]);
|
||||
}
|
||||
19
decnet_web/src/hooks/useFocusSearch.ts
Normal file
19
decnet_web/src/hooks/useFocusSearch.ts
Normal 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]);
|
||||
}
|
||||
49
decnet_web/src/hooks/useFocusTrap.ts
Normal file
49
decnet_web/src/hooks/useFocusTrap.ts
Normal 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]);
|
||||
}
|
||||
98
decnet_web/src/hooks/useGlobalHotkeys.ts
Normal file
98
decnet_web/src/hooks/useGlobalHotkeys.ts
Normal 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]);
|
||||
}
|
||||
42
decnet_web/src/hooks/useSwarmHosts.ts
Normal file
42
decnet_web/src/hooks/useSwarmHosts.ts
Normal 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
104
decnet_web/src/icons.ts
Normal 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';
|
||||
@@ -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
16
decnet_web/src/lucide-icons.d.ts
vendored
Normal 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;
|
||||
}
|
||||
39
decnet_web/src/realism/labels.ts
Normal file
39
decnet_web/src/realism/labels.ts
Normal 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_');
|
||||
}
|
||||
40
decnet_web/src/routePrefetch.ts
Normal file
40
decnet_web/src/routePrefetch.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
44
decnet_web/src/utils/parseEventBody.ts
Normal file
44
decnet_web/src/utils/parseEventBody.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user