From 4482c15b1156230101882adc3f06ae6490244075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?= Date: Thu, 23 Apr 2026 13:36:54 +0300 Subject: [PATCH 1/3] add multiple fallback pools to setup and settings --- server/src/config-generator.test.ts | 193 +++++++++--- server/src/config-generator.ts | 50 +-- server/src/index.ts | 15 +- server/src/types.ts | 1 + src/components/settings/ConfigurationTab.tsx | 243 ++++++++++++++- src/components/setup/PoolPicker.tsx | 182 +++++++++++ src/components/setup/SetupWizard.tsx | 4 +- .../setup/steps/MiningModeSelection.tsx | 2 +- src/components/setup/steps/PoolConfigStep.tsx | 289 +++++++----------- src/components/setup/steps/ReviewStart.tsx | 13 + .../setup/steps/TemplateModeSelection.tsx | 1 + src/components/setup/types.ts | 2 + 12 files changed, 750 insertions(+), 245 deletions(-) create mode 100644 src/components/setup/PoolPicker.tsx diff --git a/server/src/config-generator.test.ts b/server/src/config-generator.test.ts index 6c1e8e2..58202d8 100644 --- a/server/src/config-generator.test.ts +++ b/server/src/config-generator.test.ts @@ -1,65 +1,166 @@ import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { generateJdcConfig, generateTranslatorConfig, normalizeSetupData } from './config-generator.js'; -import type { SetupData } from './types.js'; +import test from 'node:test'; -const BASE_DATA: SetupData = { - miningMode: 'pool', - mode: 'jd', - pool: { - name: 'Custom Pool', - address: 'pool.example.com', - port: 34254, - authority_public_key: 'authority-key', - }, - bitcoin: { - network: 'testnet4', - os: 'linux', - customDataDir: '', - socket_path: '/tmp/bitcoin.sock', - }, - jdc: { - user_identity: 'miner.worker1', - jdc_signature: 'custom-miner-tag', - coinbase_reward_address: 'tb1qexample', - }, - translator: { - user_identity: 'miner.worker1', - enable_vardiff: true, - aggregate_channels: false, - min_hashrate: 100_000_000_000_000, - shares_per_minute: 12.5, - downstream_extranonce2_size: 8, - }, +import { generateTranslatorConfig, generateJdcConfig, normalizeSetupData } from './config-generator.js'; +import type { PoolConfig, SetupData } from './types.js'; + +const PRIMARY: PoolConfig = { + name: 'Primary', + address: 'pool-a.example.com', + port: 3333, + authority_public_key: '9awtMD5KQgvRUh2yFbjVeT7b6hjipWcAsQHd6wEhgtDT9soosna', }; -test('translator config uses advanced setup values', () => { - const config = generateTranslatorConfig(BASE_DATA); +const FALLBACK_1: PoolConfig = { + name: 'Fallback 1', + address: 'pool-b.example.com', + port: 4444, + authority_public_key: '9bCoFxTszKCuffyywH5uS5o6WcU4vsjTH2axxc7wE86y2HhvULU', +}; + +const FALLBACK_2: PoolConfig = { + name: 'Fallback 2', + address: 'pool-c.example.com', + port: 5555, + authority_public_key: '9cDpGyTtaLDvggzzxI6vT6p7XdV5wtkUI3byyd8xF97z3IiwVMV', +}; + +function countOccurrences(haystack: string, needle: string): number { + let count = 0; + let i = 0; + while ((i = haystack.indexOf(needle, i)) !== -1) { + count += 1; + i += needle.length; + } + return count; +} + +function baseData(overrides: Partial = {}): SetupData { + return { + miningMode: 'pool', + mode: 'no-jd', + pool: PRIMARY, + fallbackPools: [], + bitcoin: null, + jdc: null, + translator: { + user_identity: 'worker', + enable_vardiff: true, + aggregate_channels: false, + min_hashrate: 100_000_000_000_000, + shares_per_minute: 6, + downstream_extranonce2_size: 4, + }, + ...overrides, + }; +} + +test('translator config emits one [[upstreams]] block when fallbackPools is empty', () => { + const toml = generateTranslatorConfig(baseData({ fallbackPools: [] })); + assert.equal(countOccurrences(toml, '[[upstreams]]'), 1); +}); + +test('translator config emits N+1 [[upstreams]] blocks for N fallbacks, in order', () => { + const toml = generateTranslatorConfig(baseData({ fallbackPools: [FALLBACK_1, FALLBACK_2] })); + assert.equal(countOccurrences(toml, '[[upstreams]]'), 3); + const primaryIdx = toml.indexOf(PRIMARY.address); + const f1Idx = toml.indexOf(FALLBACK_1.address); + const f2Idx = toml.indexOf(FALLBACK_2.address); + assert.ok(primaryIdx < f1Idx && f1Idx < f2Idx, 'upstreams must appear in declaration order'); +}); + +test('translator config in JD mode never emits fallbacks — JDC handles failover', () => { + const data = baseData({ + mode: 'jd', + fallbackPools: [FALLBACK_1, FALLBACK_2], + bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' }, + jdc: { user_identity: 'w', jdc_signature: '', coinbase_reward_address: 'bc1q' }, + }); + const toml = generateTranslatorConfig(data); + assert.equal(countOccurrences(toml, '[[upstreams]]'), 1); + assert.match(toml, /address = "sv2-jdc"/); + assert.ok( + !toml.includes(FALLBACK_1.address) && !toml.includes(FALLBACK_2.address), + 'fallbacks should not appear in translator TOML in JD mode', + ); +}); +test('jdc config emits N+1 [[upstreams]] blocks for N fallbacks', () => { + const data = baseData({ + mode: 'jd', + fallbackPools: [FALLBACK_1, FALLBACK_2], + bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' }, + jdc: { user_identity: 'w', jdc_signature: 'sig', coinbase_reward_address: 'bc1q' }, + }); + const toml = generateJdcConfig(data); + assert.ok(toml); + assert.equal(countOccurrences(toml!, '[[upstreams]]'), 3); + assert.match(toml!, /\(primary\)/); + assert.match(toml!, /\(fallback 1\)/); + assert.match(toml!, /\(fallback 2\)/); +}); + +test('jdc config in sovereign-solo mode emits empty upstreams regardless of fallbackPools', () => { + const data = baseData({ + miningMode: 'solo', + mode: 'jd', + pool: null, + fallbackPools: [FALLBACK_1], + bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' }, + jdc: { user_identity: 'w', jdc_signature: '', coinbase_reward_address: 'bc1q' }, + }); + const toml = generateJdcConfig(data); + assert.ok(toml); + assert.match(toml!, /upstreams = \[\]/); + assert.equal(countOccurrences(toml!, '[[upstreams]]'), 0); +}); + +test('translator config uses advanced setup values', () => { + const data = baseData({ + translator: { + user_identity: 'worker', + enable_vardiff: true, + aggregate_channels: false, + min_hashrate: 100_000_000_000_000, + shares_per_minute: 12.5, + downstream_extranonce2_size: 8, + }, + }); + const config = generateTranslatorConfig(data); assert.match(config, /downstream_extranonce2_size = 8/); assert.match(config, /shares_per_minute = 12\.5/); }); test('jdc config uses shared shares-per-minute and miner signature', () => { - const config = generateJdcConfig(BASE_DATA); - + const data = baseData({ + mode: 'jd', + bitcoin: { network: 'mainnet', os: 'linux', customDataDir: '', socket_path: '/tmp/x' }, + jdc: { user_identity: 'w', jdc_signature: 'custom-miner-tag', coinbase_reward_address: 'bc1q' }, + translator: { + user_identity: 'w', + enable_vardiff: true, + aggregate_channels: false, + min_hashrate: 100_000_000_000_000, + shares_per_minute: 12.5, + downstream_extranonce2_size: 4, + }, + }); + const config = generateJdcConfig(data); assert.ok(config); - assert.match(config, /shares_per_minute = 12\.5/); - assert.match(config, /jdc_signature = "custom-miner-tag"/); + assert.match(config!, /shares_per_minute = 12\.5/); + assert.match(config!, /jdc_signature = "custom-miner-tag"/); }); test('normalization backfills advanced defaults for old saved configs', () => { - const data = { - ...BASE_DATA, + const data = baseData({ translator: { - ...BASE_DATA.translator, - shares_per_minute: undefined, - downstream_extranonce2_size: undefined, - }, - } as unknown as SetupData; - + user_identity: 'w', + enable_vardiff: true, + aggregate_channels: false, + min_hashrate: 100_000_000_000_000, + } as unknown as SetupData['translator'], + }); const normalized = normalizeSetupData(data); - assert.equal(normalized.translator.shares_per_minute, 6); assert.equal(normalized.translator.downstream_extranonce2_size, 4); }); diff --git a/server/src/config-generator.ts b/server/src/config-generator.ts index ac7e21a..d755548 100644 --- a/server/src/config-generator.ts +++ b/server/src/config-generator.ts @@ -82,13 +82,13 @@ export function normalizeSetupData(data: SetupData): SetupData { */ export function generateTranslatorConfig(data: SetupData): string { const normalizedData = normalizeSetupData(data); - const { pool, translator, mode } = normalizedData; + const { pool, fallbackPools, translator, mode } = normalizedData; const isJdMode = mode === 'jd'; - + if (!translator || (!isJdMode && !pool)) { throw new Error('Pool and translator configuration are required'); } - + // If JD mode, translator connects to JDC container; otherwise directly to pool // Both containers are on sv2-network, so we can use the container name as hostname // (hostname resolution supported since sv2-apps PR #286) @@ -109,6 +109,18 @@ export function generateTranslatorConfig(data: SetupData): string { DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE, ); + const fallbackBlocks = !isJdMode + ? fallbackPools + .map( + (p) => `\n[[upstreams]] +address = "${p.address}" +port = ${p.port} +authority_pubkey = "${p.authority_public_key}" +`, + ) + .join('') + : ''; + return `# Translator Proxy Configuration # Generated by sv2-ui @@ -148,7 +160,7 @@ job_keepalive_interval_secs = 60 address = "${upstreamAddress}" port = ${upstreamPort} authority_pubkey = "${authorityPubkey}" -`; +${fallbackBlocks}`; } /** @@ -159,7 +171,7 @@ export function generateJdcConfig(data: SetupData): string | null { return null; } - const { pool, jdc, bitcoin } = data; + const { pool, fallbackPools, jdc, bitcoin } = data; const isSovereignSolo = data.miningMode === 'solo'; const jdcSignature = isSovereignSolo ? (jdc.jdc_signature || jdc.user_identity) : jdc.jdc_signature; @@ -172,21 +184,25 @@ export function generateJdcConfig(data: SetupData): string | null { // Fee threshold and min interval for template provider const feeThreshold = '1000'; const minInterval = '5'; - const upstreamsConfig = !isSovereignSolo && pool - ? `# Upstream pool connection -[[upstreams]] -authority_pubkey = "${pool.authority_public_key}" -pool_address = "${pool.address}" -pool_port = ${pool.port} -jds_address = "${pool.address}" -jds_port = 3334 - -` - : `# No upstreams needed in solo mining mode. -upstreams = [] + const renderUpstream = (p: NonNullable): string => `[[upstreams]] +authority_pubkey = "${p.authority_public_key}" +pool_address = "${p.address}" +pool_port = ${p.port} +jds_address = "${p.address}" +jds_port = 3334 `; + let upstreamsConfig: string; + if (isSovereignSolo || !pool) { + upstreamsConfig = `# No upstreams needed in solo mining mode.\nupstreams = []\n\n`; + } else { + upstreamsConfig = `# Upstream pool connection (primary)\n${renderUpstream(pool)}\n`; + fallbackPools.forEach((fp, i) => { + upstreamsConfig += `# Upstream pool connection (fallback ${i + 1})\n${renderUpstream(fp)}\n`; + }); + } + return `# JD Client Configuration # Generated by sv2-ui diff --git a/server/src/index.ts b/server/src/index.ts index 1cbf956..6395432 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -51,7 +51,17 @@ app.use(express.static(UI_DIR)); async function loadState(): Promise<{ configured: boolean; miningMode: 'solo' | 'pool' | null; mode: 'jd' | 'no-jd' | null; data: SetupData | null }> { try { const content = await fs.readFile(STATE_FILE, 'utf-8'); - return JSON.parse(content); + const parsed = JSON.parse(content); + if (parsed.data) { + // Migrate legacy single-fallback shape to fallbackPools array. + if (!Array.isArray(parsed.data.fallbackPools)) { + parsed.data.fallbackPools = parsed.data.fallbackPool + ? [parsed.data.fallbackPool] + : []; + } + delete parsed.data.fallbackPool; + } + return parsed; } catch { return { configured: false, miningMode: null, mode: null, data: null }; } @@ -204,6 +214,9 @@ app.put('/api/config', async (req, res) => { mode: updates.mode ?? currentData.mode, miningMode: updates.miningMode ?? currentData.miningMode, pool: updates.pool ?? currentData.pool, + fallbackPools: Array.isArray(updates.fallbackPools) + ? updates.fallbackPools + : currentData.fallbackPools, bitcoin: updates.bitcoin ?? currentData.bitcoin, jdc: updates.jdc ?? currentData.jdc, translator: updates.translator ?? currentData.translator, diff --git a/server/src/types.ts b/server/src/types.ts index ebcee76..2983283 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -40,6 +40,7 @@ export interface SetupData { miningMode: MiningMode; mode: SetupMode; pool: PoolConfig | null; + fallbackPools: PoolConfig[]; bitcoin: BitcoinConfig | null; jdc: JdcConfig | null; translator: TranslatorConfig; diff --git a/src/components/settings/ConfigurationTab.tsx b/src/components/settings/ConfigurationTab.tsx index ca7977d..0864894 100644 --- a/src/components/settings/ConfigurationTab.tsx +++ b/src/components/settings/ConfigurationTab.tsx @@ -25,6 +25,9 @@ import { Pencil, Check, X, + ArrowUp, + ArrowDown, + Plus, } from 'lucide-react'; function clearPersistedDashboardState() { @@ -50,7 +53,16 @@ function clearPersistedDashboardState() { }); } -type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced'; +type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced' | 'fallbacks'; + +type DraftPool = { name: string; address: string; port: number; authority_public_key: string }; + +const EMPTY_FALLBACK: DraftPool = { + name: 'Custom Pool', + address: '', + port: 34254, + authority_public_key: '', +}; const DEFAULT_SHARES_PER_MINUTE = 6; const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4; @@ -65,6 +77,10 @@ function isPositiveInteger(value: string): boolean { return isPositiveNumber(value) && Number.isInteger(parsed); } +function isFallbackValid(p: DraftPool): boolean { + return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key); +} + /** * Configuration tab for Settings page. * Shows current setup and allows inline editing of pool and template mode. @@ -93,7 +109,7 @@ export function ConfigurationTab() { } = useControlApi(); const [editing, setEditing] = useState(null); - const [editPool, setEditPool] = useState<{ name: string; address: string; port: number; authority_public_key: string } | null>(null); + const [editPool, setEditPool] = useState(null); const [isCustomPool, setIsCustomPool] = useState(false); const [editMode, setEditMode] = useState<'jd' | 'no-jd' | null>(null); const [editIdentity, setEditIdentity] = useState(''); @@ -102,6 +118,7 @@ export function ConfigurationTab() { shares_per_minute: string; downstream_extranonce2_size: string; } | null>(null); + const [editFallbacks, setEditFallbacks] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); const clearDashboardClientState = () => { @@ -203,6 +220,11 @@ export function ConfigurationTab() { setEditing('advanced'); }; + const startEditFallbacks = () => { + setEditFallbacks((config?.fallbackPools ?? []).map((p) => ({ ...p }))); + setEditing('fallbacks'); + }; + const cancelEdit = () => { setEditing(null); setEditPool(null); @@ -211,6 +233,7 @@ export function ConfigurationTab() { setEditIdentity(''); setEditSignature(''); setEditAdvanced(null); + setEditFallbacks(null); }; const isPoolValid = @@ -223,6 +246,7 @@ export function ConfigurationTab() { !!editAdvanced && isPositiveNumber(editAdvanced.shares_per_minute) && isPositiveInteger(editAdvanced.downstream_extranonce2_size); + const areFallbacksValid = editFallbacks?.every(isFallbackValid) ?? true; const saveEdit = () => { if (!config) return; @@ -260,6 +284,9 @@ export function ConfigurationTab() { shares_per_minute: Number(editAdvanced.shares_per_minute), downstream_extranonce2_size: Number(editAdvanced.downstream_extranonce2_size), }; + } else if (editing === 'fallbacks' && editFallbacks) { + if (!areFallbacksValid) return; + updated.fallbackPools = editFallbacks; } setup(updated, { @@ -615,6 +642,47 @@ export function ConfigurationTab() { /> )} + {/* Fallback Pools — pool mode only (sovereign solo has no upstreams) */} + {!isSovereignSolo && config.pool && ( + None configured

+ ) : ( +
+ {config.fallbackPools.map((fp, i) => ( +
+

+ Fallback {i + 1} +

+

+ {fp.address}:{fp.port} +

+
+ ))} +
+ ) + } + editContent={ + editFallbacks && ( + p.badge !== 'coming-soon')} + /> + ) + } + /> + )} + {/* Username / Identity */} {(config.translator?.user_identity || config.jdc?.user_identity) && (() => { const identity = config.translator?.user_identity || config.jdc?.user_identity || ''; @@ -962,3 +1030,174 @@ function PoolOption({ ); } + +function matchPoolByEndpoint(pools: KnownPool[], p: DraftPool): KnownPool | undefined { + return pools.find((kp) => kp.address === p.address && kp.port === p.port); +} + +function FallbackListEditor({ + value, + onChange, + pools, +}: { + value: DraftPool[]; + onChange: (next: DraftPool[]) => void; + pools: KnownPool[]; +}) { + const update = (i: number, patch: Partial) => { + onChange(value.map((p, idx) => (idx === i ? { ...p, ...patch } : p))); + }; + + const replaceAt = (i: number, next: DraftPool) => { + onChange(value.map((p, idx) => (idx === i ? next : p))); + }; + + const remove = (i: number) => onChange(value.filter((_, idx) => idx !== i)); + + const move = (i: number, dir: -1 | 1) => { + const t = i + dir; + if (t < 0 || t >= value.length) return; + const next = value.slice(); + [next[i], next[t]] = [next[t], next[i]]; + onChange(next); + }; + + const add = () => onChange([...value, { ...EMPTY_FALLBACK }]); + + return ( +
+ {value.map((fp, i) => { + const matched = matchPoolByEndpoint(pools, fp); + const isCustom = !matched; + return ( +
+
+

+ Fallback {i + 1} +

+
+ + + +
+
+ +
+ {pools.map((kp) => ( + + replaceAt(i, { + name: kp.name, + address: kp.address, + port: kp.port, + authority_public_key: kp.authority_public_key, + }) + } + /> + ))} + +
+ + {isCustom && ( +
+
+ + update(i, { address: e.target.value })} + placeholder="pool.example.com" + className="w-full h-9 px-3 rounded-lg border border-input bg-background text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> +
+
+ + update(i, { port: parseInt(e.target.value) || 34254 })} + className="w-full h-9 px-3 rounded-lg border border-input bg-background text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> +
+
+ + + update(i, { authority_public_key: stripWrappingQuotes(e.target.value) }) + } + placeholder="Enter pool's authority public key" + className="w-full h-9 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> + {getPoolAuthorityPubkeyError(fp.authority_public_key) && ( +

+ {getPoolAuthorityPubkeyError(fp.authority_public_key)} +

+ )} +
+
+ )} +
+ ); + })} + + +
+ ); +} diff --git a/src/components/setup/PoolPicker.tsx b/src/components/setup/PoolPicker.tsx new file mode 100644 index 0000000..aca92c0 --- /dev/null +++ b/src/components/setup/PoolPicker.tsx @@ -0,0 +1,182 @@ +import { Check } from 'lucide-react'; +import { PoolIcon } from '@/components/ui/pool-icon'; +import type { KnownPool } from '@/lib/pools'; +import type { PoolConfig } from './types'; +import { + getPoolAuthorityPubkeyError, + stripWrappingQuotes, +} from '@/lib/utils'; + +interface PoolPickerProps { + pools: KnownPool[]; + value: PoolConfig; + onChange: (value: PoolConfig) => void; + formIdPrefix: string; +} + +function matchKnownPool(pools: KnownPool[], value: PoolConfig): KnownPool | undefined { + return pools.find((p) => p.address === value.address && p.port === value.port); +} + +export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerProps) { + const matched = matchKnownPool(pools, value); + const isCustom = !matched; + + const selectKnown = (pool: KnownPool) => { + if (pool.badge === 'coming-soon') return; + onChange({ + name: pool.name, + address: pool.address, + port: pool.port, + authority_public_key: pool.authority_public_key, + }); + }; + + const selectCustom = () => { + if (isCustom) return; + onChange({ name: 'Custom Pool', address: '', port: 34254, authority_public_key: '' }); + }; + + const updateCustomField = (field: keyof PoolConfig, val: string | number) => { + const normalized = + field === 'authority_public_key' && typeof val === 'string' + ? stripWrappingQuotes(val) + : val; + onChange({ ...value, [field]: normalized }); + }; + + return ( +
+
+ {pools.map((pool) => { + const isSelected = matched?.id === pool.id; + const isDisabled = pool.badge === 'coming-soon'; + return ( + + ); + })} + + +
+ + {isCustom && ( +
+
+ + updateCustomField('address', e.target.value)} + placeholder="pool.example.com" + aria-required="true" + autoComplete="off" + className="w-full h-10 px-3 rounded-lg border border-input bg-background focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> +
+
+ + updateCustomField('port', parseInt(e.target.value) || 34254)} + aria-required="true" + className="w-full h-10 px-3 rounded-lg border border-input bg-background focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> +
+
+ + updateCustomField('authority_public_key', e.target.value)} + placeholder="Enter pool's authority public key" + aria-required="true" + aria-describedby={`${formIdPrefix}-pubkey-hint`} + autoComplete="off" + className="w-full h-10 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> + {getPoolAuthorityPubkeyError(value.authority_public_key) && ( +

+ {getPoolAuthorityPubkeyError(value.authority_public_key)} +

+ )} +

