Add dashboard 3D fleet map, matrix stream overlay, and PWA support.
Integrates React Three Fiber topology view and a live matrix stream modal on the dashboard. Adds vite-plugin-pwa with AetherForge manifest and three.js dependencies.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0d0d12" />
|
||||
<link rel="apple-touch-icon" href="/vite.svg">
|
||||
<title>AetherForge — LAN Mining Command Deck</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
4760
server/web/package-lock.json
generated
4760
server/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,17 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/three": "^0.184.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.0"
|
||||
"recharts": "^2.10.0",
|
||||
"three": "^0.184.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
|
||||
118
server/web/src/components/Visual/3D/FleetTopologyMap.tsx
Normal file
118
server/web/src/components/Visual/3D/FleetTopologyMap.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls, Stars, Line, Sphere, Text } from '@react-three/drei';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { Agent } from '../../../types';
|
||||
|
||||
function LaserPulse({ start, end, color }: { start: [number, number, number], end: [number, number, number], color: string }) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
useFrame((state) => {
|
||||
if (meshRef.current) {
|
||||
// Move pulse from start to end
|
||||
const t = (state.clock.elapsedTime * 2) % 1;
|
||||
meshRef.current.position.set(
|
||||
start[0] + (end[0] - start[0]) * t,
|
||||
start[1] + (end[1] - start[1]) * t,
|
||||
start[2] + (end[2] - start[2]) * t
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<mesh ref={meshRef}>
|
||||
<sphereGeometry args={[0.08, 8, 8]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
|
||||
const isOnline = agent.status === 'online';
|
||||
const isHashing = agent.hashrate_15m > 0;
|
||||
const color = isOnline ? (isHashing ? '#00f5ff' : '#00aa55') : '#ff4444';
|
||||
const pulseRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
if (isOnline && pulseRef.current) {
|
||||
const scale = 1 + Math.sin(state.clock.elapsedTime * (isHashing ? 5 : 1)) * 0.15 * (isHashing ? 1 : 0.5);
|
||||
pulseRef.current.scale.set(scale, scale, scale);
|
||||
if (isHashing) {
|
||||
pulseRef.current.rotation.y += 0.05;
|
||||
pulseRef.current.rotation.x += 0.05;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group position={position}>
|
||||
<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} />
|
||||
|
||||
{/* Laser Pulse simulating hashing packets */}
|
||||
{isOnline && isHashing && (
|
||||
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00ffff" />
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
|
||||
const serverPos: [number, number, number] = [0, 0, 0];
|
||||
|
||||
const agentNodes = useMemo(() => {
|
||||
return agents.map((agent, i) => {
|
||||
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||
const angle = i * Math.PI * 2 * goldenRatio;
|
||||
// Distribute in a spherical/cylindrical rough cluster
|
||||
const radius = 4 + Math.random() * 3 + (i * 0.05);
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
const y = (Math.random() - 0.5) * 6;
|
||||
return { agent, position: [x, y, z] as [number, number, number] };
|
||||
});
|
||||
}, [agents]);
|
||||
|
||||
return (
|
||||
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 245, 255, 0.1)' }}>
|
||||
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
|
||||
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
|
||||
3D_MESH_TOPOLOGY // {agents.filter(a => a.status === 'online').length} NODES LINKED
|
||||
</div>
|
||||
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
|
||||
<color attach="background" args={['#050508']} />
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00f5ff" />
|
||||
<Stars radius={100} depth={50} count={3000} factor={3} saturation={0.5} fade speed={1} />
|
||||
|
||||
{/* Server Node (Mothership) */}
|
||||
<group position={serverPos}>
|
||||
<Sphere args={[0.7, 32, 32]}>
|
||||
<meshStandardMaterial color="#ffb020" emissive="#ffb020" emissiveIntensity={1.2} wireframe />
|
||||
</Sphere>
|
||||
<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 */}
|
||||
{agentNodes.map((node) => (
|
||||
<AgentNode key={node.agent.id} agent={node.agent} position={node.position} serverPos={serverPos} />
|
||||
))}
|
||||
|
||||
<OrbitControls enablePan={true} enableZoom={true} enableRotate={true} autoRotate autoRotateSpeed={0.8} />
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
server/web/src/components/Visual/MatrixStreamOverlay.css
Normal file
43
server/web/src/components/Visual/MatrixStreamOverlay.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.matrix-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: black;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.matrix-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.matrix-text-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 2rem;
|
||||
border: 1px solid #0f0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
|
||||
}
|
||||
|
||||
.matrix-text-overlay h2 {
|
||||
color: #0f0;
|
||||
margin: 0 0 1rem 0;
|
||||
font-family: monospace;
|
||||
font-size: 2rem;
|
||||
text-shadow: 0 0 10px #0f0;
|
||||
}
|
||||
|
||||
.matrix-close-hint {
|
||||
color: rgba(0, 255, 0, 0.7);
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
77
server/web/src/components/Visual/MatrixStreamOverlay.tsx
Normal file
77
server/web/src/components/Visual/MatrixStreamOverlay.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import './MatrixStreamOverlay.css';
|
||||
|
||||
export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) {
|
||||
const { recentShares } = useWebSocket();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$+-*/=%""\'#&_(),.;:?!\\|{}<>[]^~';
|
||||
const fontSize = 16;
|
||||
let columns = canvas.width / fontSize;
|
||||
let drops: number[] = Array(Math.floor(columns)).fill(1);
|
||||
|
||||
const draw = () => {
|
||||
// Black BG for the canvas
|
||||
// translucent BG to show trail
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = '#0F0'; // Green text
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
let text = letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
|
||||
// Occasionally drop a raw share payload in the stream
|
||||
if (Math.random() > 0.99 && recentShares.length > 0) {
|
||||
const share = recentShares[Math.floor(Math.random() * recentShares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0,6), hash: share.hash?.substring(0,8), valid: share.accepted });
|
||||
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
ctx.fillStyle = '#0F0'; // Reset color
|
||||
} else {
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
}
|
||||
|
||||
// sending the drop back to the top randomly after it has crossed the screen
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
|
||||
drops[i]++;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(draw, 33);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [active, recentShares]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div className="matrix-overlay fade-in" onClick={onClose}>
|
||||
<canvas ref={canvasRef} className="matrix-canvas" />
|
||||
<div className="matrix-text-overlay">
|
||||
<h2>RAW_SOCKET_STREAM [ACTIVE]</h2>
|
||||
<div className="matrix-close-hint">Click anywhere to close terminal</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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 FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
|
||||
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
|
||||
import {
|
||||
DEFAULT_FLEET_FILTERS,
|
||||
filterFleetAgents,
|
||||
@@ -34,6 +36,7 @@ export default function DashboardPage() {
|
||||
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [showMatrix, setShowMatrix] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getRecentShares(20).then(setShares).catch(console.error);
|
||||
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
|
||||
@@ -148,6 +151,9 @@ export default function DashboardPage() {
|
||||
<span className="font-tech live-label">{isConnected ? 'SIGNAL LOCKED' : 'RECONNECTING'}</span>
|
||||
<span className="live-sub">{agents.length} nodes registered</span>
|
||||
</div>
|
||||
<button className="button matrix-toggle-btn" onClick={() => setShowMatrix(true)} style={{ marginLeft: '1rem', background: 'transparent', border: '1px solid #0f0', color: '#0f0', fontFamily: 'monospace' }}>
|
||||
[RAW_STREAM]
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -239,6 +245,10 @@ export default function DashboardPage() {
|
||||
<ActivityPulse items={activityItems} />
|
||||
</NeonCard>
|
||||
|
||||
<section className="section">
|
||||
<FleetTopologyMap agents={agents} />
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Machine Roster
|
||||
@@ -354,6 +364,7 @@ export default function DashboardPage() {
|
||||
</table>
|
||||
</NeonCard>
|
||||
</section>
|
||||
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
|
||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||
</footer>
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user