Files
AetherForge/server/web/src/components/Layout/Layout.tsx
AetherForge 35c3271f20
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Integrator: AWS gate fixes and doc counts after cloud landing.
Apply SwarmMagnet, uiHelp, AccessDepthPanel, Seer, miningsurgery, and path-tracer test fixes; add agent cloud telemetry fields and main strings import; refresh tests/README and PROBLEMS AWS operator notes.
2026-06-07 10:52:38 -07:00

495 lines
18 KiB
TypeScript

import { ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
import MatrixRain from './MatrixRain';
import afLogo from '../../assets/af-logo.png';
import CursorFire from '../Visual/CursorFire';
import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer';
import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { mergeScoutBiomeWeather, type ScoutConstellationSnapshot } from '../../help/scoutBiomeWeather';
import { isDashboardRoute } from '../../help/routeEffects';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import { formatAppVersion } from '../../help/appVersion';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
import './Layout.css';
import './MobileNav.css';
import '../../styles/operatorDeck.css';
interface LayoutProps {
children: ReactNode;
}
function operatorDeckId(pathname: string): string {
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
if (path.startsWith('/mission-deck')) return 'mission-deck';
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
if (path.startsWith('/crucible')) return 'crucible';
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
if (path.startsWith('/builds')) return 'builds';
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline';
if (path.startsWith('/roi')) return 'roi';
if (path.startsWith('/activity')) return 'activity';
if (path.startsWith('/seer')) return 'seer';
return 'dashboard';
}
type NavItem = { readonly to: string; readonly label: string; readonly icon: string; readonly glow?: boolean };
const NAV_BASE: readonly NavItem[] = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/activity', label: 'Activity Feed', icon: 'activity' },
{ to: '/roi', label: 'ROI Intelligence', icon: 'roi' },
{ to: '/lotl-timeline', label: 'Onion', icon: 'onion' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
];
const SEER_NAV: NavItem = { to: '/seer', label: 'Seer', icon: 'seer' };
function buildNav(aiControlEnabled: boolean): NavItem[] {
if (!aiControlEnabled) {
return [...NAV_BASE];
}
const items: NavItem[] = [...NAV_BASE];
const calibrateIdx = items.findIndex((i) => i.to === '/settings');
items.splice(calibrateIdx, 0, SEER_NAV);
return items;
}
const NAV = NAV_BASE;
const DOCS_HREF = '/docs/';
/** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */
const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Path Tracer, Forge, Mission Deck, Builds, Emberwake, Calibrate — "More" sheet */
const MOBILE_MORE = NAV.slice(5);
function NavIcon({ type }: { type: string }) {
switch (type) {
case 'deck':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 19V5h16v14M4 9h16M8 5v4M16 5v4" />
<circle cx="12" cy="14" r="2" />
</svg>
);
case 'roi':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 17l4-6 4 3 4-7 4 4" />
<path d="M3 20h18" strokeOpacity="0.4" />
<circle cx="19" cy="11" r="1.5" fill="currentColor" strokeWidth="0" />
</svg>
);
case 'activity':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<polyline points="2,12 6,12 8,5 10,19 12,9 14,15 16,12 22,12" />
</svg>
);
case 'fleet':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="6" width="20" height="12" rx="1" />
<path d="M6 10h4M14 10h4M6 14h2M16 14h2" />
</svg>
);
case 'forge':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 4l6 6-8 8H6v-6l8-8z" />
<path d="M8 16l-2 4 4-2" />
</svg>
);
case 'mission':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" />
</svg>
);
case 'builds':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="14" width="18" height="5" rx="1" />
<rect x="3" y="8" width="18" height="5" rx="1" />
<rect x="3" y="2" width="18" height="5" rx="1" />
<path d="M7 4.5h10M7 10.5h10M7 16.5h10" strokeOpacity="0.35" />
</svg>
);
case 'crucible':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 3h8l1 5H7L8 3z" />
<path d="M7 8c0 5 2 8 5 10c3-2 5-5 5-10" />
<path d="M4 21h16" />
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'onion':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="13" r="6" />
<circle cx="12" cy="13" r="3" strokeOpacity="0.45" />
<path d="M12 4v3M12 19v3M4 13h3M17 13h3" strokeOpacity="0.5" />
</svg>
);
case 'ember':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 3c2 4 4 5 4 9a4 4 0 01-8 0c0-4 2-5 4-9z" />
<path d="M8 21h8" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="4" cy="12" r="2" />
<circle cx="12" cy="6" r="2" />
<circle cx="20" cy="12" r="2" />
<path d="M6 12h4l2-6 2 6h4" />
<path d="M12 8v4" />
</svg>
);
case 'seer':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<ellipse cx="12" cy="12" rx="9" ry="5" />
<circle cx="12" cy="12" r="2.5" fill="currentColor" strokeWidth="0" />
<path d="M4 12c2-3 5-4.5 8-4.5s6 1.5 8 4.5" strokeOpacity="0.45" />
</svg>
);
case 'docs':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
}
function formatHashrate(hs: number): string {
if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`;
if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} KH/s`;
return `${hs.toFixed(0)} H/s`;
}
const FleetReadout = memo(function FleetReadout() {
const { agents } = useWebSocket();
const { online, total, totalHashrate, fillPct } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
const tot = agents.length;
const hr = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
return {
online: on,
total: tot,
totalHashrate: hr,
fillPct: tot > 0 ? Math.round((on / tot) * 100) : 0,
};
}, [agents]);
// Tick animation: flash hashrate value whenever it meaningfully changes
const [flash, setFlash] = useState(false);
const prevHash = useRef(0);
useEffect(() => {
if (Math.abs(totalHashrate - prevHash.current) > 1) {
prevHash.current = totalHashrate;
setFlash(true);
const t = setTimeout(() => setFlash(false), 600);
return () => clearTimeout(t);
}
}, [totalHashrate]);
return (
<div className="fleet-readout">
<div className="readout-row">
<span className={`readout-dot ${online > 0 ? 'readout-dot-online' : 'readout-dot-idle'}`} />
<span className="readout-label">MACHINES</span>
<span className="readout-value readout-value-machines">
{online}<span className="readout-dim">/{total}</span>
</span>
</div>
<div className="readout-row">
<span className="readout-glyph"></span>
<span className="readout-label">HASHRATE</span>
<span className={`readout-value ${flash ? 'readout-flash' : ''}`}>
{totalHashrate > 0 ? formatHashrate(totalHashrate) : <span className="readout-dim">IDLE</span>}
</span>
</div>
<div className="readout-bar-wrap" title={`${online} of ${total} online`}>
<div className="readout-bar-track">
<div
className={`readout-bar-fill ${online > 0 ? 'readout-bar-active' : ''}`}
style={{ width: `${fillPct}%` }}
/>
</div>
<span className="readout-bar-pct">{fillPct}%</span>
</div>
</div>
);
});
const MobileTopStats = memo(function MobileTopStats() {
const { agents } = useWebSocket();
const { online, total, hr } = useMemo(() => {
const on = agents.filter((a) => a.status === 'online').length;
return {
online: on,
total: agents.length,
hr: agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0),
};
}, [agents]);
return (
<div className="mobile-top-stats">
<strong>
{online}/{total} online
</strong>
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
});
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const { othersOnline, comrades } = usePresence();
const { glowParticles } = useVisualEffects();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
setServerConfig(cfg);
setServerInfo(info);
})
.catch(() => {});
}, []);
useEffect(() => {
setMoreOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!moreOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const { latestMessage } = useWebSocket();
const scoutBiome = useMemo(() => {
if (latestMessage?.type !== 'scout_constellations') return null;
return latestMessage.payload as ScoutConstellationSnapshot;
}, [latestMessage]);
const pageWeather = useMemo(() => {
const base = resolvePageWeather(location.pathname);
const path = location.pathname.split('?')[0].replace(/\/$/, '') || '/';
if (path === '/emberwake' || path === '/spread' || path === '/dashboard' || path === '/agents') {
return mergeScoutBiomeWeather(base, scoutBiome);
}
return base;
}, [location.pathname, scoutBiome]);
const showDeckEffects = isDashboardRoute(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
'/crucible': 'Ops',
'/activity': 'Feed',
'/roi': 'ROI',
'/lotl-timeline': 'Onion',
'/pathtracer': 'Tracer',
'/forge': 'Forge',
};
return (
<div
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
<AmbientBackground weather={pageWeather} />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
<span className="logo-gear" />
<img src={afLogo} alt="AetherForge" className="logo-af-img" />
</div>
<div className="logo-text-block">
<span className="logo-text">AetherForge</span>
<span className="logo-tagline font-tech">COMMAND DECK</span>
</div>
</div>
</div>
<div className="sidebar-nav">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}`
}
>
<span className="nav-icon">
<NavIcon type={item.icon} />
</span>
<span className="nav-label">{item.label}</span>
{location.pathname === item.to && (
<span className={`nav-glow${'glow' in item && item.glow ? ' nav-glow--mission' : ''}`} />
)}
</NavLink>
))}
<a
href={DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className="nav-item nav-item--docs"
>
<span className="nav-icon">
<NavIcon type="docs" />
</span>
<span className="nav-label">Documentation</span>
</a>
</div>
<div className="sidebar-sacred-sigil" aria-hidden>
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — Command Deck only; fills lower sidebar between nav and footer */}
{showDeckEffects && <MatrixRain />}
<div className="sidebar-footer">
<FleetReadout />
{othersOnline && (
<div className="sidebar-comrades">
<div className="sidebar-comrades-avatars">
{comrades.slice(0, 4).map((c) => (
<ComradeAvatar key={c.user} user={c.user} size="sm" />
))}
</div>
<span className="sidebar-comrades-label">
{comrades.length} comrade{comrades.length === 1 ? '' : 's'} online
</span>
</div>
)}
<div className="sidebar-sig font-tech">
<span className="sig-love">made with <span className="sig-heart"></span> drjones</span>
<span className="sig-ver">{formatAppVersion(serverInfo?.version)}</span>
</div>
</div>
</nav>
{isMobile && (
<header className="mobile-top-bar">
<img src={afLogo} alt="" className="mobile-top-logo" />
<span className="mobile-top-title">AetherForge</span>
<MobileTopStats />
</header>
)}
<div className={`main-with-status${isMobile ? ' main-with-status--mobile' : ''}`}>
<SystemStatusBar />
<SetupBanner status={setupStatus} />
<main className="main-content">{children}</main>
</div>
{isMobile && (
<>
{moreOpen && (
<div
className="mobile-menu-backdrop"
role="presentation"
onClick={() => setMoreOpen(false)}
/>
)}
<div className={`mobile-more-sheet${moreOpen ? ' open' : ''}`}>
{MOBILE_MORE.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `mobile-more-link${isActive ? ' active' : ''}`}
onClick={() => setMoreOpen(false)}
>
<NavIcon type={item.icon} />
{item.label}
</NavLink>
))}
<a
href={DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
className="mobile-more-link mobile-more-link--docs"
onClick={() => setMoreOpen(false)}
>
<NavIcon type="docs" />
Documentation
</a>
</div>
<nav className="mobile-bottom-nav" aria-label="Main navigation">
{MOBILE_PRIMARY.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-bottom-nav-item${isActive ? ' active' : ''}`
}
>
<NavIcon type={item.icon} />
{mobileShortLabel[item.to] ?? item.label}
</NavLink>
))}
<button
type="button"
className={`mobile-bottom-nav-item${moreActive ? ' active' : ''}`}
aria-expanded={moreOpen}
aria-label="More pages"
onClick={() => setMoreOpen((o) => !o)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
</svg>
More
</button>
</nav>
</>
)}
</div>
);
}