feat(web): command palette, toasts, and global shell chrome

- CommandPalette (Alt+K): fuzzy action launcher with keyboard nav.
- Toasts: ephemeral notification stack + provider.
- useGlobalHotkeys: Alt+K palette toggle, G-chord navigation
  (G D/F/M/L/B/A/S/U/E/C), respects editable-element focus.
- Layout/App: wire ToastProvider at root, mount the palette inside the
  authed shell, introduce the global search box in the top bar.
- MazeNETRoute now renders TopologyList inline when no ?topology is
  present, instead of bouncing through a redirect.
- index.css: a few global token tweaks consumed by the new chrome.

Fixes a latent breakage: Config.tsx and MazeNET already imported
./Toasts/useToast but the directory was never committed.
This commit is contained in:
2026-04-22 17:15:19 -04:00
parent dca6eddd5f
commit ccbe949238
13 changed files with 935 additions and 59 deletions

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
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';
@@ -14,13 +14,17 @@ import SwarmHosts from './components/SwarmHosts';
import AgentEnrollment from './components/AgentEnrollment';
import MazeNET from './components/MazeNET/MazeNET';
import TopologyList from './components/TopologyList/TopologyList';
import CommandPalette from './components/CommandPalette/CommandPalette';
import { ToastProvider } from './components/Toasts/ToastProvider';
import { useToast } from './components/Toasts/useToast';
import { useGlobalHotkeys } from './hooks/useGlobalHotkeys';
/* Guard the /mazenet route so it's always bound to a real topology.
* Bare /mazenet → /topologies; ?topology=<id> → editor. */
/* 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 /> : <Navigate to="/topologies" replace />;
return hasId ? <MazeNET /> : <TopologyList />;
}
function isTokenValid(token: string): boolean {
@@ -39,40 +43,39 @@ function getValidToken(): string | null {
return null;
}
function App() {
const [token, setToken] = useState<string | null>(getValidToken);
const [searchQuery, setSearchQuery] = useState('');
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',
};
useEffect(() => {
const onAuthLogout = () => setToken(null);
window.addEventListener('auth:logout', onAuthLogout);
return () => window.removeEventListener('auth:logout', onAuthLogout);
}, []);
interface AuthedShellProps {
onLogout: () => void;
onSearch: (q: string) => void;
searchQuery: string;
}
const handleLogin = (newToken: string) => {
setToken(newToken);
const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQuery }) => {
const navigate = useNavigate();
const { push } = useToast();
const [cmdOpen, setCmdOpen] = useState(false);
useGlobalHotkeys({ cmdOpen, setCmdOpen });
const handleAction = (id: string) => {
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' });
};
const handleLogout = () => {
localStorage.removeItem('token');
setToken(null);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
};
if (!token) {
return <Login onLogin={handleLogin} />;
}
return (
<Router>
<Layout onLogout={handleLogout} onSearch={handleSearch}>
<>
<Layout onLogout={onLogout} onSearch={onSearch} onOpenCmd={() => setCmdOpen(true)}>
<Routes>
<Route path="/" element={<Dashboard searchQuery={searchQuery} />} />
<Route path="/fleet" element={<DeckyFleet />} />
<Route path="/topologies" element={<TopologyList />} />
<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="/bounty" element={<Bounty />} />
@@ -85,6 +88,51 @@ function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
<CommandPalette
open={cmdOpen}
onClose={() => setCmdOpen(false)}
onNav={navigate}
onAction={handleAction}
/>
</>
);
};
function App() {
const [token, setToken] = useState<string | null>(getValidToken);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
const onAuthLogout = () => setToken(null);
window.addEventListener('auth:logout', onAuthLogout);
return () => window.removeEventListener('auth:logout', onAuthLogout);
}, []);
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 handleLogin = (newToken: string) => setToken(newToken);
const handleLogout = () => { localStorage.removeItem('token'); setToken(null); };
const handleSearch = (query: string) => setSearchQuery(query);
if (!token) {
return <Login onLogin={handleLogin} />;
}
return (
<Router>
<ToastProvider>
<AuthedShell onLogout={handleLogout} onSearch={handleSearch} searchQuery={searchQuery} />
</ToastProvider>
</Router>
);
}

View File

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

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
} from 'lucide-react';
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: 'Logs', icon: Terminal, kbd: 'G L', kind: 'nav', payload: '/live-logs' },
{ 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: 'Agent Enrollment', icon: UserPlus, kbd: 'G E', kind: 'nav', payload: '/swarm/enroll' },
{ 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' },
];
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 && (
<div className="cmd-empty">NO COMMAND MATCHES</div>
)}
</div>
<div className="cmd-hint">
<span> NAVIGATE · SELECT</span>
<span>DECNET CLI</span>
</div>
</div>
</div>
);
};
export default CommandPalette;

View File

@@ -70,11 +70,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 {
@@ -124,6 +140,17 @@
.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 {
@@ -157,18 +184,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 {
@@ -187,8 +261,59 @@
box-shadow: none;
}
.search-kbd {
font-size: 0.6rem;
border: 1px solid var(--border-color);
padding: 1px 5px;
opacity: 0.5;
letter-spacing: 1px;
margin-left: 8px;
white-space: nowrap;
}
.threat-level {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.7rem;
letter-spacing: 1px;
padding: 6px 10px;
border: 1px solid var(--alert);
color: var(--alert);
background: rgba(255, 65, 65, 0.08);
text-transform: uppercase;
}
.threat-level .dot {
width: 6px;
height: 6px;
background: var(--alert);
border-radius: 50%;
animation: decnet-pulse 1s infinite alternate;
}
.topbar-clock {
font-size: 0.75rem;
opacity: 0.6;
letter-spacing: 1px;
font-variant-numeric: tabular-nums;
}
.topbar-status {
font-size: 0.8rem;
font-size: 0.7rem;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 8px;
}
@media (max-width: 1280px) {
.threat-level span:last-child { display: none; }
.topbar-clock { display: none; }
}
@media (max-width: 980px) {
.crumbs { display: none; }
}
.neon-blink {

View File

@@ -1,18 +1,68 @@
import React, { useState, useEffect } from 'react';
import { NavLink } from 'react-router-dom';
import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, UserPlus } from 'lucide-react';
import { NavLink, useLocation } from 'react-router-dom';
import {
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
UserPlus, ShieldAlert,
} from 'lucide-react';
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': 'LOGS',
'/bounty': 'BOUNTY',
'/attackers': 'ATTACKERS',
'/config': 'CONFIG',
'/swarm-updates': 'REMOTE UPDATES',
'/swarm/hosts': 'SWARM HOSTS',
'/swarm/enroll': 'AGENT ENROLLMENT',
};
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,13 +71,24 @@ const Layout: React.FC<LayoutProps> = ({ children, onLogout, onSearch }) => {
useEffect(() => {
const onStats = (e: Event) => {
const stats = (e as CustomEvent).detail;
setSystemActive(stats.deployed_deckies > 0);
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);
};
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 */}
@@ -39,13 +100,18 @@ 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="/topologies" icon={<Network size={20} />} label="Topologies" open={sidebarOpen} />
<NavItem to="/mazenet" icon={<Network size={20} />} label="MazeNET" open={sidebarOpen} />
<NavItem to="/live-logs" icon={<Terminal size={20} />} label="Live Logs" open={sidebarOpen} />
<NavItem
to="/live-logs"
icon={<Terminal size={20} />}
label="Logs"
open={sidebarOpen}
badge={alertCount}
/>
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
@@ -61,6 +127,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>
@@ -68,19 +141,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>
@@ -99,9 +195,10 @@ interface NavItemProps {
label: string;
open: boolean;
indent?: boolean;
badge?: number;
}
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent }) => (
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent, badge }) => (
<NavLink
to={to}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
@@ -109,6 +206,9 @@ const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent }) => (
>
{icon}
{open && <span className="nav-label">{label}</span>}
{open && badge !== undefined && badge > 0 && (
<span className="nav-badge">{badge > 99 ? '99+' : badge}</span>
)}
</NavLink>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface Options {
cmdOpen: boolean;
setCmdOpen: (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',
e: '/swarm/enroll',
};
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 }: 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) return;
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) 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, navigate]);
}

View File

@@ -93,6 +93,19 @@
--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 {