The pool's public key for Noise protocol authentication

+
+
+ )} +
+ ); +} diff --git a/src/components/setup/SetupWizard.tsx b/src/components/setup/SetupWizard.tsx index 8914708..0df83c2 100644 --- a/src/components/setup/SetupWizard.tsx +++ b/src/components/setup/SetupWizard.tsx @@ -137,9 +137,9 @@ export function SetupWizard() { if (idx > 0) { const prevStep = steps[idx - 1]; if (prevStep === 'mining-mode') { - updateData({ miningMode: null, mode: null, pool: null, bitcoin: null, jdc: null, translator: null }); + updateData({ miningMode: null, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null }); } else if (prevStep === 'template-mode') { - updateData({ pool: null, bitcoin: null, jdc: null, translator: null }); + updateData({ pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null }); } setCurrentStep(prevStep); } diff --git a/src/components/setup/steps/MiningModeSelection.tsx b/src/components/setup/steps/MiningModeSelection.tsx index 26a4c22..77fce63 100644 --- a/src/components/setup/steps/MiningModeSelection.tsx +++ b/src/components/setup/steps/MiningModeSelection.tsx @@ -18,7 +18,7 @@ export function MiningModeSelection({ updateData, onNext }: StepProps) { const handleSelect = (miningMode: MiningMode) => { if (phase !== 'idle') return; setSelectedMode(miningMode); - updateData({ miningMode, mode: null, pool: null, bitcoin: null, jdc: null, translator: null }); + updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null }); setPhase('arming'); }; diff --git a/src/components/setup/steps/PoolConfigStep.tsx b/src/components/setup/steps/PoolConfigStep.tsx index 9384215..e72845a 100644 --- a/src/components/setup/steps/PoolConfigStep.tsx +++ b/src/components/setup/steps/PoolConfigStep.tsx @@ -1,69 +1,64 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; import { StepProps, PoolConfig } from '../types'; -import { Check } from 'lucide-react'; -import { PoolIcon } from '@/components/ui/pool-icon'; -import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS, type KnownPool } from '@/lib/pools'; -import { - getPoolAuthorityPubkeyError, - isValidPoolAuthorityPubkey, - stripWrappingQuotes, -} from '@/lib/utils'; +import { Plus, ArrowUp, ArrowDown, X } from 'lucide-react'; +import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS } from '@/lib/pools'; +import { isValidPoolAuthorityPubkey } from '@/lib/utils'; +import { PoolPicker } from '../PoolPicker'; + +const EMPTY_CUSTOM: PoolConfig = { + name: 'Custom Pool', + address: '', + port: 34254, + authority_public_key: '', +}; + +function isPoolValid(pool: PoolConfig): boolean { + return pool.address.length > 0 && isValidPoolAuthorityPubkey(pool.authority_public_key); +} export function PoolConfigStep({ data, updateData, onNext }: StepProps) { const isSoloMode = data.miningMode === 'solo'; const isJdMode = data.mode === 'jd'; - const pools = isSoloMode ? SOLO_POOLS : (isJdMode ? POOL_MINING_JD : POOL_MINING_NO_JD); - const defaultPool = pools.find(p => p.badge !== 'coming-soon') ?? null; - - const [isCustom, setIsCustom] = useState(false); - const [selectedPoolId, setSelectedPoolId] = useState(() => { - if (data.pool?.address) return null; // already has a value from back-navigation - if (defaultPool) { - // pre-populate data so Continue is enabled immediately - setTimeout(() => updateData({ pool: { name: defaultPool.name, address: defaultPool.address, port: defaultPool.port, authority_public_key: defaultPool.authority_public_key } }), 0); - return defaultPool.id; - } - return null; - }); - const [customPool, setCustomPool] = useState({ - name: 'Custom Pool', - address: '', - port: 34254, - authority_public_key: '', - }); - - const handleSelectPool = (pool: KnownPool) => { - if (pool.badge === 'coming-soon') return; - setSelectedPoolId(pool.id); - updateData({ pool: { name: pool.name, address: pool.address, port: pool.port, authority_public_key: pool.authority_public_key } }); - setIsCustom(false); + const first = pools.find((p) => p.badge !== 'coming-soon'); + const defaultPrimary: PoolConfig = first + ? { name: first.name, address: first.address, port: first.port, authority_public_key: first.authority_public_key } + : EMPTY_CUSTOM; + + const primary: PoolConfig = data.pool ?? defaultPrimary; + useEffect(() => { + if (!data.pool) updateData({ pool: defaultPrimary }); + // Pre-populate primary on first mount so Continue is enabled immediately. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const fallbacks = data.fallbackPools; + + const setPrimary = (pool: PoolConfig) => updateData({ pool }); + + const setFallback = (index: number, pool: PoolConfig) => { + const next = fallbacks.map((f, i) => (i === index ? pool : f)); + updateData({ fallbackPools: next }); + }; + + const addFallback = () => { + updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM }] }); }; - const handleCustomChange = (field: keyof PoolConfig, value: string | number) => { - // Normalize the stored pubkey so the TOML writer receives a clean value. - // isValidPoolAuthorityPubkey also strips internally for its own robustness, - // but the stored value has to be unquoted independently. - const normalized = - field === 'authority_public_key' && typeof value === 'string' - ? stripWrappingQuotes(value) - : value; - const updated = { ...customPool, [field]: normalized }; - setCustomPool(updated); - updateData({ pool: updated }); + const removeFallback = (index: number) => { + updateData({ fallbackPools: fallbacks.filter((_, i) => i !== index) }); }; - const handleEnableCustom = () => { - setIsCustom(true); - setSelectedPoolId(null); - updateData({ pool: customPool }); + const moveFallback = (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= fallbacks.length) return; + const next = fallbacks.slice(); + [next[index], next[target]] = [next[target], next[index]]; + updateData({ fallbackPools: next }); }; - const isValid = - data.pool && - data.pool.address && - isValidPoolAuthorityPubkey(data.pool.authority_public_key); + const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid); return (
@@ -72,138 +67,80 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) { {isSoloMode ? 'Select Solo Pool' : 'Select Pool'}

- {isSoloMode ? 'Choose a solo mining pool to connect to' : isJdMode ? 'Choose a pool that supports Job Declaration' : 'Choose your mining pool'} + {isSoloMode + ? 'Choose a solo mining pool to connect to' + : isJdMode + ? 'Choose a pool that supports Job Declaration' + : 'Choose your mining pool'}

-
- {pools.map((pool) => { - const isSelected = selectedPoolId === pool.id; - const isDisabled = pool.badge === 'coming-soon'; - return ( - + +
- - ); - })} + setFallback(index, pool)} + formIdPrefix={`fallback-${index}`} + /> + + ))} - - - - {isCustom && ( -
-
- - handleCustomChange('address', e.target.value)} - placeholder="pool.example.com" - aria-required="true" - autoComplete="off" - className="w-full h-10 px-3 rounded-lg border border-input bg-background focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" - /> -
-
- - handleCustomChange('port', parseInt(e.target.value) || 34254)} - aria-required="true" - className="w-full h-10 px-3 rounded-lg border border-input bg-background focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" - /> -
-
- - handleCustomChange('authority_public_key', e.target.value)} - placeholder="Enter pool's authority public key" - aria-required="true" - aria-describedby="pool-pubkey-hint" - autoComplete="off" - className="w-full h-10 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" - /> - {getPoolAuthorityPubkeyError(customPool.authority_public_key) && ( -

- {getPoolAuthorityPubkeyError(customPool.authority_public_key)} -

- )} -

