feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel
Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo, FusionEstimate } from '../types';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
@@ -19,11 +19,9 @@ import {
|
||||
type ForgeDeliverable,
|
||||
} from '../help/forgeFormNormalize';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
import AuthDownloadButton from '../components/AuthDownloadButton';
|
||||
import { buildRequestFromRecord } from '../help/buildManager';
|
||||
import DownloadButton from '../components/DownloadButton';
|
||||
import { downloadApiFile } from '../api/download';
|
||||
import { useForge } from '../context/ForgeContext';
|
||||
import {
|
||||
fusionPayloadKind,
|
||||
fusionTitleFromFilename,
|
||||
@@ -50,6 +48,37 @@ 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)
|
||||
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 },
|
||||
];
|
||||
|
||||
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
|
||||
if (!building) return null;
|
||||
return (
|
||||
<div className="forge-progress-wrap" aria-live="polite">
|
||||
<div className="forge-progress-header">
|
||||
<span className="forge-progress-icon">⚙</span>
|
||||
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
|
||||
<span className="forge-progress-pct">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="forge-progress-track">
|
||||
<div
|
||||
className="forge-progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FORGE_MODE_KEY = 'aetherforge-forge-mode';
|
||||
|
||||
function loadSimpleMode(): boolean {
|
||||
@@ -63,12 +92,16 @@ function loadSimpleMode(): boolean {
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge();
|
||||
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [form, setForm] = useState<BuildRequest | null>(null);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [lastBuild, setLastBuild] = useState<BuildResponse | null>(null);
|
||||
const [recentBuilds, setRecentBuilds] = useState<BuildRecord[]>([]);
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
||||
const [fusionBatchFiles, setFusionBatchFiles] = useState<File[]>([]);
|
||||
@@ -92,6 +125,45 @@ export default function BuilderPage() {
|
||||
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
|
||||
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
|
||||
|
||||
// Drive simulated stage progress while a single build is running
|
||||
useEffect(() => {
|
||||
if (!building || batchJob) {
|
||||
endForge();
|
||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||
return;
|
||||
}
|
||||
const startMs = Date.now();
|
||||
startForge();
|
||||
|
||||
let stageIdx = 0;
|
||||
const advance = () => {
|
||||
const elapsed = Date.now() - startMs;
|
||||
// Find the furthest stage whose minMs has been reached
|
||||
let next = 0;
|
||||
for (let i = 0; i < FORGE_STAGES.length; i++) {
|
||||
if (elapsed >= FORGE_STAGES[i].minMs) next = i;
|
||||
else break;
|
||||
}
|
||||
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 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));
|
||||
forgeStageTimerRef.current = setTimeout(advance, 250);
|
||||
};
|
||||
advance();
|
||||
|
||||
return () => {
|
||||
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [building, batchJob]);
|
||||
|
||||
const setForgeMode = (simple: boolean) => {
|
||||
setSimpleMode(simple);
|
||||
try {
|
||||
@@ -142,32 +214,28 @@ export default function BuilderPage() {
|
||||
try {
|
||||
const builds = await api.listBuilds();
|
||||
setRecentBuilds(builds);
|
||||
setShowRecent(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle ?reforge=<buildId> links from Build Manager page
|
||||
useEffect(() => {
|
||||
const reforgeId = searchParams.get('reforge');
|
||||
if (!reforgeId || recentBuilds.length === 0) return;
|
||||
const match = recentBuilds.find((b) => b.id === reforgeId);
|
||||
if (match) {
|
||||
reForgeFromBuild(match);
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, recentBuilds]);
|
||||
|
||||
const finishForgeSuccess = async (result: BuildResponse) => {
|
||||
setStage('Build complete!', 100);
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
const url = result.bundle_download_url || result.download_url;
|
||||
const name = result.bundle_file_name || result.file_name;
|
||||
if (url && name) {
|
||||
try {
|
||||
await downloadApiFile(url, name);
|
||||
} catch (err) {
|
||||
console.error('Auto-download failed:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.download_url && result.file_name) {
|
||||
try {
|
||||
await downloadApiFile(result.download_url, result.file_name);
|
||||
} catch (err) {
|
||||
console.error('Auto-download failed:', err);
|
||||
}
|
||||
}
|
||||
// No auto-download — user downloads from the strip below or Build Manager page
|
||||
};
|
||||
|
||||
// Blueprint: save current form as a named blueprint
|
||||
@@ -250,10 +318,6 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const blueprintDiffRows = useMemo(() => {
|
||||
if (!form || !compareBlueprint) return [];
|
||||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||||
}, [form, compareBlueprint]);
|
||||
|
||||
// Blueprint: delete a blueprint
|
||||
const handleDeleteBlueprint = async (name: string) => {
|
||||
@@ -493,7 +557,8 @@ export default function BuilderPage() {
|
||||
const candidates = lanEndpointCandidates(info, config.port || info.port);
|
||||
const base = defaultsFromConfig(config, info, builds);
|
||||
const kind = form ? deriveDeliverableType(form) : 'single';
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates });
|
||||
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
|
||||
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
|
||||
setForm(applyDeliverableType(merged, kind));
|
||||
setBlueprintMsg('✅ Recommended defaults applied');
|
||||
setTimeout(() => setBlueprintMsg(''), 2500);
|
||||
@@ -1881,6 +1946,8 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ForgeProgressBar building={building && !batchJob} stage={forgeStage} progress={forgeProgress} />
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
|
||||
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
|
||||
@@ -1900,146 +1967,39 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
{lastBuild?.success && (
|
||||
<div className="card recent-builds">
|
||||
<h2>Installer Ready</h2>
|
||||
<div className="build-success">
|
||||
<p><strong>Deploy to each machine:</strong></p>
|
||||
{lastBuild.fusion_enabled && (
|
||||
<p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p>
|
||||
)}
|
||||
<p><strong>File:</strong> {lastBuild.file_name}</p>
|
||||
<p><strong>Size:</strong> {((lastBuild.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
||||
{lastBuild.fusion_export_dir && (
|
||||
<>
|
||||
<p><strong>Movie deliverables folder:</strong></p>
|
||||
<code className="path-display">{lastBuild.fusion_export_dir}</code>
|
||||
</>
|
||||
)}
|
||||
{lastBuild.export_path && (
|
||||
<>
|
||||
<p><strong>Your file (project root):</strong></p>
|
||||
<code className="path-display">{lastBuild.export_path}</code>
|
||||
{!lastBuild.fusion_export_dir && (
|
||||
<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>
|
||||
<div className="download-actions">
|
||||
{lastBuild.download_url && lastBuild.file_name && (
|
||||
<DownloadButton
|
||||
apiPath={lastBuild.download_url}
|
||||
filename={lastBuild.file_name}
|
||||
className="btn btn-primary btn-lg"
|
||||
>
|
||||
Download {lastBuild.file_name}
|
||||
</DownloadButton>
|
||||
<div className="forge-last-build-strip">
|
||||
<div className="forge-last-build-info">
|
||||
<span className="forge-last-build-check">✓</span>
|
||||
<div>
|
||||
<span className="forge-last-build-name">{lastBuild.file_name || 'Build ready'}</span>
|
||||
{lastBuild.file_size != null && (
|
||||
<span className="forge-last-build-size">
|
||||
{((lastBuild.file_size || 0) / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
)}
|
||||
{!lastBuild.bundle_download_url && lastBuild.build_id && lastBuild.extra_files?.map((f) => (
|
||||
<DownloadButton
|
||||
key={f.file_name}
|
||||
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
|
||||
filename={f.file_name}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Download {f.file_name}
|
||||
</DownloadButton>
|
||||
))}
|
||||
<p className="form-hint">
|
||||
{lastBuild.bundle_file_name
|
||||
? 'One ZIP per title — extract and run the runner only (agent is inside it, hidden).'
|
||||
: 'Saved to your browser Downloads when the forge completes. Click again if needed.'}
|
||||
</p>
|
||||
{lastBuild.fusion_enabled && <span className="forge-last-build-tag">FUSION</span>}
|
||||
{lastBuild.obfuscated && <span className="forge-last-build-tag">GARBLED</span>}
|
||||
{lastBuild.signed && <span className="forge-last-build-tag">SIGNED</span>}
|
||||
</div>
|
||||
{lastBuild.uninstall_export_path && (
|
||||
<p><strong>Uninstaller copy:</strong> <code className="mono-sm">{lastBuild.uninstall_export_path}</code></p>
|
||||
)}
|
||||
{lastBuild.uninstall_download_url && (
|
||||
<>
|
||||
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
|
||||
{!lastBuild.uninstall_export_path && (
|
||||
<code className="path-display">{lastBuild.uninstall_path}</code>
|
||||
)}
|
||||
<AuthDownloadButton
|
||||
apiPath={lastBuild.uninstall_download_url}
|
||||
filename={lastBuild.uninstall_file_name || 'uninstall.ps1'}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Download uninstall script
|
||||
</AuthDownloadButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRecent && (
|
||||
<div className="card recent-builds">
|
||||
<div className="recent-header">
|
||||
<h2>Build Manager</h2>
|
||||
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
|
||||
<div className="forge-last-build-actions">
|
||||
{lastBuild.download_url && lastBuild.file_name && (
|
||||
<DownloadButton
|
||||
apiPath={lastBuild.download_url}
|
||||
filename={lastBuild.file_name}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
↓ Download
|
||||
</DownloadButton>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => navigate('/builds')}
|
||||
>
|
||||
View in Build Manager →
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
|
||||
{blueprintDiffRows.length > 0 && (
|
||||
<NeonCard accent="purple" className="section">
|
||||
<h3>Blueprint Diff vs current form</h3>
|
||||
<ul className="blueprint-diff">
|
||||
{blueprintDiffRows.map((d) => (
|
||||
<li key={d.key} className={d.kind}>
|
||||
<strong>{d.key}</strong>: {d.kind}
|
||||
{d.kind === 'changed' && ` (${JSON.stringify(d.from)} → ${JSON.stringify(d.to)})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NeonCard>
|
||||
)}
|
||||
{recentBuilds.length === 0 ? (
|
||||
<p className="empty-text">No builds yet</p>
|
||||
) : (
|
||||
<div className="build-manager-grid">
|
||||
{recentBuilds.map((build) => {
|
||||
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
||||
const exeName =
|
||||
build.file_path?.replace(/^.*[/\\]/, '') || `install-${build.worker_name}.exe`;
|
||||
return (
|
||||
<div key={build.id} className="build-manager-row">
|
||||
<div>
|
||||
<div className="build-item-name">{build.worker_name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{build.threads} threads</span>
|
||||
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>{new Date(build.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LanDownloadQR url={downloadUrl} />
|
||||
<DownloadButton
|
||||
apiPath={api.buildDownloadUrl(build.id)}
|
||||
filename={exeName}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Download
|
||||
</DownloadButton>
|
||||
<AuthDownloadButton
|
||||
apiPath={api.buildUninstallUrl(build.id)}
|
||||
filename={`uninstall-${build.worker_name || 'worker'}.ps1`}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
Uninstall script
|
||||
</AuthDownloadButton>
|
||||
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
|
||||
Re-forge
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user