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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)));
|
||||
|
||||
4
server/web/src/routerFuture.ts
Normal file
4
server/web/src/routerFuture.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const routerFuture = {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
} as const;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user