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:
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user