Improve portable launch, forge persistence, and operator auth UX.

Persist build extra_files for Build Manager history, print dashboard login on every start, add libp2p for Mesh P2P forge, defer WebSocket until login, and split devrun.bat from LAUNCH.bat with USB deck auto-detection.
This commit is contained in:
AetherForge
2026-05-31 18:56:43 -07:00
parent 2f528229f2
commit feba06e008
80 changed files with 2897 additions and 675 deletions

View File

@@ -12,6 +12,7 @@ import BuildManagerPage, {
truncateWallet,
} from './BuildManagerPage';
import { api } from '../api/client';
import { routerFuture } from '../routerFuture';
vi.mock('../components/Fleet/LanDownloadQR', () => ({
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
@@ -82,7 +83,7 @@ describe('BuildManagerPage', () => {
it('renders build list after load', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<BuildManagerPage />
</MemoryRouter>
);

View File

@@ -221,7 +221,7 @@ function BuildCard({
<div className="bm-downloads-label font-tech">DOWNLOAD</div>
<div className="bm-downloads-row">
<DownloadButton
apiPath={api.buildDownloadUrl(build.id)}
apiPath={build.download_url || api.buildDownloadUrl(build.id)}
filename={exeName}
className="btn btn-primary bm-dl-btn"
>
@@ -234,6 +234,16 @@ function BuildCard({
>
Uninstall script
</AuthDownloadButton>
{build.extra_files?.map((f) => (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(build.id, f.file_name)}
filename={f.file_name}
className="btn btn-outline bm-dl-btn"
>
{f.file_name}
</DownloadButton>
))}
</div>
</div>

View File

@@ -7,12 +7,13 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
function renderBuilder(initialEntries = ['/forge']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<MemoryRouter initialEntries={initialEntries} future={routerFuture}>
<ForgeProvider>
<BuilderPage />
</ForgeProvider>

View File

@@ -19,7 +19,7 @@ import {
type ForgeDeliverable,
} from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { buildRequestFromRecord } from '../help/buildManager';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import { useForge } from '../context/ForgeContext';
import {
@@ -272,6 +272,7 @@ export default function BuilderPage() {
try {
const data = await api.getBlueprint(name);
setCompareBlueprint(data as Record<string, unknown>);
setBlueprintName(name);
// Merge loaded data into form, preserving any fields not in the blueprint
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setShowBlueprints(false);
@@ -341,7 +342,9 @@ export default function BuilderPage() {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
const data = JSON.parse(evt.target?.result as string) as Record<string, unknown>;
setCompareBlueprint(data);
setBlueprintName(file.name);
setForm((prev) => (prev ? { ...prev, ...data } : prev));
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
setTimeout(() => setBlueprintMsg(''), 3000);
@@ -594,6 +597,10 @@ export default function BuilderPage() {
);
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
const blueprintChanges = useMemo(() => {
if (!compareBlueprint || !form) return [];
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
}, [compareBlueprint, form]);
const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe';
const fusionMediaMode = form?.fusion_media_mode || 'paired';
@@ -754,6 +761,44 @@ export default function BuilderPage() {
</div>
)}
{compareBlueprint && blueprintChanges.length > 0 && (
<div className="card" style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>
Blueprint diff {blueprintName || 'loaded blueprint'}
</h3>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
setCompareBlueprint(null);
setBlueprintName('');
}}
>
Dismiss
</button>
</div>
<p className="form-hint" style={{ marginTop: 0 }}>
Fields that differ from the loaded blueprint (your previous form values were kept where they conflict).
</p>
<ul className="blueprint-diff-list" style={{ margin: 0, paddingLeft: '1.25rem', fontSize: '0.85rem' }}>
{blueprintChanges.map((row) => (
<li key={row.key}>
<code>{row.key}</code>
{' — '}
{row.kind === 'added' && <span>kept from form</span>}
{row.kind === 'removed' && <span>not in current form</span>}
{row.kind === 'changed' && (
<span>
blueprint current
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Blueprint picker panel */}
{showBlueprints && (
<div className="card" style={{ marginBottom: '16px' }}>
@@ -2022,6 +2067,19 @@ export default function BuilderPage() {
Download
</DownloadButton>
)}
{lastBuild.build_id &&
lastBuild.extra_files?.map((f) =>
f.file_name ? (
<DownloadButton
key={f.file_name}
apiPath={api.buildArtifactUrl(lastBuild.build_id!, f.file_name)}
filename={f.file_name}
className="btn btn-outline btn-sm"
>
{f.file_name}
</DownloadButton>
) : null
)}
<button
type="button"
className="btn btn-outline btn-sm"

View File

@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import DashboardPage, { formatShareTime } from './DashboardPage';
import { mockAgent, mockShare } from '../test/fixtures';
import { routerFuture } from '../routerFuture';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
@@ -41,7 +42,7 @@ function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
function renderDashboard() {
return render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<DashboardPage />
</MemoryRouter>
);

View File

@@ -1,9 +1,8 @@
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, type CSSProperties } from 'react';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import HashrateChart from '../components/Charts/HashrateChart';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -21,8 +20,14 @@ import {
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import ErrorBoundary from '../components/ErrorBoundary';
import FleetTopologyMap from '../components/Visual/3D/FleetTopologyMap';
import MatrixStreamOverlay from '../components/Visual/MatrixStreamOverlay';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
const FleetTopologyMap = lazy(() => import('../components/Visual/3D/FleetTopologyMap'));
const MatrixStreamOverlay = lazy(() => import('../components/Visual/MatrixStreamOverlay'));
function ChartPlaceholder({ height }: { height: number }) {
return <div style={{ height, opacity: 0.35 }} className="font-tech" aria-hidden />;
}
import {
DEFAULT_FLEET_FILTERS,
filterFleetAgents,
@@ -458,24 +463,28 @@ export default function DashboardPage() {
{/* ── Advanced-only panels ─────────────────────────────────────────────── */}
{advancedMode && <AIActivityPanel entries={aiEntries} agentNames={agentNameMap} />}
<div className="grid-2 chart-row">
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={300} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
<NeonCard accent="cyan" tilt3d>
<HashrateChart data={hashHistory} title="Fleet Hashrate Wave" color="#00f5ff" unit="H/s" height={300} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
<NeonCard accent="purple" tilt3d>
<HashrateChart data={acceptHistory} title="Accept Rate Pulse" color="#a855f7" unit="%" height={300} />
</NeonCard>
</div>
</Suspense>
{advancedMode && (
<Suspense fallback={<ChartPlaceholder height={220} />}>
<div className="grid-2 chart-row">
<NeonCard accent="magenta" tilt3d>
<HashrateChart data={cpuHistory} title="CPU Pressure" color="#ff2da6" unit="%" height={220} />
</NeonCard>
<NeonCard accent="brass" tilt3d>
<HashrateChart data={memHistory} title="Memory Load — Fleet Average" color="#ffb020" unit="%" height={220} />
</NeonCard>
</div>
</Suspense>
)}
<NeonCard accent="purple" className="section" hud>
@@ -494,7 +503,9 @@ export default function DashboardPage() {
</div>
}
>
<FleetTopologyMap agents={agents} />
<Suspense fallback={<ChartPlaceholder height={360} />}>
<FleetTopologyMap agents={agents} />
</Suspense>
</ErrorBoundary>
</section>
@@ -622,7 +633,11 @@ export default function DashboardPage() {
</NeonCard>
</section>
)}
<MatrixStreamOverlay active={showMatrix} onClose={() => setShowMatrix(false)} />
{showMatrix && (
<Suspense fallback={null}>
<MatrixStreamOverlay active onClose={() => setShowMatrix(false)} />
</Suspense>
)}
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -5,13 +5,14 @@ import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GuidePage from './GuidePage';
import { routerFuture } from '../routerFuture';
describe('GuidePage', () => {
afterEach(() => cleanup());
it('renders field guide hero and pipeline section', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<GuidePage />
</MemoryRouter>
);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
@@ -159,13 +159,7 @@ export default function SettingsPage() {
setRotatingSecret(true);
setRotateMsg('');
try {
await fetch('/api/v1/server/rotate-secret', {
method: 'POST',
headers: { ...authHeaders() },
}).then(async (r) => {
if (!r.ok) throw new Error(await r.text());
return r.json();
});
await api.rotateFleetSecret();
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
} catch (e: unknown) {
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));