319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
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, Sun, Moon,
|
|
} from '../icons';
|
|
import { prefetchRoute } from '../routePrefetch';
|
|
import { useThemeToggle } from '../lib/useThemeToggle';
|
|
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 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',
|
|
'/realism-llm': 'LLM PROVIDER',
|
|
'/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(() => {
|
|
try { return localStorage.getItem('decnet_sidebar_open') !== 'false'; } catch { return 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();
|
|
onSearch(search);
|
|
};
|
|
|
|
useEffect(() => {
|
|
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);
|
|
};
|
|
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 */}
|
|
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
|
|
<div className="sidebar-header">
|
|
<Activity size={24} className="violet-accent" />
|
|
{sidebarOpen && <span className="logo-text">DECNET</span>}
|
|
<button className="toggle-btn" onClick={() => setSidebarOpen(v => {
|
|
const next = !v;
|
|
try { localStorage.setItem('decnet_sidebar_open', String(next)); } catch {}
|
|
return next;
|
|
})}>
|
|
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
|
</button>
|
|
</div>
|
|
|
|
<nav className="sidebar-nav">
|
|
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" 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="/realism-llm" icon={<Cpu size={18} />} label="LLM Provider" 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>
|
|
|
|
<div className="sidebar-footer">
|
|
<button className="logout-btn" onClick={onLogout}>
|
|
<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>
|
|
|
|
{/* Main Content Area */}
|
|
<main className="main-content">
|
|
{/* Topbar */}
|
|
<header className="topbar">
|
|
<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>
|
|
)}
|
|
<ThemeToggleButton />
|
|
<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>
|
|
|
|
{/* Dynamic Content */}
|
|
<div className="content-viewport">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* Topbar dark/light toggle. Click coords drive the circular reveal
|
|
* animation in useThemeToggle so the swap visually propagates from
|
|
* the cursor outward — see lib/useThemeToggle.ts. */
|
|
const ThemeToggleButton: React.FC = () => {
|
|
const { theme, toggle } = useThemeToggle();
|
|
const isLight = theme === 'light';
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="theme-toggle-btn"
|
|
onClick={(e) => toggle({ clientX: e.clientX, clientY: e.clientY })}
|
|
aria-label={`Switch to ${isLight ? 'dark' : 'light'} mode`}
|
|
title={`Switch to ${isLight ? 'dark' : 'light'} mode`}
|
|
>
|
|
{isLight ? <Moon size={14} /> : <Sun size={14} />}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
interface NavItemProps {
|
|
to: string;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
open: boolean;
|
|
indent?: boolean;
|
|
badge?: number;
|
|
}
|
|
|
|
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 storageKey = `decnet_navgroup_${label.toLowerCase()}`;
|
|
const [expanded, setExpanded] = useState(() => {
|
|
try { return localStorage.getItem(storageKey) === 'true'; } catch { return false; }
|
|
});
|
|
const toggle = () => setExpanded(v => {
|
|
const next = !v;
|
|
try { localStorage.setItem(storageKey, String(next)); } catch {}
|
|
return next;
|
|
});
|
|
return (
|
|
<div className="nav-group">
|
|
<button
|
|
type="button"
|
|
className="nav-item nav-group-toggle"
|
|
onClick={toggle}
|
|
>
|
|
{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;
|