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

@@ -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>
);