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