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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
4313
server/web/package-lock.json
generated
4313
server/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
39
server/web/src/components/ErrorBoundary.tsx
Normal file
39
server/web/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
73
server/web/src/components/SessionGate.tsx
Normal file
73
server/web/src/components/SessionGate.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user