The pool's public key for Noise protocol authentication

-
)} diff --git a/src/components/setup/steps/ReviewStart.tsx b/src/components/setup/steps/ReviewStart.tsx index a2d57e3..161659f 100644 --- a/src/components/setup/steps/ReviewStart.tsx +++ b/src/components/setup/steps/ReviewStart.tsx @@ -212,6 +212,19 @@ export function ReviewStart({ data, onComplete }: ReviewStartProps) { will not track workers individually. )} + {data.fallbackPools.map((fp, i) => ( +
+
+ Fallback {i + 1} +
+
+ {fp.address}:{fp.port} +
+
+ {fp.authority_public_key} +
+
+ ))} )} diff --git a/src/components/setup/steps/TemplateModeSelection.tsx b/src/components/setup/steps/TemplateModeSelection.tsx index ffe4052..3cfa049 100644 --- a/src/components/setup/steps/TemplateModeSelection.tsx +++ b/src/components/setup/steps/TemplateModeSelection.tsx @@ -16,6 +16,7 @@ export function TemplateModeSelection({ data, updateData, onNext }: StepProps) { updateData({ mode, pool: mode === 'jd' ? null : data.pool, + fallbackPools: mode === 'jd' ? [] : data.fallbackPools, bitcoin: mode === 'jd' ? data.bitcoin : null, jdc: mode === 'jd' ? data.jdc : null, }); diff --git a/src/components/setup/types.ts b/src/components/setup/types.ts index 5cce3cf..8012e47 100644 --- a/src/components/setup/types.ts +++ b/src/components/setup/types.ts @@ -40,6 +40,7 @@ export interface SetupData { miningMode: MiningMode | null; mode: SetupMode | null; pool: PoolConfig | null; + fallbackPools: PoolConfig[]; bitcoin: BitcoinConfig | null; jdc: JdcConfig | null; translator: TranslatorConfig | null; @@ -49,6 +50,7 @@ export const initialSetupData: SetupData = { miningMode: null, mode: null, pool: null, + fallbackPools: [], bitcoin: null, jdc: null, translator: null, From f8ef94961c66719d24a4446dae1c8e6914c49117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?= Date: Fri, 1 May 2026 11:13:18 +0300 Subject: [PATCH 2/3] fix duplicate detection and solo-mode visibility for fallback pools --- src/components/settings/ConfigurationTab.tsx | 85 +++++++++++-------- src/components/setup/PoolPicker.tsx | 6 +- .../setup/steps/MiningModeSelection.tsx | 8 +- src/components/setup/steps/PoolConfigStep.tsx | 73 +++++++++++----- .../setup/steps/TemplateModeSelection.tsx | 5 ++ src/lib/pools.ts | 25 ++++++ 6 files changed, 141 insertions(+), 61 deletions(-) diff --git a/src/components/settings/ConfigurationTab.tsx b/src/components/settings/ConfigurationTab.tsx index 0864894..84f6f33 100644 --- a/src/components/settings/ConfigurationTab.tsx +++ b/src/components/settings/ConfigurationTab.tsx @@ -7,15 +7,14 @@ import { Badge } from '@/components/ui/badge'; import { PoolIcon } from '@/components/ui/pool-icon'; import { useSetupStatus } from '@/hooks/useSetupStatus'; import { useControlApi, getCurrentConfig } from '@/hooks/useControlApi'; -import { getPoolsForMode, type KnownPool } from '@/lib/pools'; +import { getPoolsForMode, EMPTY_CUSTOM_POOL, isPoolValid, isSamePool, type KnownPool } from '@/lib/pools'; import { getIdentifierError, getPoolAuthorityPubkeyError, isTomlSafeIdentifier, - isValidPoolAuthorityPubkey, stripWrappingQuotes, } from '@/lib/utils'; -import type { SetupData } from '@/components/setup/types'; +import type { PoolConfig, SetupData } from '@/components/setup/types'; import { Loader2, AlertCircle, @@ -55,14 +54,7 @@ function clearPersistedDashboardState() { type EditingField = null | 'pool' | 'mode' | 'identity' | 'signature' | 'advanced' | 'fallbacks'; -type DraftPool = { name: string; address: string; port: number; authority_public_key: string }; - -const EMPTY_FALLBACK: DraftPool = { - name: 'Custom Pool', - address: '', - port: 34254, - authority_public_key: '', -}; +type DraftPool = PoolConfig; const DEFAULT_SHARES_PER_MINUTE = 6; const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4; @@ -77,10 +69,6 @@ function isPositiveInteger(value: string): boolean { return isPositiveNumber(value) && Number.isInteger(parsed); } -function isFallbackValid(p: DraftPool): boolean { - return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key); -} - /** * Configuration tab for Settings page. * Shows current setup and allows inline editing of pool and template mode. @@ -236,17 +224,25 @@ export function ConfigurationTab() { setEditFallbacks(null); }; - const isPoolValid = - !!editPool?.address && - !!editPool?.authority_public_key && - isValidPoolAuthorityPubkey(editPool.authority_public_key); + const isEditPoolValid = !!editPool && isPoolValid(editPool); const isIdentityValid = isTomlSafeIdentifier(editIdentity); const isSignatureValid = editSignature === '' || isTomlSafeIdentifier(editSignature); const isAdvancedValid = !!editAdvanced && isPositiveNumber(editAdvanced.shares_per_minute) && isPositiveInteger(editAdvanced.downstream_extranonce2_size); - const areFallbacksValid = editFallbacks?.every(isFallbackValid) ?? true; + const fallbackDuplicateIndex = (() => { + if (!editFallbacks || !config?.pool) return -1; + const all = [config.pool, ...editFallbacks]; + for (let i = 1; i < all.length; i++) { + for (let j = 0; j < i; j++) { + if (isSamePool(all[i], all[j])) return i - 1; + } + } + return -1; + })(); + const areFallbacksValid = + (editFallbacks?.every(isPoolValid) ?? true) && fallbackDuplicateIndex === -1; const saveEdit = () => { if (!config) return; @@ -254,7 +250,7 @@ export function ConfigurationTab() { const updated: SetupData = { ...config }; if (editing === 'pool' && editPool) { - if (!isPoolValid) return; + if (!isEditPoolValid) return; updated.pool = { ...editPool }; } else if (editing === 'mode') { if (editMode === 'jd' && !config.bitcoin) { @@ -544,7 +540,7 @@ export function ConfigurationTab() { onSave={saveEdit} onCancel={cancelEdit} isSaving={isSaving} - saveDisabled={!isPoolValid} + saveDisabled={!isEditPoolValid} disabled={editing !== null && editing !== 'pool'} display={ <> @@ -677,6 +673,8 @@ export function ConfigurationTab() { value={editFallbacks} onChange={setEditFallbacks} pools={pools.filter((p) => p.badge !== 'coming-soon')} + primary={config.pool} + duplicateIndex={fallbackDuplicateIndex} /> ) } @@ -1031,18 +1029,26 @@ function PoolOption({ ); } -function matchPoolByEndpoint(pools: KnownPool[], p: DraftPool): KnownPool | undefined { - return pools.find((kp) => kp.address === p.address && kp.port === p.port); +function knownToConfig(kp: KnownPool): PoolConfig { + return { name: kp.name, address: kp.address, port: kp.port, authority_public_key: kp.authority_public_key }; +} + +function matchKnownByPool(pools: KnownPool[], p: DraftPool): KnownPool | undefined { + return pools.find((kp) => isSamePool(knownToConfig(kp), p)); } function FallbackListEditor({ value, onChange, pools, + primary, + duplicateIndex, }: { value: DraftPool[]; onChange: (next: DraftPool[]) => void; pools: KnownPool[]; + primary: PoolConfig | null; + duplicateIndex: number; }) { const update = (i: number, patch: Partial) => { onChange(value.map((p, idx) => (idx === i ? { ...p, ...patch } : p))); @@ -1062,12 +1068,20 @@ function FallbackListEditor({ onChange(next); }; - const add = () => onChange([...value, { ...EMPTY_FALLBACK }]); + const add = () => onChange([...value, { ...EMPTY_CUSTOM_POOL }]); return (
{value.map((fp, i) => { - const matched = matchPoolByEndpoint(pools, fp); + // Filter known pools shown for this slot: hide any pool already + // claimed by primary or another fallback. Slot's own pick stays. + const slotPools = pools.filter((kp) => { + const kpAsConfig = knownToConfig(kp); + if (isSamePool(fp, kpAsConfig)) return true; + if (primary && isSamePool(primary, kpAsConfig)) return false; + return !value.some((other, j) => j !== i && isSamePool(other, kpAsConfig)); + }); + const matched = matchKnownByPool(slotPools, fp); const isCustom = !matched; return (
@@ -1106,25 +1120,18 @@ function FallbackListEditor({
- {pools.map((kp) => ( + {slotPools.map((kp) => ( - replaceAt(i, { - name: kp.name, - address: kp.address, - port: kp.port, - authority_public_key: kp.authority_public_key, - }) - } + onSelect={() => replaceAt(i, knownToConfig(kp))} /> ))}
)} + + {duplicateIndex === i && ( +

+ This pool is already used. Remove or change it — fallbacks must be distinct from the primary and each other. +

+ )} ); })} diff --git a/src/components/setup/PoolPicker.tsx b/src/components/setup/PoolPicker.tsx index aca92c0..4f72fc2 100644 --- a/src/components/setup/PoolPicker.tsx +++ b/src/components/setup/PoolPicker.tsx @@ -1,6 +1,6 @@ import { Check } from 'lucide-react'; import { PoolIcon } from '@/components/ui/pool-icon'; -import type { KnownPool } from '@/lib/pools'; +import { EMPTY_CUSTOM_POOL, isSamePool, type KnownPool } from '@/lib/pools'; import type { PoolConfig } from './types'; import { getPoolAuthorityPubkeyError, @@ -15,7 +15,7 @@ interface PoolPickerProps { } function matchKnownPool(pools: KnownPool[], value: PoolConfig): KnownPool | undefined { - return pools.find((p) => p.address === value.address && p.port === value.port); + return pools.find((p) => isSamePool({ name: p.name, address: p.address, port: p.port, authority_public_key: p.authority_public_key }, value)); } export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerProps) { @@ -34,7 +34,7 @@ export function PoolPicker({ pools, value, onChange, formIdPrefix }: PoolPickerP const selectCustom = () => { if (isCustom) return; - onChange({ name: 'Custom Pool', address: '', port: 34254, authority_public_key: '' }); + onChange({ ...EMPTY_CUSTOM_POOL }); }; const updateCustomField = (field: keyof PoolConfig, val: string | number) => { diff --git a/src/components/setup/steps/MiningModeSelection.tsx b/src/components/setup/steps/MiningModeSelection.tsx index 77fce63..238c0d0 100644 --- a/src/components/setup/steps/MiningModeSelection.tsx +++ b/src/components/setup/steps/MiningModeSelection.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { MiningMode, StepProps } from '../types'; import { Miner3D, type MinerPhase } from './Miner3D'; -export function MiningModeSelection({ updateData, onNext }: StepProps) { +export function MiningModeSelection({ data, updateData, onNext }: StepProps) { const [phase, setPhase] = useState('idle'); const [selectedMode, setSelectedMode] = useState(null); const nextRef = useRef(onNext); @@ -18,7 +18,11 @@ export function MiningModeSelection({ updateData, onNext }: StepProps) { const handleSelect = (miningMode: MiningMode) => { if (phase !== 'idle') return; setSelectedMode(miningMode); - updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null }); + // Only reset downstream state if the user actually changed mode. Picking + // the same mode (Reconfigure flow) preserves existing pool/fallback work. + if (miningMode !== data.miningMode) { + updateData({ miningMode, mode: null, pool: null, fallbackPools: [], bitcoin: null, jdc: null, translator: null }); + } setPhase('arming'); }; diff --git a/src/components/setup/steps/PoolConfigStep.tsx b/src/components/setup/steps/PoolConfigStep.tsx index e72845a..0de1119 100644 --- a/src/components/setup/steps/PoolConfigStep.tsx +++ b/src/components/setup/steps/PoolConfigStep.tsx @@ -1,30 +1,34 @@ import { useEffect } from 'react'; import { StepProps, PoolConfig } from '../types'; import { Plus, ArrowUp, ArrowDown, X } from 'lucide-react'; -import { POOL_MINING_NO_JD, POOL_MINING_JD, SOLO_POOLS } from '@/lib/pools'; -import { isValidPoolAuthorityPubkey } from '@/lib/utils'; +import { + POOL_MINING_NO_JD, + POOL_MINING_JD, + SOLO_POOLS, + EMPTY_CUSTOM_POOL, + isPoolValid, + isSamePool, + type KnownPool, +} from '@/lib/pools'; import { PoolPicker } from '../PoolPicker'; -const EMPTY_CUSTOM: PoolConfig = { - name: 'Custom Pool', - address: '', - port: 34254, - authority_public_key: '', -}; - -function isPoolValid(pool: PoolConfig): boolean { - return pool.address.length > 0 && isValidPoolAuthorityPubkey(pool.authority_public_key); +function knownPoolToConfig(p: KnownPool): PoolConfig { + return { + name: p.name, + address: p.address, + port: p.port, + authority_public_key: p.authority_public_key, + }; } export function PoolConfigStep({ data, updateData, onNext }: StepProps) { const isSoloMode = data.miningMode === 'solo'; const isJdMode = data.mode === 'jd'; + const isSovereignSolo = isSoloMode && isJdMode; const pools = isSoloMode ? SOLO_POOLS : (isJdMode ? POOL_MINING_JD : POOL_MINING_NO_JD); const first = pools.find((p) => p.badge !== 'coming-soon'); - const defaultPrimary: PoolConfig = first - ? { name: first.name, address: first.address, port: first.port, authority_public_key: first.authority_public_key } - : EMPTY_CUSTOM; + const defaultPrimary: PoolConfig = first ? knownPoolToConfig(first) : EMPTY_CUSTOM_POOL; const primary: PoolConfig = data.pool ?? defaultPrimary; useEffect(() => { @@ -38,12 +42,11 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) { const setPrimary = (pool: PoolConfig) => updateData({ pool }); const setFallback = (index: number, pool: PoolConfig) => { - const next = fallbacks.map((f, i) => (i === index ? pool : f)); - updateData({ fallbackPools: next }); + updateData({ fallbackPools: fallbacks.map((f, i) => (i === index ? pool : f)) }); }; const addFallback = () => { - updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM }] }); + updateData({ fallbackPools: [...fallbacks, { ...EMPTY_CUSTOM_POOL }] }); }; const removeFallback = (index: number) => { @@ -58,7 +61,32 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) { updateData({ fallbackPools: next }); }; - const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid); + // Filter known pools shown in fallback slot N: hide any pool already claimed + // by primary or another fallback. The slot's own current pick stays visible + // so its branding renders. Custom Pool always remains available. + const knownPoolsForFallback = (slotIndex: number): KnownPool[] => + pools.filter((kp) => { + const kpAsConfig = knownPoolToConfig(kp); + if (isSamePool(fallbacks[slotIndex], kpAsConfig)) return true; + if (isSamePool(primary, kpAsConfig)) return false; + return !fallbacks.some((other, j) => j !== slotIndex && isSamePool(other, kpAsConfig)); + }); + + // Reject configurations where the same SV2 endpoint appears more than once. + // sv2-apps treats each [[upstreams]] entry as a fresh attempt, so duplicates + // burn retries against the same dead pool with no failover benefit. + const duplicateIndex = (() => { + const all = [primary, ...fallbacks]; + for (let i = 1; i < all.length; i++) { + for (let j = 0; j < i; j++) { + if (isSamePool(all[i], all[j])) return i - 1; // index in fallbacks + } + } + return -1; + })(); + const hasDuplicate = duplicateIndex !== -1; + + const isValid = isPoolValid(primary) && fallbacks.every(isPoolValid) && !hasDuplicate; return (
@@ -82,7 +110,7 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) { formIdPrefix="primary-pool" /> - {!isSoloMode && ( + {!isSovereignSolo && (

Fallback Pools

@@ -125,11 +153,16 @@ export function PoolConfigStep({ data, updateData, onNext }: StepProps) {
setFallback(index, pool)} formIdPrefix={`fallback-${index}`} /> + {duplicateIndex === index && ( +

+ This pool is already used. Remove or change it — fallbacks must be distinct from the primary and each other. +

+ )}
))} diff --git a/src/components/setup/steps/TemplateModeSelection.tsx b/src/components/setup/steps/TemplateModeSelection.tsx index 3cfa049..540d2fb 100644 --- a/src/components/setup/steps/TemplateModeSelection.tsx +++ b/src/components/setup/steps/TemplateModeSelection.tsx @@ -13,6 +13,11 @@ export function TemplateModeSelection({ data, updateData, onNext }: StepProps) { const secondaryFooter = isSoloMode ? 'Simpler setup with a solo pool' : 'Simpler setup'; const handleSelect = (mode: 'jd' | 'no-jd') => { + if (mode === data.mode) { + // Same mode — preserve everything (Reconfigure walk-through). + onNext(); + return; + } updateData({ mode, pool: mode === 'jd' ? null : data.pool, diff --git a/src/lib/pools.ts b/src/lib/pools.ts index 5117dc9..d5a650c 100644 --- a/src/lib/pools.ts +++ b/src/lib/pools.ts @@ -2,6 +2,31 @@ * Shared pool preset definitions used by both the Setup Wizard and Settings. */ +import type { PoolConfig } from '@/components/setup/types'; +import { isValidPoolAuthorityPubkey } from '@/lib/utils'; + +export const EMPTY_CUSTOM_POOL: PoolConfig = { + name: 'Custom Pool', + address: '', + port: 34254, + authority_public_key: '', +}; + +// Two pools are the same iff their full SV2 endpoint triplet matches. +// Address+port alone isn't enough — a typo'd or malicious pubkey on the +// same host:port is a different security context, not a duplicate. +export function isSamePool(a: PoolConfig, b: PoolConfig): boolean { + return ( + a.address.toLowerCase() === b.address.toLowerCase() && + a.port === b.port && + a.authority_public_key === b.authority_public_key + ); +} + +export function isPoolValid(p: PoolConfig): boolean { + return p.address.length > 0 && isValidPoolAuthorityPubkey(p.authority_public_key); +} + export interface KnownPool { id: string; name: string; From 5c666c55c25c5c2f5452017203d5ea4b8a04b80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=BE=D0=BB=D0=BB=D0=B8?= Date: Mon, 4 May 2026 22:23:10 +0300 Subject: [PATCH 3/3] show correct fallback connection in ui --- server/src/index.ts | 18 ++++++-- server/src/logs/current-upstream.test.ts | 54 ++++++++++++++++++++++++ server/src/logs/current-upstream.ts | 54 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 server/src/logs/current-upstream.test.ts create mode 100644 server/src/logs/current-upstream.ts diff --git a/server/src/index.ts b/server/src/index.ts index 6395432..e8a4cf4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,7 @@ import { readContainerLogs } from './docker.js'; import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; +import { getCurrentUpstreamPoolName } from './logs/current-upstream.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -104,14 +105,25 @@ app.get('/api/status', async (_req, res) => { (containers.jdc?.status === 'healthy' || containers.jdc?.status === 'starting') : (containers.translator?.status === 'healthy' || containers.translator?.status === 'starting'); + const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; + let poolName: string | null; + if (isSovereignSolo) { + poolName = 'Sovereign Solo Mining'; + } else if (running) { + const container = state.mode === 'jd' ? 'jdc' : 'translator'; + poolName = (await getCurrentUpstreamPoolName(container, state.data)) + ?? state.data?.pool?.name + ?? null; + } else { + poolName = state.data?.pool?.name ?? null; + } + const response: StatusResponse = { configured: state.configured, running, miningMode: state.miningMode, mode: state.mode, - poolName: state.data?.miningMode === 'solo' && state.data?.mode === 'jd' - ? 'Sovereign Solo Mining' - : (state.data?.pool?.name ?? null), + poolName, containers, }; diff --git a/server/src/logs/current-upstream.test.ts b/server/src/logs/current-upstream.test.ts new file mode 100644 index 0000000..0dd5c5b --- /dev/null +++ b/server/src/logs/current-upstream.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { findCurrentUpstreamFromLines } from './current-upstream.js'; + +test('returns the only Trying upstream when there is one', () => { + const lines = [ + 'INFO translator_sv2: Trying upstream 1 of 1: stratum.braiins.com:3333', + 'INFO translator_sv2::sv2::upstream: Connected to upstream at 172.65.65.63:3333', + ]; + assert.deepEqual(findCurrentUpstreamFromLines(lines), { host: 'stratum.braiins.com', port: 3333 }); +}); + +test('after failover, returns the second upstream not the first that moved on', () => { + const lines = [ + 'INFO translator_sv2: Trying upstream 1 of 2: nonexistent-pool.invalid:3333', + 'WARN translator_sv2: Max retries reached for nonexistent-pool.invalid:3333, moving to next upstream', + 'INFO translator_sv2: Trying upstream 2 of 2: blitzpool.yourdevice.ch:3333', + 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.2.3.4:3333', + ]; + assert.deepEqual(findCurrentUpstreamFromLines(lines), { + host: 'blitzpool.yourdevice.ch', + port: 3333, + }); +}); + +test('returns null when all upstreams failed', () => { + const lines = [ + 'INFO translator_sv2: Trying upstream 1 of 2: a.example.com:3333', + 'WARN translator_sv2: Max retries reached for a.example.com:3333, moving to next upstream', + 'INFO translator_sv2: Trying upstream 2 of 2: b.example.com:3333', + 'WARN translator_sv2: Max retries reached for b.example.com:3333, moving to next upstream', + 'ERROR translator_sv2: All upstreams failed after 3 retries each', + ]; + assert.equal(findCurrentUpstreamFromLines(lines), null); +}); + +test('multi-cycle reconnect returns the most recent upstream', () => { + const lines = [ + 'INFO translator_sv2: Trying upstream 1 of 2: pool-a.example.com:3333', + 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.1.1.1:3333', + 'WARN translator_sv2: Max retries reached for pool-a.example.com:3333, moving to next upstream', + 'INFO translator_sv2: Trying upstream 2 of 2: pool-b.example.com:3333', + 'INFO translator_sv2::sv2::upstream: Connected to upstream at 2.2.2.2:3333', + 'WARN translator_sv2: Max retries reached for pool-b.example.com:3333, moving to next upstream', + 'INFO translator_sv2: Trying upstream 1 of 2: pool-a.example.com:3333', + 'INFO translator_sv2::sv2::upstream: Connected to upstream at 1.1.1.1:3333', + ]; + assert.deepEqual(findCurrentUpstreamFromLines(lines), { host: 'pool-a.example.com', port: 3333 }); +}); + +test('returns null on empty log', () => { + assert.equal(findCurrentUpstreamFromLines([]), null); +}); diff --git a/server/src/logs/current-upstream.ts b/server/src/logs/current-upstream.ts new file mode 100644 index 0000000..902397b --- /dev/null +++ b/server/src/logs/current-upstream.ts @@ -0,0 +1,54 @@ +import { readContainerLogs } from '../docker.js'; +import type { LogContainerRole } from './types.js'; +import type { PoolConfig, SetupData } from '../types.js'; + +const TRYING_RE = /Trying upstream \d+ of \d+:\s+([^\s:]+):(\d+)/; +const MAX_RETRIES_RE = /Max retries reached for ([^\s:]+):(\d+),\s*moving to next upstream/; + +export function findCurrentUpstreamFromLines(lines: string[]): { host: string; port: number } | null { + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (/All upstreams failed/.test(line)) return null; + const trying = line.match(TRYING_RE); + if (!trying) continue; + const host = trying[1]; + const port = Number(trying[2]); + let movedOn = false; + for (let j = i + 1; j < lines.length; j++) { + const m = lines[j].match(MAX_RETRIES_RE); + if (m && m[1] === host && Number(m[2]) === port) { + movedOn = true; + break; + } + } + if (movedOn) continue; + return { host, port }; + } + return null; +} + +function matchPool(host: string, port: number, data: SetupData): PoolConfig | null { + const candidates: PoolConfig[] = []; + if (data.pool) candidates.push(data.pool); + candidates.push(...data.fallbackPools); + return candidates.find( + (p) => p.address.toLowerCase() === host.toLowerCase() && p.port === port, + ) ?? null; +} + +export async function getCurrentUpstreamPoolName( + container: LogContainerRole, + data: SetupData | null, +): Promise { + if (!data) return null; + let lines: string[]; + try { + const logLines = await readContainerLogs(container, { tail: 100 }); + lines = logLines.map((l) => l.message); + } catch { + return null; + } + const found = findCurrentUpstreamFromLines(lines); + if (!found) return null; + return matchPool(found.host, found.port, data)?.name ?? `${found.host}:${found.port}`; +}