diff --git a/decnet_web/package-lock.json b/decnet_web/package-lock.json index 913bd2aa..97c40743 100644 --- a/decnet_web/package-lock.json +++ b/decnet_web/package-lock.json @@ -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", diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index ce1e9ad7..4b145a88 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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= → editor. */ +/* Unified MazeNET entrypoint: no ?topology → topology selector, + * ?topology= → editor bound to that topology. */ function MazeNETRoute() { const qs = typeof window !== 'undefined' ? window.location.search : ''; const hasId = new URLSearchParams(qs).get('topology'); - return hasId ? : ; + return hasId ? : ; } function isTokenValid(token: string): boolean { @@ -39,40 +43,39 @@ function getValidToken(): string | null { return null; } -function App() { - const [token, setToken] = useState(getValidToken); - const [searchQuery, setSearchQuery] = useState(''); +const ACTION_LABELS: Record = { + '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 = ({ 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 ; - } - return ( - - + <> + setCmdOpen(true)}> } /> - } /> - } /> + } /> + } /> } /> } /> } /> @@ -85,6 +88,51 @@ function App() { } /> + setCmdOpen(false)} + onNav={navigate} + onAction={handleAction} + /> + + ); +}; + +function App() { + const [token, setToken] = useState(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 ; + } + + return ( + + + + ); } diff --git a/decnet_web/src/components/CommandPalette/CommandPalette.css b/decnet_web/src/components/CommandPalette/CommandPalette.css new file mode 100644 index 00000000..972353be --- /dev/null +++ b/decnet_web/src/components/CommandPalette/CommandPalette.css @@ -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; +} diff --git a/decnet_web/src/components/CommandPalette/CommandPalette.tsx b/decnet_web/src/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 00000000..ebbf42d8 --- /dev/null +++ b/decnet_web/src/components/CommandPalette/CommandPalette.tsx @@ -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 = ({ open, onClose, onNav, onAction }) => { + const [query, setQuery] = useState(''); + const [sel, setSel] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(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(`[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>((acc, it) => { + (acc[it.section] ||= []).push(it); + return acc; + }, {}); + + let idx = -1; + + return ( +
+
e.stopPropagation()}> +
+ + setQuery(e.target.value)} + onKeyDown={handleKey} + placeholder="Type a command or search…" + /> + ESC +
+
+ {Object.entries(groups).map(([section, items]) => ( +
+
{section}
+ {items.map(it => { + idx++; + const active = idx === sel; + const Icon = it.icon; + return ( +
fire(it)} + onMouseEnter={() => setSel(filtered.indexOf(it))} + > + + {it.label} + {it.kbd && {it.kbd}} +
+ ); + })} +
+ ))} + {filtered.length === 0 && ( +
NO COMMAND MATCHES
+ )} +
+
+ ↑↓ NAVIGATE · ⏎ SELECT + DECNET CLI +
+
+
+ ); +}; + +export default CommandPalette; diff --git a/decnet_web/src/components/Layout.css b/decnet_web/src/components/Layout.css index 82f8eed5..2ab89a85 100644 --- a/decnet_web/src/components/Layout.css +++ b/decnet_web/src/components/Layout.css @@ -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 { diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 666e9594..8fb99895 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -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 = ({ children, onLogout, onSearch }) => { +const ROUTE_LABELS: Record = { + '/': '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 = ({ + 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(threatProp); + const [alertCount, setAlertCount] = useState(alertCountProp); + const location = useLocation(); const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -21,13 +71,24 @@ const Layout: React.FC = ({ 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 (
{/* Sidebar */} @@ -39,13 +100,18 @@ const Layout: React.FC = ({ children, onLogout, onSearch }) => { {sidebarOpen ? : }
- +