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

@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, Navigate } from 'react-router-dom';
import App, { PageFallback } from './App';
import { routerFuture } from './routerFuture';
vi.mock('./context/WebSocketProvider', () => ({
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
@@ -42,7 +43,7 @@ describe('App route config', () => {
it('redirects / to dashboard and /builder to forge', () => {
function RedirectProbe({ path }: { path: string }) {
return (
<MemoryRouter initialEntries={[path]}>
<MemoryRouter initialEntries={[path]} future={routerFuture}>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
@@ -61,7 +62,7 @@ describe('App route config', () => {
it('renders crucible route via App shell', async () => {
render(
<MemoryRouter initialEntries={['/crucible']}>
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
<App />
</MemoryRouter>
);

View File

@@ -8,13 +8,19 @@ export function getStoredAuth(): string | null {
}
}
export function setStoredAuth(username: string, password: string) {
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = btoa(`${username}:${password}`);
sessionStorage.setItem(AUTH_KEY, token);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function clearStoredAuth() {
export function clearStoredAuth(opts?: { silent?: boolean }) {
sessionStorage.removeItem(AUTH_KEY);
if (!opts?.silent) {
window.dispatchEvent(new Event('aetherforge-auth'));
}
}
export function authHeaders(): Record<string, string> {

View File

@@ -263,6 +263,16 @@ describe('api client', () => {
expect(JSON.parse(lastFetch().init.body as string)).toEqual({ username: 'alice', password: 'secret' });
});
it('rotateFleetSecret POSTs rotate endpoint', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, hint: 'abcd1234...' }));
const res = await api.rotateFleetSecret();
expect(res.ok).toBe(true);
expect(lastFetch().url).toBe('/api/v1/server/rotate-secret');
expect(lastFetch().init.method).toBe('POST');
});
it('getXmrPrice and getServerInfo', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ usd: 200, updated_at: 'now' }))

View File

@@ -3,6 +3,9 @@ import { authHeaders } from './auth';
const API_BASE = '/api/v1';
// Agent-only REST (/agent/decide, /agent/report, /agent/heartbeat) is intentionally
// omitted here — forged agents call those with X-Fleet-Secret, not dashboard Basic Auth.
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
const { headers: extraHeaders, ...rest } = options ?? {};
const res = await fetch(`${API_BASE}${url}`, {
@@ -118,6 +121,8 @@ export const api = {
// Health / server
healthCheck: () => fetchJSON<{ status: string }>('/health'),
getServerInfo: () => fetchJSON<ServerInfo>('/server/info'),
rotateFleetSecret: () =>
fetchJSON<{ ok: boolean; hint?: string }>('/server/rotate-secret', { method: 'POST' }),
// Fleet ops
getAlerts: () => fetchJSON<FleetAlert[]>('/alerts'),

View File

@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/re
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download';
@@ -169,7 +170,9 @@ describe('DownloadButton', () => {
);
const btn = screen.getByRole('button', { name: 'Save' });
await userEvent.setup().click(btn);
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
});
await waitFor(() => expect(downloadApiFileMock).toHaveBeenCalledWith('/api/x', 'a.bin'));
});
@@ -645,7 +648,7 @@ describe('VisualComponents', () => {
it('ForgeCalibrateCompare links to routes', () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<ForgeCalibrateCompare />
</MemoryRouter>
);
@@ -697,7 +700,7 @@ describe('SystemStatusBar', () => {
it('shows server and fleet pills after poll', async () => {
render(
<MemoryRouter>
<MemoryRouter future={routerFuture}>
<SystemStatusBar />
</MemoryRouter>
);
@@ -780,7 +783,7 @@ describe('Layout', () => {
it('renders nav links and children', async () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout>
<div>page body</div>
</Layout>

View File

@@ -48,6 +48,7 @@ describe('WebSocketProvider', () => {
beforeEach(() => {
sessionStorage.clear();
MockWebSocket.instances = [];
setStoredAuth('testuser', 'testpass', { silent: true });
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:8080' },
@@ -69,6 +70,7 @@ describe('WebSocketProvider', () => {
it('connects to ws dashboard with auth token query param', () => {
setStoredAuth('drjones', 'secret');
MockWebSocket.instances = [];
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
const ws = latestSocket();
@@ -79,10 +81,11 @@ describe('WebSocketProvider', () => {
expect(result.current.isConnected).toBe(true);
});
it('connects without token when logged out', () => {
clearStoredAuth();
it('does not connect when logged out', () => {
clearStoredAuth({ silent: true });
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
expect(latestSocket().url).toBe('ws://localhost:8080/ws/dashboard');
expect(MockWebSocket.instances).toHaveLength(0);
});
it('useWebSocket re-exports context hook', () => {
@@ -160,6 +163,7 @@ describe('WebSocketProvider', () => {
it('schedules reconnect after close', () => {
vi.useFakeTimers();
MockWebSocket.instances = [];
renderHook(() => useWebSocketContext(), { wrapper });
const first = latestSocket();

View File

@@ -40,14 +40,25 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
reconnectTimer.current = null;
}
const token = getStoredAuth();
if (!token) {
const existing = wsRef.current;
if (existing) {
existing.onclose = null;
existing.close();
wsRef.current = null;
}
setIsConnected(false);
return;
}
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
existing.close();
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getStoredAuth();
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard${token ? `?token=${encodeURIComponent(token)}` : ''}`;
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -57,6 +68,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
if (unmounted.current) return;
setIsConnected(false);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (!getStoredAuth()) return;
reconnectTimer.current = setTimeout(connect, 3000);
};
@@ -201,8 +213,11 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
unmounted.current = false;
connect();
const onAuthChange = () => connect();
window.addEventListener('aetherforge-auth', onAuthChange);
return () => {
unmounted.current = true;
window.removeEventListener('aetherforge-auth', onAuthChange);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
const ws = wsRef.current;
if (ws) { ws.onclose = null; ws.close(); }

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
import { FORGE_BUILD_DEFAULTS, DEFAULT_PUBLIC_TUNNEL, forgeDefaultsFromServer } from './forgeDefaults';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
describe('FORGE_BUILD_DEFAULTS', () => {
@@ -47,12 +47,12 @@ describe('forgeDefaultsFromServer', () => {
expect(result.server_url).toBe('https://tunnel.example.com');
});
it('falls back to suggested_url when public_url is blank', () => {
it('falls back to baked tunnel URL when public_url is blank', () => {
const config = mockServerConfig({
server: { public_url: ' ' },
});
const result = forgeDefaultsFromServer(config, mockServerInfo);
expect(result.server_url).toBe(mockServerInfo.suggested_url);
expect(result.server_url).toBe(DEFAULT_PUBLIC_TUNNEL);
});
it('reflects obfuscate and sign defaults from server config', () => {

View File

@@ -1,5 +1,8 @@
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
/** Baked Cloudflare tunnel — used when Calibrate public_url is blank. */
export const DEFAULT_PUBLIC_TUNNEL = 'https://killa.thetempleofdoom.com';
/** Defaults for a new forge build — not stored in Calibrate. */
export const FORGE_BUILD_DEFAULTS: Omit<
BuildRequest,
@@ -62,7 +65,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
return {
...FORGE_BUILD_DEFAULTS,
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url,
server_url: publicUrl || DEFAULT_PUBLIC_TUNNEL || serverInfo.suggested_url,
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,

View File

@@ -27,11 +27,11 @@ export const FIELD_HELP: Record<string, string> = {
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (devrun.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with devrun.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:

View File

@@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { routerFuture } from './routerFuture';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css';
import './styles/steampunk-theme.css';
@@ -21,7 +22,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</div>
}
>
<BrowserRouter>
<BrowserRouter future={routerFuture}>
<App />
</BrowserRouter>
</ErrorBoundary>

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)));

View File

@@ -0,0 +1,4 @@
export const routerFuture = {
v7_startTransition: true,
v7_relativeSplatPath: true,
} as const;

View File

@@ -179,6 +179,7 @@ describe('types/index — BuildRecord / Build alias', () => {
pool_port: 443,
pool_tls: true,
pool_pass: 'x',
download_url: '/api/v1/builds/build-uuid/download',
};
it('Build alias is assignable from BuildRecord', () => {
@@ -193,8 +194,8 @@ describe('types/index — BuildRecord / Build alias', () => {
file_name: 'worker-1.exe',
platform: 'windows',
bundle_size: 2048000,
download_url: '/api/v1/builds/build-uuid/download',
pinned: true,
extra_files: [{ file_name: 'README.txt' }],
};
expect(extended.pinned).toBe(true);
expect(extended.bundle_size).toBeGreaterThan(extended.file_size);

View File

@@ -113,6 +113,11 @@ export interface ServerInfo {
websocket_url: string;
}
export interface BuildExtraFile {
file_name: string;
file_path?: string;
}
export interface BuildRecord {
id: string;
worker_name: string;
@@ -129,7 +134,9 @@ export interface BuildRecord {
pool_pass: string;
platform?: string;
bundle_size?: number;
download_url?: string;
/** Always set by the server (defaults to /builds/{id}/download when not a ZIP artifact). */
download_url: string;
extra_files?: BuildExtraFile[];
/** When true this build is served by /get and /install.* dropper endpoints */
pinned?: boolean;
}
@@ -390,7 +397,7 @@ export interface BuildResponse {
error?: string;
fusion_enabled?: boolean;
fusion_export_dir?: string;
extra_files?: { file_name: string; file_path?: string }[];
extra_files?: BuildExtraFile[];
bundle_file_name?: string;
bundle_download_url?: string;
bundle_size?: number;

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
WSAgentLog,
WSAgentOffline,
@@ -10,13 +13,66 @@ import type {
} from './ws';
import { mockAgent } from '../test/fixtures';
const fixtureDir = dirname(fileURLToPath(import.meta.url));
const goWSFixture = JSON.parse(
readFileSync(join(fixtureDir, '../../../internal/api/testdata/ws_types_fixture.json'), 'utf8'),
) as Record<string, string[]>;
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
}
}
function sampleValue(field: string): unknown {
if (field === 'agents') return [];
if (field === 'success') return true;
if (field === 'line' || field === 'content' || field === 'message' || field === 'action' || field === 'agent_id') {
return 'sample';
}
return 0;
}
function buildSample(fields: string[]): Record<string, unknown> {
const sample: Record<string, unknown> = {};
for (const field of fields) {
sample[field] = field === 'agents' ? [mockAgent()] : sampleValue(field);
}
return sample;
}
describe('types/ws payloads', () => {
it('shared payload keys match Go ws_types.go fixture', () => {
for (const [typeName, fields] of Object.entries(goWSFixture)) {
expect(fields).toEqual([...fields].sort());
const sample = buildSample(fields);
expectKeys(sample, fields);
switch (typeName) {
case 'WSDashboardInit':
void (sample as WSDashboardInit);
break;
case 'WSAgentOffline':
void (sample as WSAgentOffline);
break;
case 'WSStatsUpdate':
void (sample as WSStatsUpdate);
break;
case 'WSCommandResult':
void (sample as WSCommandResult);
break;
case 'WSAgentLog':
void (sample as WSAgentLog);
break;
case 'WSServerLog':
void (sample as WSServerLog);
break;
default:
throw new Error(`unexpected WS type in Go fixture: ${typeName}`);
}
}
});
it('WSDashboardInit carries agents array', () => {
const init: WSDashboardInit = { agents: [mockAgent()] };
expectKeys(init as unknown as Record<string, unknown>, ['agents']);

View File

@@ -1,6 +1,10 @@
import type { Agent, AgentService } from '../types';
/** Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go */
/**
* Dashboard WebSocket payloads — keep in sync with server/internal/api/ws_types.go
* Shared types: WSDashboardInit, WSAgentOffline, WSStatsUpdate, WSCommandResult, WSAgentLog, WSServerLog
* Cross-language drift guard: server/internal/api/testdata/ws_types_fixture.json (Go ws_types_test.go, TS ws.test.ts)
*/
export interface WSDashboardInit {
agents: Agent[];
}