Expand test coverage across server, agent, and web; fix bugs found during audit.
Adds hundreds of unit/integration/e2e tests, fixes WS bcrypt auth, config merge, fleet analytics, agent schedule/log tail, and documents stale PROBLEMS items. Updates PROBLEMS.md, README, and test scripts; ignores local spread-kits and coverage dirs.
This commit is contained in:
17
server/web/e2e/fixtures.ts
Normal file
17
server/web/e2e/fixtures.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
|
||||
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
|
||||
export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
|
||||
|
||||
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
|
||||
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
|
||||
|
||||
export async function loginToDashboard(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByLabel('Username').fill(E2E_USER);
|
||||
await page.getByLabel('Password').fill(E2E_PASS);
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
33
server/web/e2e/pages.spec.ts
Normal file
33
server/web/e2e/pages.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
test.describe('Page smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('Dashboard renders Command Deck', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible();
|
||||
await expect(page.getByText('Fleet Pipeline')).toBeVisible();
|
||||
await expect(page.getByText('Machine Roster')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Agents renders Fleet Roster', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/NODES/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Settings renders Calibrate', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Calibrate/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Calibrate' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('button', { name: 'Save Calibration' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Builder renders The Forge', async ({ page }) => {
|
||||
await page.getByRole('link', { name: /Forge/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Simple' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
72
server/web/e2e/remote-actions.spec.ts
Normal file
72
server/web/e2e/remote-actions.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
const OFFLINE_AGENT = {
|
||||
id: 'e2e-offline-agent',
|
||||
name: 'Offline Node',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
ip: '192.168.1.99',
|
||||
version: '1.0.0',
|
||||
status: 'offline',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
last_seen: new Date(Date.now() - 3600_000).toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
hashrate_15s: 0,
|
||||
hashrate_1m: 0,
|
||||
hashrate_15m: 0,
|
||||
shares_total: 0,
|
||||
shares_good: 0,
|
||||
shares_bad: 0,
|
||||
cpu_usage_pct: 0,
|
||||
memory_usage_pct: 0,
|
||||
uptime_seconds: 0,
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
};
|
||||
|
||||
test.describe('Remote actions UI', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/v1/agents', async (route) => {
|
||||
if (route.request().method() === 'GET' && route.request().url().endsWith('/agents')) {
|
||||
await route.fulfill({ json: [OFFLINE_AGENT] });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await page.route('**/api/v1/agents/*/stats*', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route('**/api/v1/builds', async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
});
|
||||
await page.route('**/api/v1/server/info', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
version: 'test',
|
||||
suggested_url: 'http://127.0.0.1:8989',
|
||||
agent_count: 1,
|
||||
online_count: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /Fleet Roster/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('detail panel disables remote actions for offline agent', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const detail = page.locator('.agent-detail');
|
||||
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
|
||||
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('compact row remote actions disabled when offline', async ({ page }) => {
|
||||
await page.getByText('Offline Node').click();
|
||||
const compact = page.locator('.agent-list-item.expanded');
|
||||
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { loginToDashboard } from './fixtures';
|
||||
|
||||
test.describe('AetherForge smoke', () => {
|
||||
test('health endpoint responds', async ({ request }) => {
|
||||
@@ -9,20 +10,11 @@ test.describe('AetherForge smoke', () => {
|
||||
});
|
||||
|
||||
test('login gate renders and accepts credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByLabel('Username').fill('drjones');
|
||||
await page.getByLabel('Password').fill('czapiewski');
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
await loginToDashboard(page);
|
||||
});
|
||||
|
||||
test('forge page loads after login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByLabel('Username').fill('drjones');
|
||||
await page.getByLabel('Password').fill('czapiewski');
|
||||
await page.getByRole('button', { name: /enter command deck/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Deck' })).toBeVisible({ timeout: 15_000 });
|
||||
await loginToDashboard(page);
|
||||
await page.getByRole('link', { name: /forge/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'The Forge' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
71
server/web/src/App.test.tsx
Normal file
71
server/web/src/App.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
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';
|
||||
|
||||
vi.mock('./context/WebSocketProvider', () => ({
|
||||
WebSocketProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./context/ForgeContext', () => ({
|
||||
ForgeProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/SessionGate', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/Layout/Layout', () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <div data-testid="layout">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
|
||||
vi.mock('./pages/AgentsPage', () => ({ default: () => <div>Agents Page</div> }));
|
||||
vi.mock('./pages/BuilderPage', () => ({ default: () => <div>Forge Page</div> }));
|
||||
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
|
||||
vi.mock('./pages/GuidePage', () => ({ default: () => <div>Guide Page</div> }));
|
||||
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
|
||||
|
||||
describe('PageFallback', () => {
|
||||
it('shows loading copy', () => {
|
||||
render(<PageFallback />);
|
||||
expect(screen.getByText('Loading…')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('App route config', () => {
|
||||
it('redirects / to dashboard and /builder to forge', () => {
|
||||
function RedirectProbe({ path }: { path: string }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<div>Dashboard Page</div>} />
|
||||
<Route path="/builder" element={<Navigate to="/forge" replace />} />
|
||||
<Route path="/forge" element={<div>Forge Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
const { unmount: u1 } = render(<RedirectProbe path="/" />);
|
||||
expect(screen.getByText('Dashboard Page')).toBeTruthy();
|
||||
u1();
|
||||
render(<RedirectProbe path="/builder" />);
|
||||
expect(screen.getByText('Forge Page')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders crucible route via App shell', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/crucible']}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText('Crucible Page')).toBeTruthy();
|
||||
expect(screen.getByTestId('layout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||||
const GuidePage = lazy(() => import('./pages/GuidePage'));
|
||||
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
|
||||
|
||||
function PageFallback() {
|
||||
export function PageFallback() {
|
||||
return (
|
||||
<div className="session-gate" style={{ minHeight: '40vh' }}>
|
||||
<p className="font-tech">Loading…</p>
|
||||
|
||||
@@ -153,6 +153,12 @@ describe('api client', () => {
|
||||
expectAuthHeaders(init);
|
||||
});
|
||||
|
||||
it('estimateFusion rejects without prep file', async () => {
|
||||
const req = { fusion_enabled: true } as Parameters<typeof api.estimateFusion>[0];
|
||||
await expect(api.estimateFusion(req, null)).rejects.toThrow('Fusion requires prep.exe upload');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pinBuild, unpinAll, deleteBuild use correct methods and paths', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true, pinned_id: 'b1' }))
|
||||
|
||||
@@ -70,7 +70,10 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
estimateFusion: (req: BuildRequest, prepFile: File) => {
|
||||
estimateFusion: (req: BuildRequest, prepFile?: File | null) => {
|
||||
if (!prepFile) {
|
||||
return Promise.reject(new Error('Fusion requires prep.exe upload'));
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('config', JSON.stringify(req));
|
||||
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
|
||||
|
||||
@@ -17,7 +17,8 @@ export default function GaugeRing({
|
||||
color = 'var(--neon-cyan)',
|
||||
size = 100,
|
||||
}: GaugeRingProps) {
|
||||
const pct = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
const clampedValue = Math.min(max, Math.max(0, value));
|
||||
const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
|
||||
const circumference = 2 * Math.PI * 42;
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
|
||||
@@ -50,7 +51,7 @@ export default function GaugeRing({
|
||||
/>
|
||||
</svg>
|
||||
<div className="gauge-ring-center">
|
||||
<span className="gauge-ring-value font-tech">{formatValue(value, max)}</span>
|
||||
<span className="gauge-ring-value font-tech">{formatValue(clampedValue, max)}</span>
|
||||
<span className="gauge-ring-label">{label}</span>
|
||||
{sublabel && <span className="gauge-ring-sub">{sublabel}</span>}
|
||||
</div>
|
||||
|
||||
@@ -298,11 +298,11 @@ describe('GaugeRing', () => {
|
||||
|
||||
it('shows raw value in center while ring arc clamps to 0–100%', () => {
|
||||
const { rerender, container } = render(<GaugeRing value={-10} max={100} label="X" />);
|
||||
expect(screen.getByText('-10%')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
const fill = container.querySelector('.gauge-ring-fill') as SVGCircleElement;
|
||||
expect(fill.getAttribute('stroke-dashoffset')).toBe(String(2 * Math.PI * 42));
|
||||
rerender(<GaugeRing value={200} max={100} label="X" />);
|
||||
expect(screen.getByText('200%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
expect((container.querySelector('.gauge-ring-fill') as SVGCircleElement).getAttribute('stroke-dashoffset')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,8 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
routeLabel: 'Fleet Roster',
|
||||
tips: [
|
||||
'Status dot: green = online now, grey = last seen X ago',
|
||||
'Remote action buttons are disabled when the agent is offline — by design',
|
||||
'Agent logs: use Fetch Log (get_log) in Remote Control, or AI upload_log tool reports — no separate log-ingest API',
|
||||
'If agent never appears: check C2 URL is reachable from the target machine',
|
||||
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
|
||||
'Worker name you set in Forge shows as the agent name in the roster',
|
||||
@@ -404,7 +406,7 @@ export const ROADMAP_FEATURES = [
|
||||
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + Telegram/email.' },
|
||||
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health live on Command Deck.' },
|
||||
{ priority: 'high', title: 'AI Autonomy (Ollama)', desc: 'Decide loop with tool calls, self-heal, adapt-to-hardware.' },
|
||||
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
|
||||
{ priority: 'high', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail (get_log) from dashboard when agent is online.' },
|
||||
{ priority: 'high', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day + live price.' },
|
||||
{ priority: 'high', title: 'Build Manager full page', desc: 'All builds with settings, downloads, dropper one-liners, QR, pin to dropper.' },
|
||||
{ priority: 'high', title: 'Dropper endpoints', desc: '/get /install.ps1 /install.sh — one-liner remote deploy, auto-OS detect.' },
|
||||
|
||||
@@ -115,6 +115,9 @@ describe('FIELD_HELP', () => {
|
||||
|
||||
it('wallet and server_url entries warn against localhost', () => {
|
||||
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
|
||||
expect(FIELD_HELP.wallet).toMatch(/90.*106/);
|
||||
expect(FIELD_HELP.calibrate_wallet).toMatch(/90.*106/);
|
||||
expect(FIELD_HELP.calibrate_wallet).not.toMatch(/95 char/);
|
||||
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
|
||||
expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
|
||||
});
|
||||
|
||||
@@ -13,13 +13,13 @@ export const SETUP_CHEATSHEET = [
|
||||
},
|
||||
{
|
||||
title: '4. Watch the fleet',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
|
||||
},
|
||||
];
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
calibrate_wallet:
|
||||
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.',
|
||||
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 or 8 and be 90–106 characters.',
|
||||
calibrate_quick_setup:
|
||||
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
|
||||
forge_simple_mode:
|
||||
@@ -45,7 +45,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
|
||||
output_dir:
|
||||
'Optional extra copy into a subfolder (e.g. exports). The forged .exe is always written to the project root as a single file with the same name as your Fusion output setting.',
|
||||
wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8.',
|
||||
wallet: 'Monero wallet address where pool payouts go. Must be a valid mainnet address starting with 4 or 8 (90–106 characters).',
|
||||
pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.',
|
||||
pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.',
|
||||
pool_tls: 'Enable for stratum+ssl pools. Must match what your pool requires.',
|
||||
|
||||
@@ -216,4 +216,18 @@ describe('AgentsPage', () => {
|
||||
await userEvent.setup().type(search, 'nomatchxyz');
|
||||
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('select all filtered selects every visible agent', async () => {
|
||||
const a1 = mockAgent({ id: 'a1', name: 'Alpha', tags: ['prod'] });
|
||||
const a2 = mockAgent({ id: 'a2', name: 'Beta', tags: ['prod'] });
|
||||
const a3 = mockAgent({ id: 'a3', name: 'Gamma', tags: ['dev'] });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2, a3]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Alpha')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(screen.getByTitle('Filter by tag'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Select all filtered (2)' }));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function AgentsPage() {
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
selectedCount={selectedIds.size}
|
||||
filteredCount={filteredAgents.length}
|
||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||
onBulkAction={handleBulkAction}
|
||||
bulkBusy={bulkBusy}
|
||||
/>
|
||||
|
||||
93
server/web/src/pages/BuildManagerPage.test.tsx
Normal file
93
server/web/src/pages/BuildManagerPage.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import BuildManagerPage, {
|
||||
fmtSize,
|
||||
platformColor,
|
||||
platformLabel,
|
||||
truncateUrl,
|
||||
truncateWallet,
|
||||
} from './BuildManagerPage';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../components/Fleet/LanDownloadQR', () => ({
|
||||
LanDownloadQR: () => <div data-testid="lan-qr-mock" />,
|
||||
}));
|
||||
|
||||
describe('BuildManagerPage helpers', () => {
|
||||
it('truncateWallet shortens long addresses', () => {
|
||||
const w = '4' + 'A'.repeat(94);
|
||||
expect(truncateWallet(w)).toMatch(/^4AAAAA…AAAAAA$/);
|
||||
expect(truncateWallet('short')).toBe('short');
|
||||
expect(truncateWallet('')).toBe('—');
|
||||
});
|
||||
|
||||
it('truncateUrl returns host for valid URLs', () => {
|
||||
expect(truncateUrl('https://pool.example.com:3333/path')).toBe('pool.example.com:3333');
|
||||
expect(truncateUrl('not-a-url-but-long-enough-to-truncate-xyz')).toMatch(/…$/);
|
||||
});
|
||||
|
||||
it('fmtSize formats bytes to KB and MB', () => {
|
||||
expect(fmtSize(0)).toBe('—');
|
||||
expect(fmtSize(512)).toBe('1 KB');
|
||||
expect(fmtSize(2 * 1024 * 1024)).toBe('2.0 MB');
|
||||
});
|
||||
|
||||
it('platformLabel and platformColor map known platforms', () => {
|
||||
expect(platformLabel('linux')).toBe('Linux');
|
||||
expect(platformLabel()).toBe('Win');
|
||||
expect(platformColor('darwin')).toBe('#f0abfc');
|
||||
expect(platformColor('unknown-os')).toBe('#aaa');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BuildManagerPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([
|
||||
{
|
||||
id: 'build-1',
|
||||
worker_name: 'office-worker',
|
||||
server_url: 'https://c2.example.com',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
threads: 4,
|
||||
file_size: 1024 * 1024,
|
||||
file_path: 'builds/agent.exe',
|
||||
file_name: 'agent.exe',
|
||||
created_at: '2026-05-30T12:00:00.000Z',
|
||||
pool_host: 'pool.example.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
platform: 'windows',
|
||||
download_url: '/api/v1/builds/build-1/download',
|
||||
},
|
||||
]);
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue({
|
||||
port: 8989,
|
||||
host: '0.0.0.0',
|
||||
local_ips: [],
|
||||
suggested_url: 'http://localhost:8989',
|
||||
dashboard_url: 'http://localhost:8989/dashboard',
|
||||
websocket_url: 'ws://localhost:8989/ws/dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders build list after load', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<BuildManagerPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('office-worker')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,12 @@ import './BuildManagerPage.css';
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncateWallet(w: string): string {
|
||||
export function truncateWallet(w: string): string {
|
||||
if (!w || w.length < 12) return w || '—';
|
||||
return `${w.slice(0, 6)}…${w.slice(-6)}`;
|
||||
}
|
||||
|
||||
function truncateUrl(u: string): string {
|
||||
export function truncateUrl(u: string): string {
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return parsed.host;
|
||||
@@ -24,7 +24,7 @@ function truncateUrl(u: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
export function fmtSize(bytes: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
@@ -40,7 +40,7 @@ function fmtDate(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function platformLabel(p?: string): string {
|
||||
export function platformLabel(p?: string): string {
|
||||
if (!p) return 'Win';
|
||||
const m: Record<string, string> = {
|
||||
windows: 'Win', linux: 'Linux', darwin: 'macOS', universal: 'Universal',
|
||||
@@ -48,7 +48,7 @@ function platformLabel(p?: string): string {
|
||||
return m[p.toLowerCase()] ?? p;
|
||||
}
|
||||
|
||||
function platformColor(p?: string): string {
|
||||
export function platformColor(p?: string): string {
|
||||
if (!p) return 'var(--neon-cyan)';
|
||||
const m: Record<string, string> = {
|
||||
windows: '#00e5ff', linux: '#a3e635', darwin: '#f0abfc', universal: '#ffd700',
|
||||
|
||||
@@ -635,6 +635,20 @@ export default function BuilderPage() {
|
||||
fusionPrepFile,
|
||||
]);
|
||||
|
||||
// Cancel any in-progress server-side build when the component unmounts
|
||||
// (e.g. user navigates away mid-forge). This closes the M14 UI desync where
|
||||
// the server kept compiling after the Forge page was left.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const tok = cancelTokenRef.current;
|
||||
if (tok) {
|
||||
api.cancelBuild(tok).catch(() => {});
|
||||
cancelTokenRef.current = '';
|
||||
}
|
||||
batchCancelRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loadingDefaults) {
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
|
||||
72
server/web/src/pages/CruciblePage.test.tsx
Normal file
72
server/web/src/pages/CruciblePage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import {
|
||||
agentColor,
|
||||
patchLabel,
|
||||
pendingBadge,
|
||||
portsBadge,
|
||||
postureBadge,
|
||||
postureTooltip,
|
||||
sshBadge,
|
||||
thermalBadge,
|
||||
} from './CruciblePage';
|
||||
|
||||
describe('CruciblePage helpers', () => {
|
||||
it('agentColor cycles palette by agent order', () => {
|
||||
const ids = ['a', 'b', 'c'];
|
||||
expect(agentColor('a', ids)).toBe('#00f5ff');
|
||||
expect(agentColor('b', ids)).toBe('#39ff14');
|
||||
expect(agentColor('missing', ids)).toBe('#00f5ff');
|
||||
});
|
||||
|
||||
it('sshBadge reflects ssh_available tri-state', () => {
|
||||
expect(sshBadge(mockAgent({ ssh_available: true })).label).toBe('SSH ON');
|
||||
expect(sshBadge(mockAgent({ ssh_available: false })).cls).toBe('ssh-off');
|
||||
expect(sshBadge(mockAgent({ ssh_available: undefined })).label).toBe('SSH ?');
|
||||
});
|
||||
|
||||
it('postureBadge buckets score thresholds', () => {
|
||||
expect(postureBadge(undefined).cls).toBe('posture-unk');
|
||||
expect(postureBadge(90).cls).toBe('posture-good');
|
||||
expect(postureBadge(50).cls).toBe('posture-warn');
|
||||
expect(postureBadge(10).cls).toBe('posture-bad');
|
||||
});
|
||||
|
||||
it('patchLabel marks stale patches beyond 30 days', () => {
|
||||
expect(patchLabel(10)?.cls).toBe('patch-ok');
|
||||
expect(patchLabel(45)?.cls).toBe('patch-stale');
|
||||
expect(patchLabel(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('portsBadge flags high listener counts', () => {
|
||||
expect(portsBadge(5)?.cls).toBe('ports-ok');
|
||||
expect(portsBadge(25)?.cls).toBe('ports-many');
|
||||
});
|
||||
|
||||
it('pendingBadge encodes update counts', () => {
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 0 }))?.label).toBe('UP TO DATE');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 3 }))?.cls).toBe('upd-warn');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: 12 }))?.cls).toBe('upd-bad');
|
||||
expect(pendingBadge(mockAgent({ pending_updates: -1 }))?.label).toBe('UPD ?');
|
||||
});
|
||||
|
||||
it('thermalBadge shows hot and warm thresholds', () => {
|
||||
expect(thermalBadge(mockAgent({ cpu_temp_c: 60 }))).toBeNull();
|
||||
expect(thermalBadge(mockAgent({ cpu_temp_c: 70 }))?.cls).toBe('therm-warm');
|
||||
expect(thermalBadge(mockAgent({ gpu_temp_c: 85 }))?.cls).toBe('therm-hot');
|
||||
});
|
||||
|
||||
it('postureTooltip includes defender, DNS drift, and services', () => {
|
||||
const agent = mockAgent({
|
||||
defender_enabled: true,
|
||||
defender_rtp: false,
|
||||
dns_servers: ['8.8.8.8'],
|
||||
dns_drifted: true,
|
||||
services: [{ name: 'sshd', display_name: 'OpenSSH', status: 'running', start_type: 'auto' }],
|
||||
});
|
||||
const tip = postureTooltip(agent);
|
||||
expect(tip).toContain('Defender');
|
||||
expect(tip).toContain('DNS changed since last heartbeat');
|
||||
expect(tip).toContain('OpenSSH');
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,53 @@ interface TermLine {
|
||||
text: string;
|
||||
ts: Date;
|
||||
success?: boolean;
|
||||
// Structured data for rich terminal renderers
|
||||
richData?: RichTermData;
|
||||
}
|
||||
|
||||
// ── Rich terminal data types ────────────────────────────────────────────────
|
||||
|
||||
interface RichListenPort {
|
||||
port: number;
|
||||
addr: string;
|
||||
proto: string;
|
||||
process?: string;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
interface RichListenPorts {
|
||||
type: 'listen_ports';
|
||||
ports: RichListenPort[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RichPatchStatus {
|
||||
type: 'patch_status';
|
||||
pending_updates?: number;
|
||||
last_patch?: string;
|
||||
last_patch_days?: number;
|
||||
reboot_pending?: boolean;
|
||||
}
|
||||
|
||||
interface RichPostureSummary {
|
||||
type: 'posture';
|
||||
posture_score?: number;
|
||||
defender_enabled?: boolean;
|
||||
defender_rtp?: boolean;
|
||||
av_products?: string[];
|
||||
firewall_domain?: boolean;
|
||||
firewall_private?: boolean;
|
||||
firewall_public?: boolean;
|
||||
ssh_listening?: boolean;
|
||||
agent_elevated?: boolean;
|
||||
last_patch_days?: number;
|
||||
pending_updates?: number;
|
||||
reboot_pending?: boolean;
|
||||
services?: Array<{ name: string; display_name?: string; status: string; start_type: string }>;
|
||||
}
|
||||
|
||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
|
||||
|
||||
interface NodeGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -34,35 +79,35 @@ const AGENT_COLORS = [
|
||||
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
|
||||
];
|
||||
|
||||
function agentColor(agentId: string, allIds: string[]): string {
|
||||
export function agentColor(agentId: string, allIds: string[]): string {
|
||||
const idx = allIds.indexOf(agentId);
|
||||
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
|
||||
}
|
||||
|
||||
function sshBadge(agent: Agent) {
|
||||
export function sshBadge(agent: Agent) {
|
||||
if (agent.ssh_available === true) return { label: 'SSH ON', cls: 'ssh-on' };
|
||||
if (agent.ssh_available === false) return { label: 'SSH OFF', cls: 'ssh-off' };
|
||||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||||
}
|
||||
|
||||
function postureBadge(score?: number) {
|
||||
export function postureBadge(score?: number) {
|
||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
|
||||
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
|
||||
return { label: `POSTURE ${score}`, cls: 'posture-bad' };
|
||||
}
|
||||
|
||||
function patchLabel(days?: number) {
|
||||
export function patchLabel(days?: number) {
|
||||
if (days === undefined) return null;
|
||||
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||||
}
|
||||
|
||||
function portsBadge(count?: number): { label: string; cls: string } | null {
|
||||
export function portsBadge(count?: number): { label: string; cls: string } | null {
|
||||
if (count === undefined) return null;
|
||||
return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' };
|
||||
}
|
||||
|
||||
function postureTooltip(agent: Agent): string {
|
||||
export function postureTooltip(agent: Agent): string {
|
||||
const lines: string[] = [];
|
||||
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
|
||||
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
|
||||
@@ -118,7 +163,7 @@ function postureTooltip(agent: Agent): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
export function pendingBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const u = agent.pending_updates;
|
||||
if (u === undefined) return null;
|
||||
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
|
||||
@@ -135,7 +180,7 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
|
||||
// ── Resource pressure badges ───────────────────────────────────────────────
|
||||
|
||||
function thermalBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
export function thermalBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
const t = agent.gpu_temp_c ?? agent.cpu_temp_c;
|
||||
if (t === undefined) return null;
|
||||
if (t > 80) return { label: `${t}°`, cls: 'therm-hot' };
|
||||
@@ -289,44 +334,68 @@ export default function CruciblePage() {
|
||||
if (!aid) continue;
|
||||
|
||||
const msg = r.message ?? '';
|
||||
// Always update badges from probe / heartbeat command responses
|
||||
|
||||
// ── SSH badge updates ───────────────────────────────────────────────
|
||||
if (msg.includes('SSH_PROBE:ONLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
|
||||
setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
if (r.action === 'posture' || msg.includes('{')) {
|
||||
|
||||
// ── Parse structured JSON for known actions ────────────────────────
|
||||
let richData: RichTermData | undefined;
|
||||
const jsonStart = msg.indexOf('{');
|
||||
|
||||
if (jsonStart >= 0) {
|
||||
try {
|
||||
const start = msg.indexOf('{');
|
||||
if (start >= 0) {
|
||||
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean };
|
||||
if (typeof p.posture_score === 'number') {
|
||||
const parsed = JSON.parse(msg.slice(jsonStart));
|
||||
|
||||
if (r.action === 'listen_ports' && Array.isArray(parsed.ports)) {
|
||||
richData = { type: 'listen_ports', ports: parsed.ports, count: parsed.count ?? parsed.ports.length };
|
||||
} else if (r.action === 'patch_status') {
|
||||
richData = { type: 'patch_status', ...parsed };
|
||||
} else if (r.action === 'posture' && typeof parsed.posture_score === 'number') {
|
||||
richData = { type: 'posture', ...parsed };
|
||||
// Update badge state
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||||
}));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
} else {
|
||||
// Generic JSON with posture fields (legacy path)
|
||||
if (typeof parsed.posture_score === 'number') {
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
|
||||
[aid]: { score: parsed.posture_score, patchDays: parsed.last_patch_days },
|
||||
}));
|
||||
}
|
||||
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
if (parsed.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (parsed.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
} catch { /* malformed JSON — fall through to plain text */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
if (richData) {
|
||||
// Single rich-rendered line (table/block replaces raw JSON)
|
||||
lines.push({
|
||||
id: mkId(),
|
||||
agentId: aid,
|
||||
agentName: name,
|
||||
isCmd: false,
|
||||
text: line,
|
||||
ts: new Date(),
|
||||
success: r.success,
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: '', ts: new Date(),
|
||||
success: r.success, richData,
|
||||
});
|
||||
} else {
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
id: mkId(), agentId: aid, agentName: name,
|
||||
isCmd: false, text: line, ts: new Date(), success: r.success,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
|
||||
23
server/web/src/pages/GuidePage.test.tsx
Normal file
23
server/web/src/pages/GuidePage.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
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';
|
||||
|
||||
describe('GuidePage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders field guide hero and pipeline section', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<GuidePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -297,21 +297,21 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Listen Port</label>
|
||||
<input type="number" className="input" min={1024} max={65535} value={config.port}
|
||||
<label htmlFor="cfg-port" className="label">Listen Port</label>
|
||||
<input id="cfg-port" type="number" className="input" min={1024} max={65535} value={config.port}
|
||||
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
|
||||
<span className="form-hint">Restart server after changing port.</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Data Directory</label>
|
||||
<input type="text" className="input mono" value={config.data_dir}
|
||||
<label htmlFor="cfg-data-dir" className="label">Data Directory</label>
|
||||
<input id="cfg-data-dir" type="text" className="input mono" value={config.data_dir}
|
||||
onChange={(e) => updateField('data_dir', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
|
||||
<label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||||
<input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
|
||||
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
|
||||
value={s.public_url}
|
||||
onChange={(e) => updateField('server.public_url', e.target.value)} />
|
||||
@@ -325,8 +325,8 @@ export default function SettingsPage() {
|
||||
<FieldHint field="public_url" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Dashboard Subtitle</label>
|
||||
<input type="text" className="input" value={s.dashboard_subtitle}
|
||||
<label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
|
||||
<input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}
|
||||
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -343,14 +343,14 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Upstream Pool</h2>
|
||||
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host <HelpTip field="pool_host" /></label>
|
||||
<input type="text" className="input" value={config.pool.host}
|
||||
<label htmlFor="cfg-pool-host" className="label">Pool Host <HelpTip field="pool_host" /></label>
|
||||
<input id="cfg-pool-host" type="text" className="input" value={config.pool.host}
|
||||
onChange={(e) => updateField('pool.host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Port</label>
|
||||
<input type="number" className="input" value={config.pool.port}
|
||||
<label htmlFor="cfg-pool-port" className="label">Port</label>
|
||||
<input id="cfg-pool-port" type="number" className="input" value={config.pool.port}
|
||||
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
|
||||
@@ -362,13 +362,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Password</label>
|
||||
<input type="text" className="input" value={config.pool.password}
|
||||
<label htmlFor="cfg-pool-pass" className="label">Pool Password</label>
|
||||
<input id="cfg-pool-pass" type="text" className="input" value={config.pool.password}
|
||||
onChange={(e) => updateField('pool.password', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Reconnect Interval (sec)</label>
|
||||
<input type="number" className="input" min={5} value={s.pool_reconnect_seconds}
|
||||
<label htmlFor="cfg-pool-reconnect" className="label">Pool Reconnect Interval (sec)</label>
|
||||
<input id="cfg-pool-reconnect" type="number" className="input" min={5} value={s.pool_reconnect_seconds}
|
||||
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
@@ -377,15 +377,15 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Fleet Payout Wallet</h2>
|
||||
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
|
||||
<input type="text" className="input mono" placeholder="4… or 8… (90–106 chars)"
|
||||
<label htmlFor="cfg-wallet-addr" className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
|
||||
<input id="cfg-wallet-addr" type="text" className="input mono" placeholder="4… or 8… (90–106 chars)"
|
||||
value={config.wallet.address}
|
||||
onChange={(e) => updateField('wallet.address', e.target.value)} />
|
||||
<FieldHint field="calibrate_wallet" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Payment ID (optional)</label>
|
||||
<input type="text" className="input mono" value={config.wallet.payment_id}
|
||||
<label htmlFor="cfg-payment-id" className="label">Payment ID (optional)</label>
|
||||
<input id="cfg-payment-id" type="text" className="input mono" value={config.wallet.payment_id}
|
||||
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
@@ -401,19 +401,19 @@ export default function SettingsPage() {
|
||||
<h2 className="font-display">Fleet Alerts</h2>
|
||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||
<div className="form-group">
|
||||
<label className="label">Offline After (minutes)</label>
|
||||
<input type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
|
||||
<label htmlFor="cfg-alert-offline" className="label">Offline After (minutes)</label>
|
||||
<input id="cfg-alert-offline" type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
|
||||
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Hashrate Drop (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
|
||||
<label htmlFor="cfg-alert-hashrate" className="label">Hashrate Drop (%)</label>
|
||||
<input id="cfg-alert-hashrate" type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Rejection Rate (%)</label>
|
||||
<input type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
|
||||
<label htmlFor="cfg-alert-reject" className="label">Rejection Rate (%)</label>
|
||||
<input id="cfg-alert-reject" type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
|
||||
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -424,13 +424,13 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">Telegram and email when fleet thresholds fire (offline, hashrate crash, rejection spike).</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Bot Token</label>
|
||||
<input type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
|
||||
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
|
||||
<input id="cfg-tg-token" type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Telegram Chat ID</label>
|
||||
<input type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
|
||||
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
|
||||
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
|
||||
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="-100…" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,37 +445,37 @@ export default function SettingsPage() {
|
||||
<>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Host</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_host || ''}
|
||||
<label htmlFor="cfg-smtp-host" className="label">SMTP Host</label>
|
||||
<input id="cfg-smtp-host" type="text" className="input" value={config.alerts.smtp_host || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Port</label>
|
||||
<input type="number" className="input" value={config.alerts.smtp_port || 587}
|
||||
<label htmlFor="cfg-smtp-port" className="label">SMTP Port</label>
|
||||
<input id="cfg-smtp-port" type="number" className="input" value={config.alerts.smtp_port || 587}
|
||||
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP User</label>
|
||||
<input type="text" className="input" value={config.alerts.smtp_user || ''}
|
||||
<label htmlFor="cfg-smtp-user" className="label">SMTP User</label>
|
||||
<input id="cfg-smtp-user" type="text" className="input" value={config.alerts.smtp_user || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">SMTP Password</label>
|
||||
<input type="password" className="input" value={config.alerts.smtp_password || ''}
|
||||
<label htmlFor="cfg-smtp-pass" className="label">SMTP Password</label>
|
||||
<input id="cfg-smtp-pass" type="password" className="input" value={config.alerts.smtp_password || ''}
|
||||
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Email To</label>
|
||||
<input type="email" className="input" value={config.alerts.email_to || ''}
|
||||
<label htmlFor="cfg-email-to" className="label">Email To</label>
|
||||
<input id="cfg-email-to" type="email" className="input" value={config.alerts.email_to || ''}
|
||||
onChange={(e) => updateField('alerts.email_to', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Email From</label>
|
||||
<input type="email" className="input" value={config.alerts.email_from || ''}
|
||||
<label htmlFor="cfg-email-from" className="label">Email From</label>
|
||||
<input id="cfg-email-from" type="email" className="input" value={config.alerts.email_from || ''}
|
||||
onChange={(e) => updateField('alerts.email_from', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,21 +503,21 @@ export default function SettingsPage() {
|
||||
<FieldHint field="sign_enabled" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
|
||||
<input type="text" className="input mono" placeholder="AB CD EF ..."
|
||||
<label htmlFor="cfg-sign-cert" className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
|
||||
<input id="cfg-sign-cert" type="text" className="input mono" placeholder="AB CD EF ..."
|
||||
value={s.sign_cert_thumbprint || ''}
|
||||
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
|
||||
<FieldHint field="sign_cert_thumbprint" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
|
||||
<input type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
|
||||
<label htmlFor="cfg-sign-tool" className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
|
||||
<input id="cfg-sign-tool" type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
|
||||
value={s.sign_tool_path || ''}
|
||||
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
|
||||
<input type="text" className="input mono"
|
||||
<label htmlFor="cfg-sign-ts" className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
|
||||
<input id="cfg-sign-ts" type="text" className="input mono"
|
||||
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
|
||||
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
|
||||
<FieldHint field="sign_timestamp_url" />
|
||||
@@ -529,31 +529,31 @@ export default function SettingsPage() {
|
||||
<p className="section-desc">Retention and capacity for this host.</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Stats Retention (hours)</label>
|
||||
<input type="number" className="input" min={24} value={s.stats_retention_hours}
|
||||
<label htmlFor="cfg-stats-ret" className="label">Stats Retention (hours)</label>
|
||||
<input id="cfg-stats-ret" type="number" className="input" min={24} value={s.stats_retention_hours}
|
||||
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Keep Builds (days)</label>
|
||||
<input type="number" className="input" min={1} value={s.build_retention_days}
|
||||
<label htmlFor="cfg-build-ret" className="label">Keep Builds (days)</label>
|
||||
<input id="cfg-build-ret" type="number" className="input" min={1} value={s.build_retention_days}
|
||||
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Max Agents</label>
|
||||
<input type="number" className="input" min={1} value={s.max_agents}
|
||||
<label htmlFor="cfg-max-agents" className="label">Max Agents</label>
|
||||
<input id="cfg-max-agents" type="number" className="input" min={1} value={s.max_agents}
|
||||
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Max Build Size (MB)</label>
|
||||
<input type="number" className="input" min={10} value={s.max_build_size_mb}
|
||||
<label htmlFor="cfg-max-build-mb" className="label">Max Build Size (MB)</label>
|
||||
<input id="cfg-max-build-mb" type="number" className="input" min={10} value={s.max_build_size_mb}
|
||||
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">WebSocket Ping (sec)</label>
|
||||
<input type="number" className="input" min={10} value={s.websocket_ping_seconds}
|
||||
<label htmlFor="cfg-ws-ping" className="label">WebSocket Ping (sec)</label>
|
||||
<input id="cfg-ws-ping" type="number" className="input" min={10} value={s.websocket_ping_seconds}
|
||||
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
|
||||
</div>
|
||||
</NeonCard>
|
||||
@@ -591,13 +591,13 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — username</label>
|
||||
<input type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
<label htmlFor="cfg-session-user" className="label">Browser session — username</label>
|
||||
<input id="cfg-session-user" type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
onChange={(e) => setSessionUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — password</label>
|
||||
<input type="password" className="input" value={sessionPass}
|
||||
<label htmlFor="cfg-session-pass" className="label">Browser session — password</label>
|
||||
<input id="cfg-session-pass" type="password" className="input" value={sessionPass}
|
||||
onChange={(e) => setSessionPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -612,13 +612,13 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">New Username</label>
|
||||
<input type="text" className="input" placeholder="admin" value={newUser}
|
||||
<label htmlFor="cfg-new-user" className="label">New Username</label>
|
||||
<input id="cfg-new-user" type="text" className="input" placeholder="admin" value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">New Password</label>
|
||||
<input type="password" className="input" placeholder="••••••••" value={newPass}
|
||||
<label htmlFor="cfg-new-pass" className="label">New Password</label>
|
||||
<input id="cfg-new-pass" type="password" className="input" placeholder="••••••••" value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/**
|
||||
* Dashboard TypeScript models — compile-time contracts only.
|
||||
*
|
||||
* These interfaces mirror server JSON (`server/internal/models`, `ws_types.go`).
|
||||
* There are no runtime validators or schema guards here; API responses are trusted
|
||||
* after auth and validated ad hoc where it matters (forms, forge preflight, etc.).
|
||||
* Drift is caught by unit tests, shared WS type tests, and integration tests — not
|
||||
* by automatic parsing of every endpoint payload.
|
||||
*/
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
71
server/web/src/types/ws.test.ts
Normal file
71
server/web/src/types/ws.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
WSAgentLog,
|
||||
WSAgentOffline,
|
||||
WSCommandResult,
|
||||
WSDashboardInit,
|
||||
WSMessageTyped,
|
||||
WSServerLog,
|
||||
WSStatsUpdate,
|
||||
} from './ws';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
|
||||
function expectKeys(obj: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('types/ws payloads', () => {
|
||||
it('WSDashboardInit carries agents array', () => {
|
||||
const init: WSDashboardInit = { agents: [mockAgent()] };
|
||||
expectKeys(init as unknown as Record<string, unknown>, ['agents']);
|
||||
expect(init.agents[0].id).toBe('agent-001-uuid');
|
||||
});
|
||||
|
||||
it('WSStatsUpdate includes hashrate fields', () => {
|
||||
const stats: WSStatsUpdate = {
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
};
|
||||
expectKeys(stats as unknown as Record<string, unknown>, [
|
||||
'agent_id',
|
||||
'hashrate_15s',
|
||||
'hashrate_1m',
|
||||
'hashrate_15m',
|
||||
'cpu_usage_pct',
|
||||
]);
|
||||
});
|
||||
|
||||
it('WSAgentOffline and WSCommandResult shape', () => {
|
||||
const offline: WSAgentOffline = { agent_id: 'gone' };
|
||||
const cmd: WSCommandResult = { agent_id: 'a1', action: 'pause', success: true, message: 'ok' };
|
||||
expect(offline.agent_id).toBe('gone');
|
||||
expect(cmd.success).toBe(true);
|
||||
});
|
||||
|
||||
it('log payloads carry content lines', () => {
|
||||
const agentLog: WSAgentLog = { agent_id: 'a1', content: 'line1\nline2' };
|
||||
const serverLog: WSServerLog = { line: 'server boot' };
|
||||
expect(agentLog.content).toContain('line1');
|
||||
expect(serverLog.line).toBe('server boot');
|
||||
});
|
||||
|
||||
it('WSMessageTyped pairs type with payload', () => {
|
||||
const msg: WSMessageTyped<'stats_update'> = {
|
||||
type: 'stats_update',
|
||||
payload: {
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 1,
|
||||
hashrate_1m: 1,
|
||||
hashrate_15m: 1,
|
||||
cpu_usage_pct: 0,
|
||||
},
|
||||
};
|
||||
expect(msg.type).toBe('stats_update');
|
||||
expect((msg.payload as WSStatsUpdate).agent_id).toBe('a1');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
['src/hooks/**', 'happy-dom'],
|
||||
['src/pages/**', 'happy-dom'],
|
||||
['src/components/**', 'happy-dom'],
|
||||
['src/App.test.tsx', 'happy-dom'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user