Fix dashboard black screen, add login gate, and expand README.

Pin React Three Fiber to React 18, remove PWA caching, add SessionGate and error boundaries, and document movie fusion, auth, outputs, and troubleshooting.
This commit is contained in:
drjones
2026-05-29 08:50:48 -07:00
parent b99c8aab15
commit e11fb30350
14 changed files with 601 additions and 4151 deletions

View File

@@ -207,7 +207,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
return
}
// SPA fallback - serve index.html
// SPA fallback - serve index.html (never cache: hashed assets change each build)
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
http.ServeFile(w, r, filepath.Join(webRoot, "index.html"))
})
} else {

View File

@@ -10,6 +10,19 @@
</head>
<body>
<div id="root"></div>
<script>
(function () {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (r) { r.unregister(); });
});
if (window.caches) {
caches.keys().then(function (keys) {
keys.forEach(function (k) { caches.delete(k); });
});
}
})();
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
"test": "vitest run"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.184.1",
"qrcode": "^1.5.4",
@@ -19,8 +19,7 @@
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0",
"three": "^0.184.0",
"vite-plugin-pwa": "^1.3.0"
"three": "^0.170.0"
},
"devDependencies": {
"@types/react": "^18.2.37",

View File

@@ -1,24 +1,39 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import DashboardPage from './pages/DashboardPage';
import AgentsPage from './pages/AgentsPage';
import BuilderPage from './pages/BuilderPage';
import SettingsPage from './pages/SettingsPage';
import GuidePage from './pages/GuidePage';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
const BuilderPage = lazy(() => import('./pages/BuilderPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage'));
function PageFallback() {
return (
<div className="session-gate" style={{ minHeight: '40vh' }}>
<p className="font-tech">Loading</p>
</div>
);
}
function App() {
return (
<SessionGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/forge" element={<BuilderPage />} />
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/forge" element={<BuilderPage />} />
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</Layout>
</SessionGate>
);
}

View File

@@ -0,0 +1,39 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('UI error:', error, info);
}
render() {
if (this.state.error) {
return (
this.props.fallback ?? (
<div className="error-boundary-fallback card">
<h3>Something failed to render</h3>
<p className="form-hint">{this.state.error.message}</p>
<button type="button" className="btn btn-primary" onClick={() => this.setState({ error: null })}>
Retry
</button>
</div>
)
);
}
return this.props.children;
}
}

View File

@@ -205,7 +205,7 @@
.main-content {
flex: 1;
margin-left: 260px;
margin-left: 0;
padding: 2rem 2.5rem;
min-height: 100vh;
position: relative;
@@ -243,7 +243,7 @@
}
.main-content {
margin-left: 72px;
margin-left: 0;
padding: 1rem;
}
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useState, type ReactNode } from 'react';
import { getStoredAuth, setStoredAuth } from '../api/auth';
export default function SessionGate({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [user, setUser] = useState('drjones');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setReady(true);
return;
}
fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } })
.then((r) => {
setAuthed(r.ok);
setReady(true);
})
.catch(() => setReady(true));
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
const token = btoa(`${user}:${pass}`);
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
};
if (!ready) {
return (
<div className="session-gate">
<p className="font-tech">Starting AetherForge</p>
</div>
);
}
if (!authed) {
return (
<div className="session-gate">
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
<label className="label">Username</label>
<input className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label">Password</label>
<input
className="input"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
/>
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="form-hint">Default: drjones / czapiewski (change under Calibrate Users)</p>
</form>
</div>
);
}
return <>{children}</>;
}

View File

@@ -1,5 +1,5 @@
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Stars, Line, Sphere, Text } from '@react-three/drei';
import { OrbitControls, Stars, Line, Sphere } from '@react-three/drei';
import { useMemo, useRef } from 'react';
import * as THREE from 'three';
import { Agent } from '../../../types';
@@ -48,12 +48,6 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
<Sphere ref={pulseRef} args={[0.3, 16, 16]}>
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={isOnline ? (isHashing ? 2 : 1) : 0.2} wireframe />
</Sphere>
<Text position={[0, -0.6, 0]} fontSize={0.2} color="white" anchorX="center" anchorY="middle">
{agent.name}
</Text>
<Text position={[0, -0.85, 0]} fontSize={0.15} color={color} anchorX="center" anchorY="middle">
{isOnline ? `${(agent.hashrate_15m).toFixed(0)} H/s` : 'OFFLINE'}
</Text>
{/* Connection Line */}
<Line points={[[0,0,0], [serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]]} color={isOnline ? '#004455' : '#330000'} lineWidth={1} transparent opacity={0.4} />
@@ -101,9 +95,6 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
<Sphere args={[0.3, 16, 16]}>
<meshStandardMaterial color="#ffffff" emissive="#ffffff" emissiveIntensity={2} />
</Sphere>
<Text position={[0, -1.2, 0]} fontSize={0.35} color="#ffb020" anchorX="center" anchorY="middle">
MOTHERSHIP
</Text>
</group>
{/* Agent Nodes */}

View File

@@ -2,13 +2,28 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css';
import './styles/steampunk-theme.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ErrorBoundary
fallback={
<div className="session-gate">
<div className="session-gate-card card">
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">The dashboard failed to load. Hard-refresh (Ctrl+Shift+R) or clear site data for this host.</p>
<button type="button" className="btn btn-primary" onClick={() => window.location.reload()}>
Reload
</button>
</div>
</div>
}
>
<BrowserRouter>
<App />
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -10,6 +10,7 @@ import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualC
import { AlertBanner, PoolStatusPanel, AIActivityPanel, EarningsEstimator } from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import ErrorBoundary from '../components/ErrorBoundary';
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
import {
@@ -246,7 +247,15 @@ export default function DashboardPage() {
</NeonCard>
<section className="section">
<FleetTopologyMap agents={agents} />
<ErrorBoundary
fallback={
<div className="card">
<p className="form-hint">3D fleet map unavailable on this GPU rest of the deck still works.</p>
</div>
}
>
<FleetTopologyMap agents={agents} />
</ErrorBoundary>
</section>
<section className="section">

View File

@@ -33,6 +33,28 @@ body {
min-height: 100vh;
}
.session-gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: radial-gradient(ellipse at center, #121a2e 0%, #05070d 70%);
}
.session-gate-card {
width: min(420px, 100%);
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 2rem;
}
.error-boundary-fallback {
padding: 1.5rem;
margin: 1rem 0;
}
a {
color: var(--accent-blue);
text-decoration: none;

View File

@@ -1,36 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true
},
manifest: {
name: 'AetherForge Command Deck',
short_name: 'AetherForge',
theme_color: '#0d0d12',
background_color: '#000000',
display: 'standalone',
icons: [
{
src: '/vite.svg',
sizes: '192x192',
type: 'image/svg+xml'
},
{
src: '/vite.svg',
sizes: '512x512',
type: 'image/svg+xml'
}
]
}
})
],
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom'],
},
server: {
port: 5173,
proxy: {