Add fleet resilience, passive spread, matrix rain UI, and live earnings.
Backup server URL failover, watchdog process restart, service masquerade, remote fleet upgrade, recon UI, SupportXMR earnings, USB/share passive spread, and sidebar matrix rain with live fleet telemetry.
This commit is contained in:
@@ -88,7 +88,8 @@
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
/* No flex-grow: let the matrix rain claim the remaining space */
|
||||
flex: 0 0 auto;
|
||||
padding: 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -150,44 +151,229 @@
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* ── Matrix rain ──────────────────────────────── */
|
||||
.matrix-rain-wrap {
|
||||
/* Flex-grow to fill all space between nav and footer */
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid rgba(0, 255, 65, 0.12);
|
||||
border-bottom: 1px solid rgba(0, 255, 65, 0.08);
|
||||
min-height: 160px;
|
||||
max-height: 320px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.matrix-rain-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* CRT scanline overlay */
|
||||
.matrix-rain-scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(0, 0, 0, 0.18) 1px,
|
||||
rgba(0, 0, 0, 0.18) 2px
|
||||
);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Top fade — blends into nav area */
|
||||
.matrix-rain-vignette-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to bottom, #000 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Bottom fade — blends into footer */
|
||||
.matrix-rain-vignette-btm {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 32px;
|
||||
background: linear-gradient(to top, #000 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1.25rem;
|
||||
padding: 1rem 1.25rem 1.1rem;
|
||||
border-top: 1px solid var(--border-brass);
|
||||
}
|
||||
|
||||
.power-meter {
|
||||
margin-bottom: 0.75rem;
|
||||
/* ── Fleet readout widget ─────────────────────── */
|
||||
.fleet-readout {
|
||||
margin-bottom: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.power-label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.2em;
|
||||
.readout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.readout-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.readout-dot-online {
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 6px var(--neon-green);
|
||||
animation: readout-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.readout-dot-idle {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
@keyframes readout-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--neon-green); }
|
||||
50% { opacity: 0.7; box-shadow: 0 0 12px var(--neon-green); }
|
||||
}
|
||||
|
||||
.readout-glyph {
|
||||
width: 7px;
|
||||
text-align: center;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.readout-label {
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.power-bar {
|
||||
height: 4px;
|
||||
background: rgba(201, 162, 39, 0.15);
|
||||
.readout-value {
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: right;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.readout-value-machines {
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
.readout-dim {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@keyframes readout-tick {
|
||||
0% { opacity: 0.4; color: #fff; }
|
||||
50% { opacity: 1; color: var(--neon-amber); }
|
||||
100% { opacity: 1; color: var(--neon-cyan); }
|
||||
}
|
||||
|
||||
.readout-flash {
|
||||
animation: readout-tick 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Readout progress bar */
|
||||
.readout-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.readout-bar-track {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(201, 162, 39, 0.12);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.power-fill {
|
||||
.readout-bar-fill {
|
||||
height: 100%;
|
||||
width: 78%;
|
||||
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
|
||||
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
width: 0%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.readout-bar-fill.readout-bar-active {
|
||||
background: linear-gradient(90deg, var(--neon-green), var(--neon-cyan));
|
||||
box-shadow: 0 0 6px rgba(0, 245, 255, 0.35);
|
||||
animation: bar-shimmer 2.5s ease-in-out infinite;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
font-size: 0.65rem;
|
||||
@keyframes bar-shimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.readout-bar-pct {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.55rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
letter-spacing: 0.15em;
|
||||
letter-spacing: 0.06em;
|
||||
min-width: 2.2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Signature ────────────────────────────────── */
|
||||
.sidebar-sig {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(201, 162, 39, 0.1);
|
||||
}
|
||||
|
||||
.sig-love {
|
||||
font-size: 0.58rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.sig-heart {
|
||||
color: #f87171;
|
||||
animation: heart-beat 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes heart-beat {
|
||||
0%, 100% { transform: scale(1); }
|
||||
20% { transform: scale(1.3); }
|
||||
40% { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.sig-ver {
|
||||
font-size: 0.55rem;
|
||||
color: rgba(0, 245, 255, 0.35);
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
.main-with-status {
|
||||
@@ -219,8 +405,9 @@
|
||||
|
||||
.logo-text-block,
|
||||
.nav-label,
|
||||
.power-meter,
|
||||
.version-badge {
|
||||
.fleet-readout,
|
||||
.sidebar-sig,
|
||||
.matrix-rain-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useEffect, 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 MatrixRain from './MatrixRain';
|
||||
import './Layout.css';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -57,6 +59,60 @@ function NavIcon({ type }: { type: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function FleetReadout() {
|
||||
const { agents } = useWebSocket();
|
||||
const online = agents.filter((a) => a.status === 'online').length;
|
||||
const total = agents.length;
|
||||
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -93,14 +149,15 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
|
||||
<MatrixRain />
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="power-meter">
|
||||
<span className="power-label font-tech">SYSTEM</span>
|
||||
<div className="power-bar">
|
||||
<div className="power-fill" />
|
||||
</div>
|
||||
<FleetReadout />
|
||||
<div className="sidebar-sig font-tech">
|
||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||
<span className="sig-ver">v0.0.1</span>
|
||||
</div>
|
||||
<div className="version-badge font-tech">MK.I · v1.0</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
212
server/web/src/components/Layout/MatrixRain.tsx
Normal file
212
server/web/src/components/Layout/MatrixRain.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
|
||||
// Full matrix alphabet: katakana + hex + braille dots for visual density
|
||||
const KATAKANA =
|
||||
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
|
||||
const HEX = '0123456789ABCDEFabcdef';
|
||||
const SYMBOLS = '!@#$%^&*<>/?|\\~';
|
||||
const ALPHABET = KATAKANA + HEX + SYMBOLS;
|
||||
|
||||
const FONT_SIZE = 10;
|
||||
|
||||
interface Column {
|
||||
y: number;
|
||||
speed: number;
|
||||
// occasionally carry a char from live data
|
||||
liveSrc: string;
|
||||
livePos: number;
|
||||
}
|
||||
|
||||
export default function MatrixRain() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const { agents, recentShares, commandResults } = useWebSocket();
|
||||
|
||||
// ── Live data pool ──────────────────────────────────────────────────────────
|
||||
// Collect strings from the fleet that will be injected character-by-character
|
||||
// into the rain columns so real data scrolls through the matrix.
|
||||
const livePoolRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
const pool: string[] = [];
|
||||
for (const a of agents) {
|
||||
pool.push(a.id.replace(/-/g, '')); // stripped UUID
|
||||
if (a.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`);
|
||||
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
|
||||
}
|
||||
for (const s of recentShares.slice(0, 8)) {
|
||||
if (s.hash) pool.push(s.hash.replace(/[^a-fA-F0-9]/g, '').slice(0, 24));
|
||||
}
|
||||
for (const r of (commandResults ?? []).slice(-5)) {
|
||||
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
|
||||
}
|
||||
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
|
||||
}, [agents, recentShares, commandResults]);
|
||||
|
||||
// ── Event log feed ──────────────────────────────────────────────────────────
|
||||
// We inject one short log line per meaningful event, shown as a dim overlay
|
||||
// row scrolling through the canvas.
|
||||
const eventLogRef = useRef<{ text: string; alpha: number }[]>([]);
|
||||
const prevShareLen = useRef(0);
|
||||
const prevAgentLen = useRef(0);
|
||||
useEffect(() => {
|
||||
const newEvents: string[] = [];
|
||||
if (recentShares.length > prevShareLen.current) {
|
||||
const s = recentShares[0];
|
||||
newEvents.push(`SHARE ${s.accepted ? 'OK' : 'REJECT'} ${s.agent_id?.slice(0, 6) ?? '??'}`);
|
||||
}
|
||||
prevShareLen.current = recentShares.length;
|
||||
if (agents.length > prevAgentLen.current) {
|
||||
const a = agents[agents.length - 1];
|
||||
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
|
||||
}
|
||||
prevAgentLen.current = agents.length;
|
||||
for (const ev of newEvents) {
|
||||
eventLogRef.current.push({ text: ev, alpha: 1 });
|
||||
if (eventLogRef.current.length > 6) eventLogRef.current.shift();
|
||||
}
|
||||
}, [recentShares, agents]);
|
||||
|
||||
// ── Canvas renderer ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
if (!canvas || !wrap) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Resize canvas to match wrapper
|
||||
const resize = () => {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
canvas.width = Math.floor(r.width);
|
||||
canvas.height = Math.floor(r.height);
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(wrap);
|
||||
|
||||
let cols: Column[] = [];
|
||||
const resetCols = () => {
|
||||
const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE));
|
||||
cols = Array.from({ length: numCols }, (_, i) => ({
|
||||
y: Math.random() * -(canvas.height * 2),
|
||||
speed: 0.3 + Math.random() * 0.55,
|
||||
liveSrc: '',
|
||||
livePos: 0,
|
||||
}));
|
||||
};
|
||||
resetCols();
|
||||
|
||||
// Periodically inject live data strings into random columns
|
||||
const injectInterval = setInterval(() => {
|
||||
const pool = livePoolRef.current;
|
||||
if (pool.length === 0 || cols.length === 0) return;
|
||||
const colIdx = Math.floor(Math.random() * cols.length);
|
||||
const src = pool[Math.floor(Math.random() * pool.length)];
|
||||
cols[colIdx].liveSrc = src;
|
||||
cols[colIdx].livePos = 0;
|
||||
}, 180);
|
||||
|
||||
let raf: number;
|
||||
let lastTime = 0;
|
||||
const FPS = 24; // keep CPU gentle
|
||||
const MS_PER_FRAME = 1000 / FPS;
|
||||
|
||||
const draw = (ts: number) => {
|
||||
raf = requestAnimationFrame(draw);
|
||||
if (ts - lastTime < MS_PER_FRAME) return;
|
||||
lastTime = ts;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
// Fade trail
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
||||
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const col = cols[i];
|
||||
const x = i * FONT_SIZE;
|
||||
const y = col.y;
|
||||
|
||||
// Pick character: live data char or random alphabet
|
||||
let ch: string;
|
||||
if (col.liveSrc && col.livePos < col.liveSrc.length) {
|
||||
ch = col.liveSrc[col.livePos];
|
||||
col.livePos++;
|
||||
} else {
|
||||
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
||||
}
|
||||
|
||||
// Head character — bright white flash
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||
|
||||
// Second char — bright cyan-green (neon)
|
||||
if (y > 1) {
|
||||
ctx.fillStyle = '#00ff41';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
(y - 1) * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
// Dim previous chars handled by fade overlay above.
|
||||
// Occasionally render a mid-column dim glyph for density.
|
||||
if (Math.random() < 0.04) {
|
||||
const dimY = Math.floor(Math.random() * (y - 2));
|
||||
ctx.fillStyle = 'rgba(0,180,60,0.22)';
|
||||
ctx.fillText(
|
||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||
x,
|
||||
dimY * FONT_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
col.y += col.speed;
|
||||
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
|
||||
col.y = Math.random() * -20;
|
||||
col.speed = 0.3 + Math.random() * 0.55;
|
||||
col.liveSrc = '';
|
||||
col.livePos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event log overlay — bottom of canvas ──────────────────────────
|
||||
const logs = eventLogRef.current;
|
||||
const lineH = FONT_SIZE + 2;
|
||||
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
|
||||
for (let j = 0; j < logs.length; j++) {
|
||||
const entry = logs[logs.length - 1 - j];
|
||||
const oy = H - 6 - j * lineH;
|
||||
if (oy < 0) break;
|
||||
ctx.fillStyle = `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
|
||||
ctx.fillText(`> ${entry.text}`, 4, oy);
|
||||
// fade over time
|
||||
entry.alpha = Math.max(0, entry.alpha - 0.003);
|
||||
}
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
clearInterval(injectInterval);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="matrix-rain-wrap" aria-hidden="true">
|
||||
<canvas ref={canvasRef} className="matrix-rain-canvas" />
|
||||
{/* Scanline overlay for authentic CRT feel */}
|
||||
<div className="matrix-rain-scanlines" />
|
||||
{/* Top and bottom vignette fades */}
|
||||
<div className="matrix-rain-vignette-top" />
|
||||
<div className="matrix-rain-vignette-btm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user