From 1eb9040e7cd58cf1314c9de703801fd710c4b2f7 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:27:50 +0500 Subject: [PATCH 01/13] feat: prototype Telegram mining notifications Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/index.ts | 259 ++++++++-- server/src/telegram.test.ts | 210 ++++++++ server/src/telegram.ts | 522 ++++++++++++++++++++ src/components/settings/ExperimentalTab.tsx | 314 ++++++++++++ src/hooks/useTelegram.ts | 143 ++++++ src/pages/Settings.tsx | 12 +- 6 files changed, 1425 insertions(+), 35 deletions(-) create mode 100644 server/src/telegram.test.ts create mode 100644 server/src/telegram.ts create mode 100644 src/components/settings/ExperimentalTab.tsx create mode 100644 src/hooks/useTelegram.ts diff --git a/server/src/index.ts b/server/src/index.ts index 49e76dcf..4c6be405 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -34,6 +34,15 @@ import { import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; +import { + TelegramApiError, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { + TelegramActivitySnapshot, + TelegramSettingsUpdate, +} from './telegram.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -42,13 +51,17 @@ const PORT = process.env.PORT || 3001; // Config storage const CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname, '../../data/config'); const STATE_FILE = path.join(CONFIG_DIR, 'state.json'); +const TELEGRAM_SETTINGS_FILE = path.join(CONFIG_DIR, 'telegram.json'); const AUTO_START_RETRY_INTERVAL_MS = 30_000; +const TELEGRAM_POLL_INTERVAL_MS = 30_000; type StackBusyReason = 'auto-start' | 'manual'; let stackBusyReason: StackBusyReason | null = null; const activePoolTracker = new ActivePoolTracker(readContainerLogs); +const telegramService = new TelegramService(TELEGRAM_SETTINGS_FILE); +let telegramMonitorTimer: ReturnType | null = null; type SavedState = { configured: boolean; @@ -215,6 +228,39 @@ function stackBusyResponse() { }; } +async function getCurrentStatus(): Promise { + const state = await loadState(); + const containers = await getStackStatus(state.mode); + const running = isStackRunning(state.mode, containers); + const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; + const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; + + if (!running) { + activePoolTracker.reset(); + } + + const activePool = running && state.mode && pools.length > 0 + ? await activePoolTracker.getActivePool( + state.mode === 'jd' ? 'jdc' : 'translator', + pools + ) + : null; + + return { + configured: state.configured, + running, + autoStarting: stackBusyReason === 'auto-start', + shouldBeRunning: state.shouldBeRunning, + miningMode: state.miningMode, + mode: state.mode, + poolName: isSovereignSolo + ? 'Sovereign Solo Mining' + : (activePool?.name ?? null), + activePoolIndex: activePool?.index ?? null, + containers, + }; +} + /** * GET /api/health - Health check */ @@ -231,38 +277,7 @@ app.get('/api/health', async (_req, res) => { */ app.get('/api/status', async (_req, res) => { try { - const state = await loadState(); - const containers = await getStackStatus(state.mode); - const running = isStackRunning(state.mode, containers); - const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; - const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; - - if (!running) { - activePoolTracker.reset(); - } - - const activePool = running && state.mode && pools.length > 0 - ? await activePoolTracker.getActivePool( - state.mode === 'jd' ? 'jdc' : 'translator', - pools - ) - : null; - - const response: StatusResponse = { - configured: state.configured, - running, - autoStarting: stackBusyReason === 'auto-start', - shouldBeRunning: state.shouldBeRunning, - miningMode: state.miningMode, - mode: state.mode, - poolName: isSovereignSolo - ? 'Sovereign Solo Mining' - : (activePool?.name ?? null), - activePoolIndex: activePool?.index ?? null, - containers, - }; - - res.json(response); + res.json(await getCurrentStatus()); } catch (error) { console.error('Status error:', error); res.status(500).json({ error: 'Failed to get status' }); @@ -293,6 +308,87 @@ app.get('/api/env', (_req, res) => { res.json({ HOST_OS: process.env.HOST_OS || null, STRATUM_HOST: process.env.STRATUM_HOST || null }); }); +function sendTelegramError(res: express.Response, error: unknown): void { + if (error instanceof TelegramConfigError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + + if (error instanceof TelegramApiError) { + res.status(error.statusCode).json({ success: false, error: error.message }); + return; + } + + console.error( + 'Telegram settings error:', + error instanceof Error ? error.message : 'Unknown error' + ); + res.status(500).json({ success: false, error: 'Telegram settings could not be updated' }); +} + +/** + * Telegram notification proof of concept. + * + * The bot token and chat ID are stored only in CONFIG_DIR/telegram.json and + * are never included in an API response. + */ +app.get('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.getSettings()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/connect', async (req, res) => { + try { + if (!isJsonObject(req.body) || typeof req.body.botToken !== 'string') { + throw new TelegramConfigError('A Telegram bot token is required'); + } + + res.json(await telegramService.connectBot(req.body.botToken)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/pair', async (_req, res) => { + try { + res.json(await telegramService.pairChat()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.patch('/api/telegram', async (req, res) => { + try { + if (!isJsonObject(req.body)) { + throw new TelegramConfigError('Telegram settings must be a JSON object'); + } + + res.json(await telegramService.updateSettings(req.body as TelegramSettingsUpdate)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/test', async (_req, res) => { + try { + await telegramService.sendTestMessage(); + res.json({ success: true }); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.delete('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.disconnect()); + } catch (error) { + sendTelegramError(res, error); + } +}); + /** * POST /api/validate/bitcoin-socket - Check if a Bitcoin Core IPC socket is listening */ @@ -696,6 +792,82 @@ function getContainerUrl(containerName: string, port: number): string { : `http://localhost:${port}`; } +type MonitoringGlobal = { + server?: { total_hashrate: number } | null; + sv1_clients?: { total_clients: number; total_hashrate: number } | null; + sv2_clients?: { total_clients: number; total_hashrate: number } | null; +}; + +type MonitoringServerChannel = { + shares_submitted: number; + shares_acknowledged: number; + shares_rejected: number; +}; + +type MonitoringServerChannels = { + extended_channels: MonitoringServerChannel[]; + standard_channels: MonitoringServerChannel[]; +}; + +async function fetchMonitoringJson(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + return response.ok ? await response.json() as T : null; + } catch { + return null; + } +} + +async function getTelegramActivitySnapshot(): Promise { + const status = await getCurrentStatus(); + + if (!status.running || !status.mode) { + return { + running: false, + poolName: status.poolName, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + }; + } + + const isJdMode = status.mode === 'jd'; + const containerName = isJdMode ? 'sv2-jdc' : 'sv2-translator'; + const port = isJdMode ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; + const baseUrl = `${getContainerUrl(containerName, port)}/api/v1`; + const [global, channels] = await Promise.all([ + fetchMonitoringJson(`${baseUrl}/global`), + fetchMonitoringJson( + `${baseUrl}/server/channels?offset=0&limit=100` + ), + ]); + + const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; + const serverChannels = channels + ? [...channels.extended_channels, ...channels.standard_channels] + : null; + + return { + running: true, + poolName: status.poolName, + hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, + workers: clients?.total_clients ?? null, + sharesSubmitted: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_submitted, 0) + : null, + sharesAccepted: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_acknowledged, 0) + : null, + sharesRejected: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) + : null, + }; +} + /** * Proxy requests to Translator monitoring API * This avoids CORS issues when the frontend is served from a different port @@ -777,6 +949,17 @@ async function reconcileShouldBeRunning(): Promise { } } +async function pollTelegramNotifications(): Promise { + try { + await telegramService.poll(getTelegramActivitySnapshot); + } catch (error) { + console.warn( + 'Telegram notification check failed:', + error instanceof Error ? error.message : 'Unknown error' + ); + } +} + app.listen(PORT, () => { const dockerConnection = getDockerConnectionInfo(); @@ -801,6 +984,13 @@ app.listen(PORT, () => { setInterval(() => { void reconcileShouldBeRunning(); }, AUTO_START_RETRY_INTERVAL_MS); + + // Telegram notifications run in the local backend, so they keep working + // while the browser UI is closed. + void pollTelegramNotifications(); + telegramMonitorTimer = setInterval(() => { + void pollTelegramNotifications(); + }, TELEGRAM_POLL_INTERVAL_MS); }); // Graceful shutdown: stop mining containers when sv2-ui exits @@ -810,6 +1000,11 @@ async function shutdown(signal: string) { if (isShuttingDown) return; isShuttingDown = true; + if (telegramMonitorTimer) { + clearInterval(telegramMonitorTimer); + telegramMonitorTimer = null; + } + console.log(`\n${signal} received. Stopping mining containers...`); try { await stopStack(); diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts new file mode 100644 index 00000000..8ae0d25d --- /dev/null +++ b/server/src/telegram.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import { + formatTelegramStatus, + getStatusChangeMessage, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { TelegramActivitySnapshot } from './telegram.js'; + +const BOT_TOKEN = '123456:test-token'; + +type FetchCall = { + url: string; + body: Record; +}; + +function createTelegramFetch(results: unknown[]) { + const calls: FetchCall[] = []; + const fetchImplementation = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) as Record : {}, + }); + + const result = results.shift(); + if (result instanceof Error) throw result; + + return new Response(JSON.stringify({ + ok: true, + result, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + return { calls, fetchImplementation }; +} + +async function createSettingsFile(t: TestContext): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sv2-telegram-test-')); + t.after(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + return path.join(directory, 'telegram.json'); +} + +function snapshot( + update: Partial = {} +): TelegramActivitySnapshot { + return { + running: true, + poolName: 'Primary pool', + hashrate: 125_000_000_000_000, + workers: 3, + sharesSubmitted: 42, + sharesAccepted: 40, + sharesRejected: 2, + ...update, + }; +} + +test('formats a compact mining summary', () => { + assert.equal( + formatTelegramStatus(snapshot()), + [ + '⛏ SV2 mining status', + 'Status: Running', + 'Pool: Primary pool', + 'Hashrate: 125 TH/s', + 'Workers: 3', + 'Shares: 42 submitted · 40 accepted · 2 rejected', + ].join('\n') + ); +}); + +test('reports only meaningful status changes', () => { + const stopped = snapshot({ + running: false, + poolName: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + }); + + assert.match(getStatusChangeMessage(stopped, snapshot()) ?? '', /mining started/); + assert.match( + getStatusChangeMessage(snapshot(), snapshot({ poolName: 'Fallback pool' })) ?? '', + /active pool changed/ + ); + assert.equal(getStatusChangeMessage(snapshot(), snapshot()), null); +}); + +test('verifies a bot and pairs the private chat from a one-time deep link', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + ]); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + assert.ok(pairingCode); + assert.equal(JSON.stringify(connected).includes(BOT_TOKEN), false); + + const pairingTelegram = createTelegramFetch([ + [{ + update_id: 77, + message: { + text: `/start ${pairingCode}`, + chat: { + id: 987, + type: 'private', + first_name: 'Miner', + username: 'miner_one', + }, + }, + }], + { message_id: 5 }, + ]); + const reloadedService = new TelegramService(settingsFile, pairingTelegram.fetchImplementation); + const paired = await reloadedService.pairChat(); + + assert.equal(paired.paired, true); + assert.equal(paired.enabled, true); + assert.equal(paired.recipient, '@miner_one'); + assert.equal(paired.pairingUrl, null); + assert.equal(pairingTelegram.calls.at(-1)?.body.chat_id, 987); + + const savedMode = (await fs.stat(settingsFile)).mode & 0o777; + assert.equal(savedMode, 0o600); +}); + +test('does not pair a chat until the matching Start command arrives', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + [], + ]); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + await service.connectBot(BOT_TOKEN); + + await assert.rejects( + service.pairChat(), + (error: unknown) => + error instanceof TelegramConfigError && + error.message.includes('press Start') + ); +}); + +test('the background poll establishes a baseline before sending a transition', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + ]); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + + const pairedTelegram = createTelegramFetch([ + [{ + update_id: 77, + message: { + text: `/start ${pairingCode}`, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }], + { message_id: 5 }, + { message_id: 6 }, + ]); + const pairedService = new TelegramService(settingsFile, pairedTelegram.fetchImplementation); + await pairedService.pairChat(); + + const stopped = snapshot({ + running: false, + poolName: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + }); + await pairedService.poll(async () => stopped); + assert.equal(pairedTelegram.calls.length, 2); + + await pairedService.poll(async () => snapshot()); + assert.equal(pairedTelegram.calls.length, 3); + assert.match(String(pairedTelegram.calls[2].body.text), /mining started/); +}); diff --git a/server/src/telegram.ts b/server/src/telegram.ts new file mode 100644 index 00000000..337fa3e8 --- /dev/null +++ b/server/src/telegram.ts @@ -0,0 +1,522 @@ +import { randomBytes } from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +const DEFAULT_SUMMARY_INTERVAL_MINUTES = 60; +const MIN_SUMMARY_INTERVAL_MINUTES = 15; +const MAX_SUMMARY_INTERVAL_MINUTES = 24 * 60; + +type FetchImplementation = typeof fetch; + +type TelegramBot = { + id: number; + is_bot: boolean; + first_name: string; + username?: string; +}; + +type TelegramChat = { + id: number; + type: string; + first_name?: string; + last_name?: string; + username?: string; + title?: string; +}; + +type TelegramUpdate = { + update_id: number; + message?: { + text?: string; + chat: TelegramChat; + }; +}; + +type TelegramApiResponse = { + ok: boolean; + result?: T; + description?: string; + error_code?: number; +}; + +type SavedTelegramSettings = { + version: 1; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettings = { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnStatusChange?: boolean; + summaryIntervalMinutes?: number; +}; + +export type TelegramActivitySnapshot = { + running: boolean; + poolName: string | null; + hashrate: number | null; + workers: number | null; + sharesSubmitted: number | null; + sharesAccepted: number | null; + sharesRejected: number | null; +}; + +export class TelegramConfigError extends Error {} + +export class TelegramApiError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + } +} + +function getEmptySettings(): TelegramSettings { + return { + connected: false, + paired: false, + botUsername: null, + botName: null, + recipient: null, + pairingUrl: null, + enabled: false, + notifyOnStatusChange: true, + summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, + }; +} + +function toPublicSettings(settings: SavedTelegramSettings | null): TelegramSettings { + if (!settings) return getEmptySettings(); + + return { + connected: true, + paired: settings.chatId !== null, + botUsername: settings.botUsername, + botName: settings.botName, + recipient: settings.recipient, + pairingUrl: settings.pairingCode + ? `https://t.me/${settings.botUsername}?start=${settings.pairingCode}` + : null, + enabled: settings.enabled, + notifyOnStatusChange: settings.notifyOnStatusChange, + summaryIntervalMinutes: settings.summaryIntervalMinutes, + }; +} + +function isSavedSettings(value: unknown): value is SavedTelegramSettings { + if (!value || typeof value !== 'object') return false; + const settings = value as Partial; + + return settings.version === 1 && + typeof settings.botToken === 'string' && + typeof settings.botUsername === 'string' && + typeof settings.botName === 'string' && + (settings.pairingCode === null || typeof settings.pairingCode === 'string') && + (settings.chatId === null || typeof settings.chatId === 'number') && + (settings.recipient === null || typeof settings.recipient === 'string') && + typeof settings.enabled === 'boolean' && + typeof settings.notifyOnStatusChange === 'boolean' && + Number.isInteger(settings.summaryIntervalMinutes); +} + +function getRecipientLabel(chat: TelegramChat): string { + if (chat.username) return `@${chat.username}`; + if (chat.title) return chat.title; + + const name = [chat.first_name, chat.last_name].filter(Boolean).join(' ').trim(); + return name || 'Telegram chat'; +} + +function getStartParameter(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)$/); + return match?.[1] ?? null; +} + +function formatHashrate(hashrate: number | null): string | null { + if (hashrate === null || !Number.isFinite(hashrate)) return null; + + const units = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s']; + let value = Math.max(0, hashrate); + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(decimals)} ${units[unitIndex]}`; +} + +export function formatTelegramStatus( + snapshot: TelegramActivitySnapshot, + heading = '⛏ SV2 mining status' +): string { + const lines = [ + heading, + `Status: ${snapshot.running ? 'Running' : 'Stopped'}`, + ]; + + if (snapshot.poolName) lines.push(`Pool: ${snapshot.poolName}`); + + const hashrate = formatHashrate(snapshot.hashrate); + if (hashrate) lines.push(`Hashrate: ${hashrate}`); + if (snapshot.workers !== null) lines.push(`Workers: ${snapshot.workers.toLocaleString()}`); + + if (snapshot.sharesSubmitted !== null) { + const shareParts = [`${snapshot.sharesSubmitted.toLocaleString()} submitted`]; + if (snapshot.sharesAccepted !== null) { + shareParts.push(`${snapshot.sharesAccepted.toLocaleString()} accepted`); + } + if (snapshot.sharesRejected !== null) { + shareParts.push(`${snapshot.sharesRejected.toLocaleString()} rejected`); + } + lines.push(`Shares: ${shareParts.join(' · ')}`); + } + + return lines.join('\n'); +} + +export function getStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '🟢 SV2 mining started'); + } + + if (previous.running && !current.running) { + return formatTelegramStatus(current, '🔴 SV2 mining stopped'); + } + + if ( + current.running && + previous.poolName !== current.poolName && + current.poolName !== null + ) { + return formatTelegramStatus(current, '🔁 SV2 active pool changed'); + } + + return null; +} + +export class TelegramService { + private settings: SavedTelegramSettings | null = null; + private initialized = false; + private previousSnapshot: TelegramActivitySnapshot | null = null; + private lastSummaryAt: number | null = null; + private pollInProgress = false; + + constructor( + private readonly settingsFile: string, + private readonly fetchImplementation: FetchImplementation = fetch + ) {} + + async initialize(): Promise { + if (this.initialized) return; + + try { + const raw = await fs.readFile(this.settingsFile, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (!isSavedSettings(parsed)) { + throw new TelegramConfigError('Stored Telegram settings are invalid'); + } + this.settings = parsed; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + console.warn( + 'Telegram settings could not be loaded:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + this.settings = null; + } + + this.initialized = true; + } + + async getSettings(): Promise { + await this.initialize(); + return toPublicSettings(this.settings); + } + + async connectBot(botToken: string): Promise { + await this.initialize(); + const token = botToken.trim(); + + if (!token || token.length > 256 || /\s/.test(token)) { + throw new TelegramConfigError('Enter a valid Telegram bot token'); + } + + const bot = await this.callApi(token, 'getMe'); + if (!bot.is_bot || !bot.username) { + throw new TelegramConfigError('Telegram did not return a usable bot username'); + } + + this.settings = { + version: 1, + botToken: token, + botUsername: bot.username, + botName: bot.first_name, + pairingCode: `sv2_${randomBytes(18).toString('base64url')}`, + chatId: null, + recipient: null, + enabled: false, + notifyOnStatusChange: true, + summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async pairChat(): Promise { + await this.initialize(); + const settings = this.requireConnected(); + + if (settings.chatId !== null) { + return toPublicSettings(settings); + } + if (!settings.pairingCode) { + throw new TelegramConfigError('Create a new Telegram pairing link first'); + } + + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: -100, + limit: 100, + timeout: 0, + allowed_updates: ['message'], + } + ); + + const matchingUpdate = [...updates].reverse().find((update) => + update.message?.chat.type === 'private' && + getStartParameter(update.message.text) === settings.pairingCode + ); + const chat = matchingUpdate?.message?.chat; + + if (!chat) { + throw new TelegramConfigError( + `Open @${settings.botUsername} from the pairing link and press Start, then try again` + ); + } + + await this.sendMessage( + settings.botToken, + chat.id, + '✅ SV2 UI is linked. Telegram activity updates are now enabled.' + ); + + this.settings = { + ...settings, + pairingCode: null, + chatId: chat.id, + recipient: getRecipientLabel(chat), + enabled: true, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async updateSettings(update: TelegramSettingsUpdate): Promise { + await this.initialize(); + const settings = this.requirePaired(); + + const enabled = update.enabled ?? settings.enabled; + const notifyOnStatusChange = + update.notifyOnStatusChange ?? settings.notifyOnStatusChange; + const summaryIntervalMinutes = + update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes; + + if (typeof enabled !== 'boolean' || typeof notifyOnStatusChange !== 'boolean') { + throw new TelegramConfigError('Notification settings must be true or false'); + } + if ( + !Number.isInteger(summaryIntervalMinutes) || + (summaryIntervalMinutes !== 0 && + ( + summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES + )) + ) { + throw new TelegramConfigError( + `Summary interval must be 0 (off) or ${MIN_SUMMARY_INTERVAL_MINUTES}-${MAX_SUMMARY_INTERVAL_MINUTES} minutes` + ); + } + + this.settings = { + ...settings, + enabled, + notifyOnStatusChange, + summaryIntervalMinutes, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async sendTestMessage(): Promise { + await this.initialize(); + const settings = this.requirePaired(); + await this.sendMessage( + settings.botToken, + settings.chatId, + '✅ SV2 UI Telegram notifications are working.' + ); + } + + async disconnect(): Promise { + await this.initialize(); + this.settings = null; + this.resetMonitorState(); + + try { + await fs.unlink(this.settingsFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + return getEmptySettings(); + } + + async poll(snapshotProvider: () => Promise): Promise { + await this.initialize(); + if (this.pollInProgress) return; + + const settings = this.settings; + if (!settings?.enabled || settings.chatId === null) { + this.resetMonitorState(); + return; + } + + this.pollInProgress = true; + try { + const current = await snapshotProvider(); + const now = Date.now(); + + if (!this.previousSnapshot) { + this.previousSnapshot = current; + this.lastSummaryAt = now; + return; + } + + const statusMessage = settings.notifyOnStatusChange + ? getStatusChangeMessage(this.previousSnapshot, current) + : null; + const summaryDue = settings.summaryIntervalMinutes > 0 && + this.lastSummaryAt !== null && + now - this.lastSummaryAt >= settings.summaryIntervalMinutes * 60_000; + + if (statusMessage) { + await this.sendMessage(settings.botToken, settings.chatId, statusMessage); + } else if (summaryDue) { + await this.sendMessage( + settings.botToken, + settings.chatId, + formatTelegramStatus(current) + ); + this.lastSummaryAt = now; + } + + this.previousSnapshot = current; + } finally { + this.pollInProgress = false; + } + } + + private requireConnected(): SavedTelegramSettings { + if (!this.settings) { + throw new TelegramConfigError('Connect a Telegram bot first'); + } + return this.settings; + } + + private requirePaired(): SavedTelegramSettings & { chatId: number } { + const settings = this.requireConnected(); + if (settings.chatId === null) { + throw new TelegramConfigError('Pair a Telegram chat first'); + } + return settings as SavedTelegramSettings & { chatId: number }; + } + + private async save(): Promise { + if (!this.settings) return; + + await fs.mkdir(path.dirname(this.settingsFile), { recursive: true }); + await fs.writeFile( + this.settingsFile, + JSON.stringify(this.settings, null, 2), + { mode: 0o600 } + ); + await fs.chmod(this.settingsFile, 0o600); + } + + private resetMonitorState(): void { + this.previousSnapshot = null; + this.lastSummaryAt = null; + } + + private async sendMessage(botToken: string, chatId: number, text: string): Promise { + await this.callApi(botToken, 'sendMessage', { chat_id: chatId, text }); + } + + private async callApi( + botToken: string, + method: string, + body: Record = {} + ): Promise { + let response: Response; + + try { + response = await this.fetchImplementation( + `https://api.telegram.org/bot${botToken}/${method}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + } + ); + } catch { + throw new TelegramApiError( + 'Could not reach Telegram. Check this machine’s internet connection.', + 502 + ); + } + + const payload = await response.json().catch(() => null) as TelegramApiResponse | null; + if (!response.ok || !payload?.ok || payload.result === undefined) { + const description = payload?.description?.replace(/^Bad Request:\s*/i, ''); + const message = description || 'Telegram rejected the request'; + throw new TelegramApiError(message, response.status >= 400 ? response.status : 502); + } + + return payload.result; + } +} diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx new file mode 100644 index 00000000..18f9cde8 --- /dev/null +++ b/src/components/settings/ExperimentalTab.tsx @@ -0,0 +1,314 @@ +import { useEffect, useState } from 'react'; +import { + Bot, + ExternalLink, + FlaskConical, + Loader2, + Send, + ShieldCheck, + Unplug, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useTelegram } from '@/hooks/useTelegram'; + +function runMutation(promise: Promise): void { + void promise.catch(() => { + // Mutation errors are rendered from the hook state. + }); +} + +export function ExperimentalTab() { + const { + settings, + isLoading, + isPending, + error, + testSent, + connect, + pair, + update, + sendTest, + disconnect, + clearError, + } = useTelegram(); + const [botToken, setBotToken] = useState(''); + const [summaryInterval, setSummaryInterval] = useState('60'); + + useEffect(() => { + if (settings) { + setSummaryInterval(String(settings.summaryIntervalMinutes)); + } + }, [settings]); + + const handleConnect = async () => { + clearError(); + try { + await connect(botToken); + setBotToken(''); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const handleUpdateSummary = async () => { + clearError(); + const value = Number(summaryInterval); + try { + await update({ summaryIntervalMinutes: value }); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const openPairingLink = () => { + if (!settings?.pairingUrl) return; + window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); + }; + + if (isLoading || !settings) { + return ( +
+ + Loading experimental settings... +
+ ); + } + + return ( +
+ + +
+
+ +
+
+ Telegram activity updates + + Experimental local-only notifications for mining status, pool changes, and summaries. + +
+
+
+ +
+

Proof-of-concept flow

+

+ Telegram does not let bots start a conversation. Create a dedicated bot with{' '} + + @BotFather + + , enter its token here, then press Start in the private bot chat. No SV2 cloud service + is involved. +

+
+ + {!settings.connected && ( +
+
+ + setBotToken(event.target.value)} + placeholder="Paste the token from @BotFather" + /> +

+ The token controls the bot. SV2 UI stores it only in the local config volume with + owner-only file permissions and never returns it to the browser. +

+
+ + +
+ )} + + {settings.connected && !settings.paired && ( +
+
+
+ + {settings.botName} · @{settings.botUsername} +
+

+ Open the one-time link, press Start in Telegram, then come back and check the + pairing. This links only that private chat. +

+
+ +
+ + + +
+
+ )} + + {settings.paired && ( +
+
+
+ +
+

Paired with {settings.recipient}

+

+ @{settings.botUsername} sends updates from this local SV2 UI backend. +

+
+
+ +
+ +
+
+
+ +

+ The backend keeps checking even when the browser is closed. +

+
+ { + clearError(); + runMutation(update({ enabled })); + }} + disabled={isPending} + /> +
+ +
+
+ +

+ Notify when mining starts, stops, or switches to another configured pool. +

+
+ { + clearError(); + runMutation(update({ notifyOnStatusChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+ +
+ setSummaryInterval(event.target.value)} + disabled={isPending || !settings.enabled} + /> + +
+

+ Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include + hashrate, workers, and upstream share counts when monitoring data is available. +

+
+
+ +
+ +
+
+ )} + + {error && ( +

+ {error} +

+ )} + + {testSent && !error && ( +

+ Test update sent to Telegram. +

+ )} +
+
+
+ ); +} diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts new file mode 100644 index 00000000..4849b261 --- /dev/null +++ b/src/hooks/useTelegram.ts @@ -0,0 +1,143 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export type TelegramSettings = { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnStatusChange?: boolean; + summaryIntervalMinutes?: number; +}; + +type SuccessResponse = { + success: true; +}; + +async function parseResponse(response: Response): Promise { + const data = await response.json().catch(() => ({})) as { + error?: string; + }; + + if (!response.ok) { + throw new Error(data.error || `Request failed (${response.status})`); + } + + return data as T; +} + +async function fetchSettings(): Promise { + return parseResponse(await fetch('/api/telegram')); +} + +async function connectBot(botToken: string): Promise { + return parseResponse(await fetch('/api/telegram/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botToken }), + })); +} + +async function pairChat(): Promise { + return parseResponse(await fetch('/api/telegram/pair', { + method: 'POST', + })); +} + +async function updateSettings(update: TelegramSettingsUpdate): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(update), + })); +} + +async function sendTestMessage(): Promise { + return parseResponse(await fetch('/api/telegram/test', { + method: 'POST', + })); +} + +async function disconnectBot(): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'DELETE', + })); +} + +export function useTelegram() { + const queryClient = useQueryClient(); + const queryKey = ['telegram-settings']; + + const settingsQuery = useQuery({ + queryKey, + queryFn: fetchSettings, + retry: false, + }); + + const updateCachedSettings = (settings: TelegramSettings) => { + queryClient.setQueryData(queryKey, settings); + }; + + const connectMutation = useMutation({ + mutationFn: connectBot, + onSuccess: updateCachedSettings, + }); + const pairMutation = useMutation({ + mutationFn: pairChat, + onSuccess: updateCachedSettings, + }); + const settingsMutation = useMutation({ + mutationFn: updateSettings, + onSuccess: updateCachedSettings, + }); + const testMutation = useMutation({ + mutationFn: sendTestMessage, + }); + const disconnectMutation = useMutation({ + mutationFn: disconnectBot, + onSuccess: updateCachedSettings, + }); + + const error = + connectMutation.error ?? + pairMutation.error ?? + settingsMutation.error ?? + testMutation.error ?? + disconnectMutation.error ?? + settingsQuery.error; + + const clearError = () => { + connectMutation.reset(); + pairMutation.reset(); + settingsMutation.reset(); + testMutation.reset(); + disconnectMutation.reset(); + }; + + return { + settings: settingsQuery.data, + isLoading: settingsQuery.isLoading, + isPending: + connectMutation.isPending || + pairMutation.isPending || + settingsMutation.isPending || + testMutation.isPending || + disconnectMutation.isPending, + error: error instanceof Error ? error.message : null, + testSent: testMutation.isSuccess, + connect: connectMutation.mutateAsync, + pair: pairMutation.mutateAsync, + update: settingsMutation.mutateAsync, + sendTest: testMutation.mutateAsync, + disconnect: disconnectMutation.mutateAsync, + clearError, + }; +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5a5a6849..6e5aee4a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,9 +15,10 @@ import { Upload, } from 'lucide-react'; import { ConfigurationTab } from '@/components/settings/ConfigurationTab'; +import { ExperimentalTab } from '@/components/settings/ExperimentalTab'; /** - * Settings page with Configuration and Appearance tabs. + * Settings page with Configuration, Logs, Appearance, and Experimental tabs. */ export function Settings() { const { config, updateConfig, resetConfig } = useUiConfig(); @@ -66,16 +67,17 @@ export function Settings() {

Settings

- Manage your configuration and appearance. + Manage your configuration, appearance, and experimental features.

- + Configuration Logs Appearance + Experimental @@ -183,6 +185,10 @@ export function Settings() { + + + + From 35babde89f48bac7aa7186da6ad175767e01105f Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:59:19 +0500 Subject: [PATCH 02/13] feat: add configurable Telegram mining alerts Add block-found, best-difficulty, and pool-failover defaults, bot command controls, and monitoring-backed alert detection. Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/index.ts | 88 ++- server/src/telegram.test.ts | 336 ++++++++--- server/src/telegram.ts | 617 ++++++++++++++++++-- src/components/settings/ExperimentalTab.tsx | 118 +++- src/hooks/useTelegram.ts | 22 +- 5 files changed, 1018 insertions(+), 163 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 4c6be405..eff25069 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,7 @@ import { } from './telegram.js'; import type { TelegramActivitySnapshot, + TelegramMiningChannel, TelegramSettingsUpdate, } from './telegram.js'; @@ -54,7 +55,7 @@ const STATE_FILE = path.join(CONFIG_DIR, 'state.json'); const TELEGRAM_SETTINGS_FILE = path.join(CONFIG_DIR, 'telegram.json'); const AUTO_START_RETRY_INTERVAL_MS = 30_000; -const TELEGRAM_POLL_INTERVAL_MS = 30_000; +const TELEGRAM_POLL_INTERVAL_MS = 5_000; type StackBusyReason = 'auto-start' | 'manual'; @@ -799,6 +800,10 @@ type MonitoringGlobal = { }; type MonitoringServerChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; shares_submitted: number; shares_acknowledged: number; shares_rejected: number; @@ -809,6 +814,26 @@ type MonitoringServerChannels = { standard_channels: MonitoringServerChannel[]; }; +type MonitoringClient = { + client_id: number; +}; + +type MonitoringClients = { + items: MonitoringClient[]; +}; + +type MonitoringMiningChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; +}; + +type MonitoringClientChannels = { + extended_channels: MonitoringMiningChannel[]; + standard_channels: MonitoringMiningChannel[]; +}; + async function fetchMonitoringJson(url: string): Promise { try { const response = await fetch(url, { @@ -832,6 +857,7 @@ async function getTelegramActivitySnapshot(): Promise sharesSubmitted: null, sharesAccepted: null, sharesRejected: null, + channels: null, }; } @@ -839,17 +865,70 @@ async function getTelegramActivitySnapshot(): Promise const containerName = isJdMode ? 'sv2-jdc' : 'sv2-translator'; const port = isJdMode ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; const baseUrl = `${getContainerUrl(containerName, port)}/api/v1`; - const [global, channels] = await Promise.all([ + const [global, serverChannelResponse, clientResponse] = await Promise.all([ fetchMonitoringJson(`${baseUrl}/global`), fetchMonitoringJson( `${baseUrl}/server/channels?offset=0&limit=100` ), + isJdMode + ? fetchMonitoringJson(`${baseUrl}/clients?offset=0&limit=100`) + : Promise.resolve(null), ]); const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; - const serverChannels = channels - ? [...channels.extended_channels, ...channels.standard_channels] + const serverChannels = serverChannelResponse + ? [ + ...serverChannelResponse.extended_channels, + ...serverChannelResponse.standard_channels, + ] : null; + let miningChannels: TelegramMiningChannel[] | null = null; + + if (isJdMode && clientResponse) { + const downstreamResponses = await Promise.all( + clientResponse.items.map(async (client) => ({ + clientId: client.client_id, + channels: await fetchMonitoringJson( + `${baseUrl}/clients/${client.client_id}/channels?offset=0&limit=100` + ), + })) + ); + + if (downstreamResponses.every((response) => response.channels !== null)) { + miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { + if (!channels) return []; + return [ + ...channels.extended_channels.map((channel) => ({ + key: `jdc:${clientId}:extended:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ...channels.standard_channels.map((channel) => ({ + key: `jdc:${clientId}:standard:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ]; + }); + } + } else if (!isJdMode && serverChannelResponse) { + miningChannels = [ + ...serverChannelResponse.extended_channels.map((channel) => ({ + key: `translator:server:extended:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ...serverChannelResponse.standard_channels.map((channel) => ({ + key: `translator:server:standard:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ]; + } return { running: true, @@ -865,6 +944,7 @@ async function getTelegramActivitySnapshot(): Promise sharesRejected: serverChannels ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) : null, + channels: miningChannels, }; } diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts index 8ae0d25d..3a656477 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -13,21 +13,39 @@ import { import type { TelegramActivitySnapshot } from './telegram.js'; const BOT_TOKEN = '123456:test-token'; +const BOT = { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', +}; type FetchCall = { + method: string; url: string; body: Record; }; -function createTelegramFetch(results: unknown[]) { +function createTelegramFetch(initialResults: Record = {}) { const calls: FetchCall[] = []; + const results = new Map( + Object.entries(initialResults).map(([method, values]) => [method, [...values]]) + ); + const fetchImplementation = (async (input: string | URL | Request, init?: RequestInit) => { - calls.push({ - url: String(input), - body: init?.body ? JSON.parse(String(init.body)) as Record : {}, - }); + const url = String(input); + const method = url.split('/').at(-1) ?? ''; + const body = init?.body + ? JSON.parse(String(init.body)) as Record + : {}; + calls.push({ method, url, body }); - const result = results.shift(); + const queued = results.get(method); + const result = queued?.length + ? queued.shift() + : method === 'getUpdates' + ? [] + : { message_id: calls.length }; if (result instanceof Error) throw result; return new Response(JSON.stringify({ @@ -39,7 +57,18 @@ function createTelegramFetch(results: unknown[]) { }); }) as typeof fetch; - return { calls, fetchImplementation }; + return { + calls, + fetchImplementation, + enqueue(method: string, ...values: unknown[]) { + const queued = results.get(method) ?? []; + queued.push(...values); + results.set(method, queued); + }, + callsFor(method: string) { + return calls.filter((call) => call.method === method); + }, + }; } async function createSettingsFile(t: TestContext): Promise { @@ -61,11 +90,42 @@ function snapshot( sharesSubmitted: 42, sharesAccepted: 40, sharesRejected: 2, + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 1250, + }], ...update, }; } -test('formats a compact mining summary', () => { +async function pairService( + t: TestContext, + telegram = createTelegramFetch({ getMe: [BOT] }) +) { + const settingsFile = await createSettingsFile(t); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + telegram.enqueue('getUpdates', [{ + update_id: 77, + message: { + message_id: 4, + text: `/start ${pairingCode}`, + chat: { + id: 987, + type: 'private', + first_name: 'Miner', + username: 'miner_one', + }, + }, + }]); + await service.pairChat(); + return { service, settingsFile, telegram }; +} + +test('formats a compact mining summary with block and difficulty data', () => { assert.equal( formatTelegramStatus(snapshot()), [ @@ -75,6 +135,8 @@ test('formats a compact mining summary', () => { 'Hashrate: 125 TH/s', 'Workers: 3', 'Shares: 42 submitted · 40 accepted · 2 rejected', + 'Blocks found: 0', + 'Best difficulty: 1,250', ].join('\n') ); }); @@ -88,6 +150,7 @@ test('reports only meaningful status changes', () => { sharesSubmitted: null, sharesAccepted: null, sharesRejected: null, + channels: null, }); assert.match(getStatusChangeMessage(stopped, snapshot()) ?? '', /mining started/); @@ -98,45 +161,26 @@ test('reports only meaningful status changes', () => { assert.equal(getStatusChangeMessage(snapshot(), snapshot()), null); }); -test('verifies a bot and pairs the private chat from a one-time deep link', async (t) => { - const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', - }, - ]); - const service = new TelegramService(settingsFile, telegram.fetchImplementation); - const connected = await service.connectBot(BOT_TOKEN); - const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); - assert.ok(pairingCode); - assert.equal(JSON.stringify(connected).includes(BOT_TOKEN), false); - - const pairingTelegram = createTelegramFetch([ - [{ - update_id: 77, - message: { - text: `/start ${pairingCode}`, - chat: { - id: 987, - type: 'private', - first_name: 'Miner', - username: 'miner_one', - }, - }, - }], - { message_id: 5 }, - ]); - const reloadedService = new TelegramService(settingsFile, pairingTelegram.fetchImplementation); - const paired = await reloadedService.pairChat(); +test('pairs a private chat with the three critical alerts enabled by default', async (t) => { + const { service, settingsFile, telegram } = await pairService(t); + const paired = await service.getSettings(); assert.equal(paired.paired, true); assert.equal(paired.enabled, true); assert.equal(paired.recipient, '@miner_one'); assert.equal(paired.pairingUrl, null); - assert.equal(pairingTelegram.calls.at(-1)?.body.chat_id, 987); + assert.equal(paired.notifyOnBlockFound, true); + assert.equal(paired.notifyOnBestDifficulty, true); + assert.equal(paired.notifyOnPoolChange, true); + assert.equal(paired.notifyOnStatusChange, false); + assert.equal(paired.notifyOnWorkerChange, false); + assert.equal(paired.notifyOnRejectedShares, false); + assert.equal(paired.summaryIntervalMinutes, 0); + assert.equal(JSON.stringify(paired).includes(BOT_TOKEN), false); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /Block found, new best difficulty, and pool failover/ + ); const savedMode = (await fs.stat(settingsFile)).mode & 0o777; assert.equal(savedMode, 0o600); @@ -144,15 +188,10 @@ test('verifies a bot and pairs the private chat from a one-time deep link', asyn test('does not pair a chat until the matching Start command arrives', async (t) => { const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', - }, - [], - ]); + const telegram = createTelegramFetch({ + getMe: [BOT], + getUpdates: [[]], + }); const service = new TelegramService(settingsFile, telegram.fetchImplementation); await service.connectBot(BOT_TOKEN); @@ -164,47 +203,168 @@ test('does not pair a chat until the matching Start command arrives', async (t) ); }); -test('the background poll establishes a baseline before sending a transition', async (t) => { - const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', +test('establishes a baseline and announces only a new block', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ channels: null })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^🎉 Block found!/); + assert.match(alert, /Worker: miner-one/); + assert.doesNotMatch(alert, /New best difficulty/); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + assert.equal(telegram.callsFor('sendMessage').length, 2); +}); + +test('announces a new best difficulty on an existing channel', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 2500, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^🏆 New best difficulty!/); + assert.match(alert, /Difficulty: 2,500/); +}); + +test('detects failover across a temporary unknown pool state', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ poolName: null })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ poolName: 'Fallback pool' })); + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^🔁 Pool failover/); + assert.match(alert, /From: Primary pool/); + assert.match(alert, /To: Fallback pool/); +}); + +test('configures alerts with the bot settings keyboard', async (t) => { + const { service, telegram } = await pairService(t); + + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 8, + text: '/settings', + chat: { id: 987, type: 'private', first_name: 'Miner' }, }, - ]); - const service = new TelegramService(settingsFile, telegram.fetchImplementation); - const connected = await service.connectBot(BOT_TOKEN); - const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + }]); + await service.poll(async () => snapshot()); - const pairedTelegram = createTelegramFetch([ - [{ - update_id: 77, - message: { - text: `/start ${pairingCode}`, - chat: { id: 987, type: 'private', first_name: 'Miner' }, + const settingsMessage = telegram.callsFor('sendMessage').at(-1); + assert.match(String(settingsMessage?.body.text), /SV2 Telegram alerts/); + assert.ok(settingsMessage?.body.reply_markup); + + telegram.enqueue('getUpdates', [ + { + update_id: 79, + callback_query: { + id: 'callback-1', + data: 'sv2:toggle:block', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, }, - }], - { message_id: 5 }, - { message_id: 6 }, + }, + { + update_id: 80, + callback_query: { + id: 'callback-2', + data: 'sv2:toggle:best', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, ]); - const pairedService = new TelegramService(settingsFile, pairedTelegram.fetchImplementation); - await pairedService.pairChat(); + await service.poll(async () => snapshot()); - const stopped = snapshot({ - running: false, - poolName: null, - hashrate: null, - workers: null, - sharesSubmitted: null, - sharesAccepted: null, - sharesRejected: null, - }); - await pairedService.poll(async () => stopped); - assert.equal(pairedTelegram.calls.length, 2); + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, false); + assert.equal(settings.notifyOnBestDifficulty, false); + assert.equal(telegram.callsFor('answerCallbackQuery').length, 2); + assert.equal(telegram.callsFor('editMessageText').length, 2); + assert.equal( + (telegram.callsFor('getUpdates').at(-1)?.body.offset), + 79 + ); +}); + +test('keeps bot commands active when notifications are disabled', async (t) => { + const { service, telegram } = await pairService(t); + await service.updateSettings({ enabled: false }); + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 10, + text: '/status', + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }]); - await pairedService.poll(async () => snapshot()); - assert.equal(pairedTelegram.calls.length, 3); - assert.match(String(pairedTelegram.calls[2].body.text), /mining started/); + await service.poll(async () => snapshot()); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /SV2 mining status/ + ); +}); + +test('migrates the original proof-of-concept settings', async (t) => { + const settingsFile = await createSettingsFile(t); + await fs.writeFile(settingsFile, JSON.stringify({ + version: 1, + botToken: BOT_TOKEN, + botUsername: 'sv2_alerts_bot', + botName: 'SV2 alerts', + pairingCode: null, + chatId: 987, + recipient: '@miner_one', + enabled: true, + notifyOnStatusChange: true, + summaryIntervalMinutes: 60, + })); + + const service = new TelegramService( + settingsFile, + createTelegramFetch().fetchImplementation + ); + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, true); + assert.equal(settings.notifyOnBestDifficulty, true); + assert.equal(settings.notifyOnPoolChange, true); + assert.equal(settings.notifyOnStatusChange, true); + assert.equal(settings.summaryIntervalMinutes, 60); }); diff --git a/server/src/telegram.ts b/server/src/telegram.ts index 337fa3e8..bde71aa0 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -1,10 +1,10 @@ -import { randomBytes } from 'crypto'; -import fs from 'fs/promises'; -import path from 'path'; +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; -const DEFAULT_SUMMARY_INTERVAL_MINUTES = 60; const MIN_SUMMARY_INTERVAL_MINUTES = 15; const MAX_SUMMARY_INTERVAL_MINUTES = 24 * 60; +const SUMMARY_INTERVAL_OPTIONS = [0, 15, 60, 6 * 60] as const; type FetchImplementation = typeof fetch; @@ -24,12 +24,22 @@ type TelegramChat = { title?: string; }; +type TelegramMessage = { + message_id: number; + text?: string; + chat: TelegramChat; +}; + +type TelegramCallbackQuery = { + id: string; + data?: string; + message?: TelegramMessage; +}; + type TelegramUpdate = { update_id: number; - message?: { - text?: string; - chat: TelegramChat; - }; + message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; }; type TelegramApiResponse = { @@ -39,7 +49,29 @@ type TelegramApiResponse = { error_code?: number; }; -type SavedTelegramSettings = { +type TelegramAlertSettings = { + enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; + notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; + summaryIntervalMinutes: number; +}; + +type SavedTelegramSettings = TelegramAlertSettings & { + version: 2; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + lastUpdateId: number | null; +}; + +type LegacySavedTelegramSettings = { version: 1; botToken: string; botUsername: string; @@ -52,22 +84,22 @@ type SavedTelegramSettings = { summaryIntervalMinutes: number; }; -export type TelegramSettings = { +export type TelegramSettings = TelegramAlertSettings & { connected: boolean; paired: boolean; botUsername: string | null; botName: string | null; recipient: string | null; pairingUrl: string | null; - enabled: boolean; - notifyOnStatusChange: boolean; - summaryIntervalMinutes: number; }; -export type TelegramSettingsUpdate = { - enabled?: boolean; - notifyOnStatusChange?: boolean; - summaryIntervalMinutes?: number; +export type TelegramSettingsUpdate = Partial; + +export type TelegramMiningChannel = { + key: string; + userIdentity: string; + blocksFound: number; + bestDifficulty: number; }; export type TelegramActivitySnapshot = { @@ -78,6 +110,7 @@ export type TelegramActivitySnapshot = { sharesSubmitted: number | null; sharesAccepted: number | null; sharesRejected: number | null; + channels: TelegramMiningChannel[] | null; }; export class TelegramConfigError extends Error {} @@ -91,6 +124,17 @@ export class TelegramApiError extends Error { } } +const DEFAULT_ALERT_SETTINGS: TelegramAlertSettings = { + enabled: true, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnStatusChange: false, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + summaryIntervalMinutes: 0, +}; + function getEmptySettings(): TelegramSettings { return { connected: false, @@ -99,9 +143,8 @@ function getEmptySettings(): TelegramSettings { botName: null, recipient: null, pairingUrl: null, + ...DEFAULT_ALERT_SETTINGS, enabled: false, - notifyOnStatusChange: true, - summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, }; } @@ -118,17 +161,22 @@ function toPublicSettings(settings: SavedTelegramSettings | null): TelegramSetti ? `https://t.me/${settings.botUsername}?start=${settings.pairingCode}` : null, enabled: settings.enabled, + notifyOnBlockFound: settings.notifyOnBlockFound, + notifyOnBestDifficulty: settings.notifyOnBestDifficulty, + notifyOnPoolChange: settings.notifyOnPoolChange, notifyOnStatusChange: settings.notifyOnStatusChange, + notifyOnWorkerChange: settings.notifyOnWorkerChange, + notifyOnRejectedShares: settings.notifyOnRejectedShares, summaryIntervalMinutes: settings.summaryIntervalMinutes, }; } -function isSavedSettings(value: unknown): value is SavedTelegramSettings { - if (!value || typeof value !== 'object') return false; - const settings = value as Partial; +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} - return settings.version === 1 && - typeof settings.botToken === 'string' && +function hasConnectionFields(settings: Record): boolean { + return typeof settings.botToken === 'string' && typeof settings.botUsername === 'string' && typeof settings.botName === 'string' && (settings.pairingCode === null || typeof settings.pairingCode === 'string') && @@ -139,6 +187,38 @@ function isSavedSettings(value: unknown): value is SavedTelegramSettings { Number.isInteger(settings.summaryIntervalMinutes); } +function parseSavedSettings(value: unknown): SavedTelegramSettings | null { + if (!isObject(value) || !hasConnectionFields(value)) return null; + + if (value.version === 1) { + const legacy = value as LegacySavedTelegramSettings; + return { + ...legacy, + version: 2, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + lastUpdateId: null, + }; + } + + if ( + value.version !== 2 || + typeof value.notifyOnBlockFound !== 'boolean' || + typeof value.notifyOnBestDifficulty !== 'boolean' || + typeof value.notifyOnPoolChange !== 'boolean' || + typeof value.notifyOnWorkerChange !== 'boolean' || + typeof value.notifyOnRejectedShares !== 'boolean' || + (value.lastUpdateId !== null && !Number.isInteger(value.lastUpdateId)) + ) { + return null; + } + + return value as SavedTelegramSettings; +} + function getRecipientLabel(chat: TelegramChat): string { if (chat.username) return `@${chat.username}`; if (chat.title) return chat.title; @@ -153,6 +233,12 @@ function getStartParameter(text: string | undefined): string | null { return match?.[1] ?? null; } +function getCommand(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/([a-z]+)(?:@[A-Za-z0-9_]+)?(?:\s|$)/i); + return match?.[1]?.toLowerCase() ?? null; +} + function formatHashrate(hashrate: number | null): string | null { if (hashrate === null || !Number.isFinite(hashrate)) return null; @@ -169,6 +255,20 @@ function formatHashrate(hashrate: number | null): string | null { return `${value.toFixed(decimals)} ${units[unitIndex]}`; } +function formatDifficulty(difficulty: number): string { + if (!Number.isFinite(difficulty)) return 'Unknown'; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits: difficulty >= 100 ? 0 : 2, + }).format(difficulty); +} + +function getBestChannel(channels: TelegramMiningChannel[] | null): TelegramMiningChannel | null { + if (!channels?.length) return null; + return channels.reduce((best, channel) => + channel.bestDifficulty > best.bestDifficulty ? channel : best + ); +} + export function formatTelegramStatus( snapshot: TelegramActivitySnapshot, heading = '⛏ SV2 mining status' @@ -195,6 +295,18 @@ export function formatTelegramStatus( lines.push(`Shares: ${shareParts.join(' · ')}`); } + if (snapshot.channels) { + const blocksFound = snapshot.channels.reduce( + (total, channel) => total + channel.blocksFound, + 0 + ); + const bestChannel = getBestChannel(snapshot.channels); + lines.push(`Blocks found: ${blocksFound.toLocaleString()}`); + if (bestChannel) { + lines.push(`Best difficulty: ${formatDifficulty(bestChannel.bestDifficulty)}`); + } + } + return lines.join('\n'); } @@ -221,11 +333,143 @@ export function getStatusChangeMessage( return null; } +function getMiningStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '🟢 SV2 mining started'); + } + if (previous.running && !current.running) { + return formatTelegramStatus(current, '🔴 SV2 mining stopped'); + } + return null; +} + +function getBlockFoundMessages( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string[] { + if (!previous.channels || !current.channels) return []; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + return current.channels.flatMap((channel) => { + const before = previousByKey.get(channel.key); + if (!before || channel.blocksFound <= before.blocksFound) return []; + + const delta = channel.blocksFound - before.blocksFound; + const lines = ['🎉 Block found!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${channel.userIdentity}`); + lines.push( + delta === 1 + ? `Channel total: ${channel.blocksFound.toLocaleString()}` + : `New blocks: ${delta.toLocaleString()} · Channel total: ${channel.blocksFound.toLocaleString()}` + ); + lines.push(`Best difficulty: ${formatDifficulty(channel.bestDifficulty)}`); + return [lines.join('\n')]; + }); +} + +function getBestDifficultyMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot, + highWatermark: number +): string | null { + if (!previous.channels || !current.channels) return null; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + const improved = current.channels + .filter((channel) => { + const before = previousByKey.get(channel.key); + return before && + channel.bestDifficulty > before.bestDifficulty && + channel.bestDifficulty > highWatermark; + }) + .sort((left, right) => right.bestDifficulty - left.bestDifficulty)[0]; + + if (!improved) return null; + + const lines = ['🏆 New best difficulty!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${improved.userIdentity}`); + lines.push(`Difficulty: ${formatDifficulty(improved.bestDifficulty)}`); + return lines.join('\n'); +} + +function formatSummaryInterval(minutes: number): string { + if (minutes === 0) return 'Off'; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} + +function getSettingsMessage(settings: SavedTelegramSettings): string { + return [ + '⚙️ SV2 Telegram alerts', + '', + 'Tap a button to toggle an alert. Critical defaults are block found, new best difficulty, and pool failover.', + '', + `Notifications: ${settings.enabled ? 'ON' : 'OFF'}`, + `Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + ].join('\n'); +} + +function toggleButton(enabled: boolean, label: string, callbackData: string) { + return { + text: `${enabled ? '✅' : '⬜'} ${label}`, + callback_data: callbackData, + }; +} + +function getSettingsKeyboard(settings: SavedTelegramSettings) { + return { + inline_keyboard: [ + [toggleButton(settings.enabled, 'Notifications', 'sv2:toggle:enabled')], + [ + toggleButton(settings.notifyOnBlockFound, 'Block found', 'sv2:toggle:block'), + toggleButton(settings.notifyOnBestDifficulty, 'Best difficulty', 'sv2:toggle:best'), + ], + [toggleButton(settings.notifyOnPoolChange, 'Pool failover', 'sv2:toggle:pool')], + [ + toggleButton(settings.notifyOnStatusChange, 'Mining status', 'sv2:toggle:status'), + toggleButton(settings.notifyOnWorkerChange, 'Workers', 'sv2:toggle:workers'), + ], + [toggleButton(settings.notifyOnRejectedShares, 'Rejected shares', 'sv2:toggle:rejected')], + [{ + text: `⏱ Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + callback_data: 'sv2:toggle:summary', + }], + ], + }; +} + +function getHelpMessage(): string { + return [ + '⛏ SV2 UI Telegram bot', + '', + '/settings — choose alerts', + '/status — current mining status', + '/help — show these commands', + ].join('\n'); +} + +function cycleSummaryInterval(current: number): number { + const currentIndex = SUMMARY_INTERVAL_OPTIONS.indexOf( + current as typeof SUMMARY_INTERVAL_OPTIONS[number] + ); + return SUMMARY_INTERVAL_OPTIONS[ + currentIndex === -1 ? 0 : (currentIndex + 1) % SUMMARY_INTERVAL_OPTIONS.length + ]; +} + export class TelegramService { private settings: SavedTelegramSettings | null = null; private initialized = false; private previousSnapshot: TelegramActivitySnapshot | null = null; + private channelBaselines = new Map(); + private bestDifficultyHighWatermark = 0; private lastSummaryAt: number | null = null; + private lastKnownPoolName: string | null = null; private pollInProgress = false; constructor( @@ -238,8 +482,8 @@ export class TelegramService { try { const raw = await fs.readFile(this.settingsFile, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (!isSavedSettings(parsed)) { + const parsed = parseSavedSettings(JSON.parse(raw) as unknown); + if (!parsed) { throw new TelegramConfigError('Stored Telegram settings are invalid'); } this.settings = parsed; @@ -276,16 +520,16 @@ export class TelegramService { } this.settings = { - version: 1, + version: 2, botToken: token, botUsername: bot.username, botName: bot.first_name, pairingCode: `sv2_${randomBytes(18).toString('base64url')}`, chatId: null, recipient: null, + lastUpdateId: null, + ...DEFAULT_ALERT_SETTINGS, enabled: false, - notifyOnStatusChange: true, - summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, }; this.resetMonitorState(); await this.save(); @@ -326,19 +570,30 @@ export class TelegramService { ); } - await this.sendMessage( - settings.botToken, - chat.id, - '✅ SV2 UI is linked. Telegram activity updates are now enabled.' - ); - - this.settings = { + const pairedSettings: SavedTelegramSettings = { ...settings, pairingCode: null, chatId: chat.id, recipient: getRecipientLabel(chat), - enabled: true, + lastUpdateId: updates.reduce( + (latest, update) => Math.max(latest, update.update_id), + matchingUpdate.update_id + ), + ...DEFAULT_ALERT_SETTINGS, }; + + await this.sendMessage( + pairedSettings.botToken, + chat.id, + [ + '✅ SV2 UI is linked.', + '', + 'Block found, new best difficulty, and pool failover alerts are enabled.', + 'Use /settings to configure alerts or /status for a live summary.', + ].join('\n') + ); + + this.settings = pairedSettings; this.resetMonitorState(); await this.save(); return toPublicSettings(this.settings); @@ -347,22 +602,39 @@ export class TelegramService { async updateSettings(update: TelegramSettingsUpdate): Promise { await this.initialize(); const settings = this.requirePaired(); + const next = { + ...settings, + enabled: update.enabled ?? settings.enabled, + notifyOnBlockFound: update.notifyOnBlockFound ?? settings.notifyOnBlockFound, + notifyOnBestDifficulty: + update.notifyOnBestDifficulty ?? settings.notifyOnBestDifficulty, + notifyOnPoolChange: update.notifyOnPoolChange ?? settings.notifyOnPoolChange, + notifyOnStatusChange: update.notifyOnStatusChange ?? settings.notifyOnStatusChange, + notifyOnWorkerChange: update.notifyOnWorkerChange ?? settings.notifyOnWorkerChange, + notifyOnRejectedShares: + update.notifyOnRejectedShares ?? settings.notifyOnRejectedShares, + summaryIntervalMinutes: + update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes, + }; - const enabled = update.enabled ?? settings.enabled; - const notifyOnStatusChange = - update.notifyOnStatusChange ?? settings.notifyOnStatusChange; - const summaryIntervalMinutes = - update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes; - - if (typeof enabled !== 'boolean' || typeof notifyOnStatusChange !== 'boolean') { + const booleanKeys = [ + 'enabled', + 'notifyOnBlockFound', + 'notifyOnBestDifficulty', + 'notifyOnPoolChange', + 'notifyOnStatusChange', + 'notifyOnWorkerChange', + 'notifyOnRejectedShares', + ] as const; + if (booleanKeys.some((key) => typeof next[key] !== 'boolean')) { throw new TelegramConfigError('Notification settings must be true or false'); } if ( - !Number.isInteger(summaryIntervalMinutes) || - (summaryIntervalMinutes !== 0 && + !Number.isInteger(next.summaryIntervalMinutes) || + (next.summaryIntervalMinutes !== 0 && ( - summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || - summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES + next.summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + next.summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES )) ) { throw new TelegramConfigError( @@ -370,12 +642,7 @@ export class TelegramService { ); } - this.settings = { - ...settings, - enabled, - notifyOnStatusChange, - summaryIntervalMinutes, - }; + this.settings = next; this.resetMonitorState(); await this.save(); return toPublicSettings(this.settings); @@ -409,32 +676,116 @@ export class TelegramService { await this.initialize(); if (this.pollInProgress) return; - const settings = this.settings; - if (!settings?.enabled || settings.chatId === null) { + const initialSettings = this.settings; + if (!initialSettings || initialSettings.chatId === null) { this.resetMonitorState(); return; } this.pollInProgress = true; + let snapshotPromise: Promise | null = null; + const getSnapshot = () => { + snapshotPromise ??= snapshotProvider(); + return snapshotPromise; + }; + try { - const current = await snapshotProvider(); + await this.processBotUpdates( + initialSettings as SavedTelegramSettings & { chatId: number }, + getSnapshot + ); + const settings = this.requirePaired(); + if (!settings.enabled) { + this.resetMonitorState(); + return; + } + + const current = await getSnapshot(); const now = Date.now(); if (!this.previousSnapshot) { this.previousSnapshot = current; + this.updateChannelBaselines(current.channels); + this.lastKnownPoolName = current.poolName; this.lastSummaryAt = now; return; } - const statusMessage = settings.notifyOnStatusChange - ? getStatusChangeMessage(this.previousSnapshot, current) - : null; + const messages: string[] = []; + const channelBaselineSnapshot = { + ...this.previousSnapshot, + channels: [...this.channelBaselines.values()], + }; + const blockMessages = settings.notifyOnBlockFound + ? getBlockFoundMessages(channelBaselineSnapshot, current) + : []; + messages.push(...blockMessages); + + if ( + settings.notifyOnBestDifficulty && + blockMessages.length === 0 + ) { + const bestDifficultyMessage = getBestDifficultyMessage( + channelBaselineSnapshot, + current, + this.bestDifficultyHighWatermark + ); + if (bestDifficultyMessage) messages.push(bestDifficultyMessage); + } + + if ( + settings.notifyOnPoolChange && + current.running && + this.lastKnownPoolName && + current.poolName && + this.lastKnownPoolName !== current.poolName + ) { + messages.push([ + '🔁 Pool failover', + `From: ${this.lastKnownPoolName}`, + `To: ${current.poolName}`, + ].join('\n')); + } + + if (settings.notifyOnStatusChange) { + const statusMessage = getMiningStatusChangeMessage(this.previousSnapshot, current); + if (statusMessage) messages.push(statusMessage); + } + + if ( + settings.notifyOnWorkerChange && + this.previousSnapshot.workers !== null && + current.workers !== null && + this.previousSnapshot.workers !== current.workers + ) { + messages.push([ + current.workers > this.previousSnapshot.workers + ? '🟢 Worker connected' + : '🟠 Worker disconnected', + `Workers: ${this.previousSnapshot.workers.toLocaleString()} → ${current.workers.toLocaleString()}`, + ].join('\n')); + } + + if ( + settings.notifyOnRejectedShares && + this.previousSnapshot.sharesRejected !== null && + current.sharesRejected !== null && + current.sharesRejected > this.previousSnapshot.sharesRejected + ) { + messages.push([ + '⚠️ Rejected shares increased', + `New rejected shares: ${(current.sharesRejected - this.previousSnapshot.sharesRejected).toLocaleString()}`, + `Total rejected: ${current.sharesRejected.toLocaleString()}`, + ].join('\n')); + } + const summaryDue = settings.summaryIntervalMinutes > 0 && this.lastSummaryAt !== null && now - this.lastSummaryAt >= settings.summaryIntervalMinutes * 60_000; - if (statusMessage) { - await this.sendMessage(settings.botToken, settings.chatId, statusMessage); + if (messages.length > 0) { + await this.sendMessage(settings.botToken, settings.chatId, messages.join('\n\n')); + this.lastSummaryAt = now; } else if (summaryDue) { await this.sendMessage( settings.botToken, @@ -444,12 +795,137 @@ export class TelegramService { this.lastSummaryAt = now; } - this.previousSnapshot = current; + this.previousSnapshot = current.channels === null + ? { ...current, channels: this.previousSnapshot.channels } + : current; + this.updateChannelBaselines(current.channels); + if (current.poolName) this.lastKnownPoolName = current.poolName; } finally { this.pollInProgress = false; } } + private async processBotUpdates( + settings: SavedTelegramSettings & { chatId: number }, + getSnapshot: () => Promise + ): Promise { + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: settings.lastUpdateId === null ? 0 : settings.lastUpdateId + 1, + limit: 100, + timeout: 0, + allowed_updates: ['message', 'callback_query'], + } + ); + + if (updates.length === 0) return; + + for (const update of [...updates].sort((left, right) => left.update_id - right.update_id)) { + if (this.settings) { + this.settings = { ...this.settings, lastUpdateId: update.update_id }; + await this.save(); + } + const currentSettings = this.requirePaired(); + const message = update.message; + if ( + message?.chat.type === 'private' && + message.chat.id === currentSettings.chatId + ) { + const command = getCommand(message.text); + if (command === 'settings' || command === 'alerts') { + await this.sendSettingsMessage(currentSettings); + } else if (command === 'status') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + formatTelegramStatus(await getSnapshot()) + ); + } else if (command === 'start' || command === 'help') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + getHelpMessage() + ); + } + } + + const callback = update.callback_query; + if ( + callback?.message?.chat.type === 'private' && + callback.message.chat.id === currentSettings.chatId && + callback.data?.startsWith('sv2:toggle:') + ) { + await this.handleToggle(callback); + } + } + } + + private async handleToggle(callback: TelegramCallbackQuery): Promise { + const settings = this.requirePaired(); + const key = callback.data?.slice('sv2:toggle:'.length); + const next = { ...settings }; + + switch (key) { + case 'enabled': + next.enabled = !next.enabled; + break; + case 'block': + next.notifyOnBlockFound = !next.notifyOnBlockFound; + break; + case 'best': + next.notifyOnBestDifficulty = !next.notifyOnBestDifficulty; + break; + case 'pool': + next.notifyOnPoolChange = !next.notifyOnPoolChange; + break; + case 'status': + next.notifyOnStatusChange = !next.notifyOnStatusChange; + break; + case 'workers': + next.notifyOnWorkerChange = !next.notifyOnWorkerChange; + break; + case 'rejected': + next.notifyOnRejectedShares = !next.notifyOnRejectedShares; + break; + case 'summary': + next.summaryIntervalMinutes = cycleSummaryInterval(next.summaryIntervalMinutes); + break; + default: + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'This alert option is no longer available.', + }); + return; + } + + this.settings = next; + this.resetMonitorState(); + await this.save(); + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'Alert settings updated.', + }); + + if (callback.message) { + await this.callApi(settings.botToken, 'editMessageText', { + chat_id: settings.chatId, + message_id: callback.message.message_id, + text: getSettingsMessage(next), + reply_markup: getSettingsKeyboard(next), + }); + } + } + + private async sendSettingsMessage(settings: SavedTelegramSettings): Promise { + await this.callApi(settings.botToken, 'sendMessage', { + chat_id: settings.chatId, + text: getSettingsMessage(settings), + reply_markup: getSettingsKeyboard(settings), + }); + } + private requireConnected(): SavedTelegramSettings { if (!this.settings) { throw new TelegramConfigError('Connect a Telegram bot first'); @@ -479,7 +955,22 @@ export class TelegramService { private resetMonitorState(): void { this.previousSnapshot = null; + this.channelBaselines.clear(); + this.bestDifficultyHighWatermark = 0; this.lastSummaryAt = null; + this.lastKnownPoolName = null; + } + + private updateChannelBaselines(channels: TelegramMiningChannel[] | null): void { + if (!channels) return; + + for (const channel of channels) { + this.channelBaselines.set(channel.key, channel); + this.bestDifficultyHighWatermark = Math.max( + this.bestDifficultyHighWatermark, + channel.bestDifficulty + ); + } } private async sendMessage(botToken: string, chatId: number, text: string): Promise { diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx index 18f9cde8..277ee467 100644 --- a/src/components/settings/ExperimentalTab.tsx +++ b/src/components/settings/ExperimentalTab.tsx @@ -26,6 +26,7 @@ export function ExperimentalTab() { const { settings, isLoading, + retry, isPending, error, testSent, @@ -70,7 +71,7 @@ export function ExperimentalTab() { window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); }; - if (isLoading || !settings) { + if (isLoading) { return (
@@ -79,6 +80,19 @@ export function ExperimentalTab() { ); } + if (!settings) { + return ( +
+

+ {error || 'Telegram settings could not be loaded.'} +

+ +
+ ); + } + return (
@@ -90,7 +104,7 @@ export function ExperimentalTab() {
Telegram activity updates - Experimental local-only notifications for mining status, pool changes, and summaries. + Experimental local-only alerts for blocks, best difficulty, failover, and mining health.
@@ -196,7 +210,8 @@ export function ExperimentalTab() {

Paired with {settings.recipient}

- @{settings.botUsername} sends updates from this local SV2 UI backend. + @{settings.botUsername} sends updates from this local SV2 UI backend. Send{' '} + /settings to configure these alerts in Telegram.

@@ -235,9 +250,63 @@ export function ExperimentalTab() {
- + +

+ Notify immediately when a channel's block counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnBlockFound })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+

- Notify when mining starts, stops, or switches to another configured pool. + Notify when an existing miner channel sets a higher best share difficulty. +

+
+ { + clearError(); + runMutation(update({ notifyOnBestDifficulty })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when mining moves from one configured pool to another. +

+
+ { + clearError(); + runMutation(update({ notifyOnPoolChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the local mining stack starts or stops.

+
+
+ +

+ Notify when the monitored worker count increases or decreases. +

+
+ { + clearError(); + runMutation(update({ notifyOnWorkerChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the upstream rejected-share counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnRejectedShares })); + }} + disabled={isPending || !settings.enabled} + /> +
+
@@ -274,7 +379,8 @@ export function ExperimentalTab() {

Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include - hashrate, workers, and upstream share counts when monitoring data is available. + hashrate, workers, upstream share counts, blocks found, and best difficulty when + monitoring data is available.

diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts index 4849b261..dbde2ae4 100644 --- a/src/hooks/useTelegram.ts +++ b/src/hooks/useTelegram.ts @@ -8,13 +8,23 @@ export type TelegramSettings = { recipient: string | null; pairingUrl: string | null; enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; summaryIntervalMinutes: number; }; export type TelegramSettingsUpdate = { enabled?: boolean; + notifyOnBlockFound?: boolean; + notifyOnBestDifficulty?: boolean; + notifyOnPoolChange?: boolean; notifyOnStatusChange?: boolean; + notifyOnWorkerChange?: boolean; + notifyOnRejectedShares?: boolean; summaryIntervalMinutes?: number; }; @@ -23,9 +33,15 @@ type SuccessResponse = { }; async function parseResponse(response: Response): Promise { - const data = await response.json().catch(() => ({})) as { + const data = await response.json().catch(() => null) as { error?: string; - }; + } | null; + + if (!data) { + throw new Error( + 'Telegram settings API is unavailable. Restart the SV2 UI backend from this branch.' + ); + } if (!response.ok) { throw new Error(data.error || `Request failed (${response.status})`); @@ -80,6 +96,7 @@ export function useTelegram() { queryKey, queryFn: fetchSettings, retry: false, + refetchInterval: 5000, }); const updateCachedSettings = (settings: TelegramSettings) => { @@ -125,6 +142,7 @@ export function useTelegram() { return { settings: settingsQuery.data, isLoading: settingsQuery.isLoading, + retry: settingsQuery.refetch, isPending: connectMutation.isPending || pairMutation.isPending || From c1f0d5f6f4596d4f117a25bd87bbd66db433541b Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:03:47 +0500 Subject: [PATCH 03/13] refine uo --- src/components/ExperimentalTab.test.ts | 37 ++++ src/components/settings/ExperimentalTab.tsx | 200 ++++++++++++++------ 2 files changed, 174 insertions(+), 63 deletions(-) create mode 100644 src/components/ExperimentalTab.test.ts diff --git a/src/components/ExperimentalTab.test.ts b/src/components/ExperimentalTab.test.ts new file mode 100644 index 00000000..6881a0aa --- /dev/null +++ b/src/components/ExperimentalTab.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isTelegramExperimentOpen } from './settings/ExperimentalTab'; + +test('keeps an unconfigured Telegram experiment closed until it is enabled', () => { + const settings = { + connected: false, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), false); + assert.equal(isTelegramExperimentOpen(settings, true), true); +}); + +test('opens an existing unpaired Telegram setup unless the user closes it', () => { + const settings = { + connected: true, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), true); + assert.equal(isTelegramExperimentOpen(settings, false), false); +}); + +test('uses the backend notification state after Telegram is paired', () => { + const settings = { + connected: true, + paired: true, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, true), false); + assert.equal(isTelegramExperimentOpen({ ...settings, enabled: true }, false), true); +}); diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx index 277ee467..584cc3fb 100644 --- a/src/components/settings/ExperimentalTab.tsx +++ b/src/components/settings/ExperimentalTab.tsx @@ -1,20 +1,41 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Bot, ExternalLink, - FlaskConical, Loader2, Send, - ShieldCheck, Unplug, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; -import { useTelegram } from '@/hooks/useTelegram'; +import { useTelegram, type TelegramSettings } from '@/hooks/useTelegram'; + +const TELEGRAM_EXPERIMENT_STORAGE_KEY = 'sv2-ui-experiment-telegram-enabled'; + +function readStoredTelegramExperimentState(): boolean | null { + if (typeof window === 'undefined') return null; + + const stored = window.localStorage.getItem(TELEGRAM_EXPERIMENT_STORAGE_KEY); + return stored === null ? null : stored === 'true'; +} + +function storeTelegramExperimentState(enabled: boolean): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(TELEGRAM_EXPERIMENT_STORAGE_KEY, String(enabled)); +} + +export function isTelegramExperimentOpen( + settings: Pick, + setupEnabled: boolean | null, +): boolean { + return settings.paired + ? settings.enabled + : setupEnabled ?? settings.connected; +} function runMutation(promise: Promise): void { void promise.catch(() => { @@ -39,6 +60,9 @@ export function ExperimentalTab() { } = useTelegram(); const [botToken, setBotToken] = useState(''); const [summaryInterval, setSummaryInterval] = useState('60'); + const [telegramSetupEnabled, setTelegramSetupEnabled] = useState( + readStoredTelegramExperimentState, + ); useEffect(() => { if (settings) { @@ -71,6 +95,18 @@ export function ExperimentalTab() { window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); }; + const handleTelegramExperimentChange = (enabled: boolean) => { + clearError(); + + if (settings?.paired) { + runMutation(update({ enabled })); + return; + } + + setTelegramSetupEnabled(enabled); + storeTelegramExperimentState(enabled); + }; + if (isLoading) { return (
@@ -93,27 +129,34 @@ export function ExperimentalTab() { ); } + const telegramExperimentOpen = isTelegramExperimentOpen( + settings, + telegramSetupEnabled, + ); + return (
- - -
-
- -
-
- Telegram activity updates - - Experimental local-only alerts for blocks, best difficulty, failover, and mining health. - -
-
-
- +
+

Experiments

+

+ These features are functional but still being refined. Enable them to try new + capabilities early. +

+
+ +
+

Proof-of-concept flow

- Telegram does not let bots start a conversation. Create a dedicated bot with{' '} + Create a dedicated bot with{' '} @BotFather - , enter its token here, then press Start in the private bot chat. No SV2 cloud service - is involved. + , enter its token here, then press Start in the private bot chat on Telegram.

@@ -205,15 +247,12 @@ export function ExperimentalTab() { {settings.paired && (
-
- -
-

Paired with {settings.recipient}

-

- @{settings.botUsername} sends updates from this local SV2 UI backend. Send{' '} - /settings to configure these alerts in Telegram. -

-
+
+

Paired with {settings.recipient}

+

+ @{settings.botUsername} sends updates from this local SV2 UI backend. Send{' '} + /settings to configure these alerts in Telegram. +

-

- Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include - hashrate, workers, upstream share counts, blocks found, and best difficulty when - monitoring data is available. -

@@ -402,19 +418,77 @@ export function ExperimentalTab() {
)} - {error && ( -

- {error} -

- )} - {testSent && !error && (

Test update sent to Telegram.

)} -
-
+ + + {error && ( +

+ {error} +

+ )} +
); } + +function ExperimentToggleCard({ + id, + title, + description, + enabled, + disabled = false, + onEnabledChange, + children, +}: { + id: string; + title: string; + description: string; + enabled: boolean; + disabled?: boolean; + onEnabledChange: (enabled: boolean) => void; + children: ReactNode; +}) { + const switchId = `${id}-enabled`; + const labelId = `${id}-label`; + const contentId = `${id}-content`; + + return ( + +
+
+ +

{description}

+
+ +
+ + {enabled && ( +
+ {children} +
+ )} +
+ ); +} From 4168768a95845621bb3296c44cd4b52b495aaea6 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:54:12 +0500 Subject: [PATCH 04/13] fix: address Telegram review findings --- server/src/index.ts | 148 ++++++++++++-------- server/src/telegram.test.ts | 48 +++++++ server/src/telegram.ts | 36 +++++ src/components/pools/PoolPriorityEditor.tsx | 40 ++---- src/lib/pools.test.ts | 26 +++- src/lib/pools.ts | 17 ++- 6 files changed, 226 insertions(+), 89 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index eff25069..3a5e4ebb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,6 +35,8 @@ import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/d import { ActivePoolTracker } from './active-pool.js'; import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; import { + collectPaginatedMonitoringItems, + getTelegramWorkerCount, TelegramApiError, TelegramConfigError, TelegramService, @@ -796,7 +798,11 @@ function getContainerUrl(containerName: string, port: number): string { type MonitoringGlobal = { server?: { total_hashrate: number } | null; sv1_clients?: { total_clients: number; total_hashrate: number } | null; - sv2_clients?: { total_clients: number; total_hashrate: number } | null; + sv2_clients?: { + total_clients: number; + total_channels: number; + total_hashrate: number; + } | null; }; type MonitoringServerChannel = { @@ -809,17 +815,25 @@ type MonitoringServerChannel = { shares_rejected: number; }; -type MonitoringServerChannels = { - extended_channels: MonitoringServerChannel[]; - standard_channels: MonitoringServerChannel[]; +type MonitoringChannelsPage = { + total_extended: number; + total_standard: number; + extended_channels: T[]; + standard_channels: T[]; }; type MonitoringClient = { client_id: number; }; -type MonitoringClients = { - items: MonitoringClient[]; +type MonitoringItemsPage = { + total: number; + items: T[]; +}; + +type TaggedMonitoringChannel = { + kind: 'extended' | 'standard'; + channel: T; }; type MonitoringMiningChannel = { @@ -829,11 +843,6 @@ type MonitoringMiningChannel = { blocks_found: number; }; -type MonitoringClientChannels = { - extended_channels: MonitoringMiningChannel[]; - standard_channels: MonitoringMiningChannel[]; -}; - async function fetchMonitoringJson(url: string): Promise { try { const response = await fetch(url, { @@ -845,6 +854,47 @@ async function fetchMonitoringJson(url: string): Promise { } } +function combineMonitoringChannelPage( + page: MonitoringChannelsPage, +): { + items: TaggedMonitoringChannel[]; + total: number; +} { + return { + items: [ + ...page.extended_channels.map((channel) => ({ + kind: 'extended' as const, + channel, + })), + ...page.standard_channels.map((channel) => ({ + kind: 'standard' as const, + channel, + })), + ], + total: Math.max(page.total_extended, page.total_standard), + }; +} + +async function fetchAllMonitoringChannels( + endpoint: string, +): Promise[] | null> { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? combineMonitoringChannelPage(page) : null; + }); +} + +async function fetchAllMonitoringItems(endpoint: string): Promise { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? { items: page.items, total: page.total } : null; + }); +} + async function getTelegramActivitySnapshot(): Promise { const status = await getCurrentStatus(); @@ -865,31 +915,23 @@ async function getTelegramActivitySnapshot(): Promise const containerName = isJdMode ? 'sv2-jdc' : 'sv2-translator'; const port = isJdMode ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; const baseUrl = `${getContainerUrl(containerName, port)}/api/v1`; - const [global, serverChannelResponse, clientResponse] = await Promise.all([ + const [global, serverChannels, monitoringClients] = await Promise.all([ fetchMonitoringJson(`${baseUrl}/global`), - fetchMonitoringJson( - `${baseUrl}/server/channels?offset=0&limit=100` - ), + fetchAllMonitoringChannels(`${baseUrl}/server/channels`), isJdMode - ? fetchMonitoringJson(`${baseUrl}/clients?offset=0&limit=100`) + ? fetchAllMonitoringItems(`${baseUrl}/clients`) : Promise.resolve(null), ]); const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; - const serverChannels = serverChannelResponse - ? [ - ...serverChannelResponse.extended_channels, - ...serverChannelResponse.standard_channels, - ] - : null; let miningChannels: TelegramMiningChannel[] | null = null; - if (isJdMode && clientResponse) { + if (isJdMode && monitoringClients) { const downstreamResponses = await Promise.all( - clientResponse.items.map(async (client) => ({ + monitoringClients.map(async (client) => ({ clientId: client.client_id, - channels: await fetchMonitoringJson( - `${baseUrl}/clients/${client.client_id}/channels?offset=0&limit=100` + channels: await fetchAllMonitoringChannels( + `${baseUrl}/clients/${client.client_id}/channels` ), })) ); @@ -897,52 +939,40 @@ async function getTelegramActivitySnapshot(): Promise if (downstreamResponses.every((response) => response.channels !== null)) { miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { if (!channels) return []; - return [ - ...channels.extended_channels.map((channel) => ({ - key: `jdc:${clientId}:extended:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ...channels.standard_channels.map((channel) => ({ - key: `jdc:${clientId}:standard:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ]; + return channels.map(({ kind, channel }) => ({ + key: `jdc:${clientId}:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); }); } - } else if (!isJdMode && serverChannelResponse) { - miningChannels = [ - ...serverChannelResponse.extended_channels.map((channel) => ({ - key: `translator:server:extended:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ...serverChannelResponse.standard_channels.map((channel) => ({ - key: `translator:server:standard:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ]; + } else if (!isJdMode && serverChannels) { + miningChannels = serverChannels.map(({ kind, channel }) => ({ + key: `translator:server:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); } return { running: true, poolName: status.poolName, hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, - workers: clients?.total_clients ?? null, + workers: getTelegramWorkerCount( + isJdMode, + global?.sv1_clients, + global?.sv2_clients, + ), sharesSubmitted: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_submitted, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_submitted, 0) : null, sharesAccepted: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_acknowledged, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_acknowledged, 0) : null, sharesRejected: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_rejected, 0) : null, channels: miningChannels, }; diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts index 3a656477..6af2009b 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -5,7 +5,9 @@ import path from 'node:path'; import test, { type TestContext } from 'node:test'; import { + collectPaginatedMonitoringItems, formatTelegramStatus, + getTelegramWorkerCount, getStatusChangeMessage, TelegramConfigError, TelegramService, @@ -100,6 +102,52 @@ function snapshot( }; } +test('collects every monitoring page using the reported total', async () => { + const offsets: number[] = []; + const items = await collectPaginatedMonitoringItems(async (offset, limit) => { + offsets.push(offset); + const total = 205; + const pageLength = Math.min(limit, total - offset); + return { + total, + items: Array.from({ length: pageLength }, (_, index) => offset + index), + }; + }); + + assert.deepEqual(offsets, [0, 100, 200]); + assert.equal(items?.length, 205); + assert.equal(items?.at(-1), 204); +}); + +test('returns no partial monitoring snapshot when a later page fails', async () => { + const items = await collectPaginatedMonitoringItems(async (offset) => ( + offset === 0 + ? { total: 101, items: Array.from({ length: 100 }, (_, index) => index) } + : null + )); + + assert.equal(items, null); +}); + +test('counts JD channels as workers instead of downstream connections', () => { + assert.equal( + getTelegramWorkerCount( + true, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 17, + ); + assert.equal( + getTelegramWorkerCount( + false, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 4, + ); +}); + async function pairService( t: TestContext, telegram = createTelegramFetch({ getMe: [BOT] }) diff --git a/server/src/telegram.ts b/server/src/telegram.ts index bde71aa0..1583d94c 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -113,6 +113,42 @@ export type TelegramActivitySnapshot = { channels: TelegramMiningChannel[] | null; }; +type MonitoringPage = { + items: T[]; + total: number; +}; + +export async function collectPaginatedMonitoringItems( + fetchPage: (offset: number, limit: number) => Promise | null>, + pageSize = 100, +): Promise { + if (!Number.isInteger(pageSize) || pageSize <= 0) { + throw new RangeError('Monitoring page size must be a positive integer'); + } + + const items: T[] = []; + let offset = 0; + + while (true) { + const page = await fetchPage(offset, pageSize); + if (!page) return null; + + items.push(...page.items); + if (offset + pageSize >= page.total) return items; + offset += pageSize; + } +} + +export function getTelegramWorkerCount( + isJdMode: boolean, + sv1Clients: { total_clients: number } | null | undefined, + sv2Clients: { total_channels: number } | null | undefined, +): number | null { + return isJdMode + ? sv2Clients?.total_channels ?? null + : sv1Clients?.total_clients ?? null; +} + export class TelegramConfigError extends Error {} export class TelegramApiError extends Error { diff --git a/src/components/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index f274d1fc..64f16e7f 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -3,7 +3,7 @@ import { ArrowDown, ArrowUp, GripVertical, X } from 'lucide-react'; import { DEFAULT_POOL_PORT, type MiningMode, type PoolConfig } from '@sv2-ui/shared'; import { PoolIcon } from '@/components/ui/pool-icon'; import { - createEmptyCustomPool, + appendEmptyCustomPool, isSamePool, knownPoolToConfig, type KnownPool, @@ -31,7 +31,6 @@ export function PoolPriorityEditor({ const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); - const selectedCustomIndex = pools.findIndex((pool) => !getSelectedPreset(pool, presets)); const unselectedPresets = presets.filter((preset) => ( !pools.some((selectedPool) => isSamePool(selectedPool, preset)) )); @@ -55,20 +54,8 @@ export function PoolPriorityEditor({ ]); }; - const toggleCustomPool = () => { - if (selectedCustomIndex >= 0) { - onChange(pools.filter((_, index) => index !== selectedCustomIndex)); - return; - } - - onChange([ - ...pools, - withCompatiblePoolIdentity( - pools[0], - createEmptyCustomPool(), - miningMode, - ), - ]); + const addCustomPool = () => { + onChange(appendEmptyCustomPool(pools, miningMode)); }; const updatePool = (index: number, pool: PoolConfig) => { @@ -265,18 +252,15 @@ export function PoolPriorityEditor({ ); })} - {selectedCustomIndex === -1 && ( - - )} + ); } diff --git a/src/lib/pools.test.ts b/src/lib/pools.test.ts index 58fb342a..d32d7f50 100644 --- a/src/lib/pools.test.ts +++ b/src/lib/pools.test.ts @@ -1,9 +1,33 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SOLO_POOLS, knownPoolToConfig } from './pools'; +import type { PoolConfig } from '@sv2-ui/shared'; +import { + appendEmptyCustomPool, + knownPoolToConfig, + SOLO_POOLS, +} from './pools'; import { isValidPoolAuthorityPubkey } from './utils'; +const PRIMARY_POOL: PoolConfig = { + name: 'Primary Pool', + address: 'pool.example.com', + port: 3333, + authority_public_key: 'primary-key', + user_identity: 'miner.worker', +}; + +test('appendEmptyCustomPool allows multiple custom fallback pools', () => { + const withFirstCustomPool = appendEmptyCustomPool([PRIMARY_POOL], 'pool'); + const withSecondCustomPool = appendEmptyCustomPool(withFirstCustomPool, 'pool'); + + assert.equal(withSecondCustomPool.length, 3); + assert.equal(withSecondCustomPool[1].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[2].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[1].user_identity, PRIMARY_POOL.user_identity); + assert.equal(withSecondCustomPool[2].user_identity, PRIMARY_POOL.user_identity); +}); + test('solo pool presets are sorted alphabetically', () => { const names = SOLO_POOLS.map((pool) => pool.name); diff --git a/src/lib/pools.ts b/src/lib/pools.ts index 7c781e55..878abda8 100644 --- a/src/lib/pools.ts +++ b/src/lib/pools.ts @@ -1,7 +1,8 @@ /** * Shared pool preset definitions used by both the Setup Wizard and Settings. */ -import type { PoolConfig } from '@sv2-ui/shared'; +import type { MiningMode, PoolConfig } from '@sv2-ui/shared'; +import { withCompatiblePoolIdentity } from './miningIdentity'; export interface KnownPool { id: string; @@ -148,6 +149,20 @@ export function createEmptyCustomPool(userIdentity = ''): PoolConfig { }; } +export function appendEmptyCustomPool( + pools: PoolConfig[], + miningMode: MiningMode | null, +): PoolConfig[] { + return [ + ...pools, + withCompatiblePoolIdentity( + pools[0], + createEmptyCustomPool(), + miningMode, + ), + ]; +} + export function isSamePool(a: Pick | null | undefined, b: Pick | null | undefined): boolean { if (!a || !b) return false; return a.address.trim().toLowerCase() === b.address.trim().toLowerCase() && a.port === b.port; From da7d350e9825cc48f8356b36dcfcfba815ac5158 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:02:09 +0500 Subject: [PATCH 05/13] fix: distinguish pool failovers and enforce fallback limits --- server/src/index.ts | 5 +- server/src/pool-validation.ts | 1 - server/src/telegram.test.ts | 27 +++++++++- server/src/telegram.ts | 56 +++++++++++++++++---- shared/src/constants.ts | 1 + src/components/pools/PoolPriorityEditor.tsx | 10 ++-- src/lib/pools.test.ts | 17 ++++++- src/lib/pools.ts | 12 ++++- 8 files changed, 109 insertions(+), 20 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 3a5e4ebb..d34545e5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { normalizeBitcoinCoreVersion, TRANSLATOR_MONITORING_PORT, JDC_MONITORING_PORT, + MAX_FALLBACK_POOLS, } from '@sv2-ui/shared'; import { BITCOIN_ERROR_MESSAGES } from './messages.js'; import { @@ -33,7 +34,7 @@ import { } from './docker.js'; import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; -import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; +import { getPoolConfigError } from './pool-validation.js'; import { collectPaginatedMonitoringItems, getTelegramWorkerCount, @@ -902,6 +903,7 @@ async function getTelegramActivitySnapshot(): Promise return { running: false, poolName: status.poolName, + activePoolIndex: status.activePoolIndex, hashrate: null, workers: null, sharesSubmitted: null, @@ -959,6 +961,7 @@ async function getTelegramActivitySnapshot(): Promise return { running: true, poolName: status.poolName, + activePoolIndex: status.activePoolIndex, hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, workers: getTelegramWorkerCount( isJdMode, diff --git a/server/src/pool-validation.ts b/server/src/pool-validation.ts index accf3a7a..9e671460 100644 --- a/server/src/pool-validation.ts +++ b/server/src/pool-validation.ts @@ -5,7 +5,6 @@ const MAX_POOL_NAME_LENGTH = 128; const MAX_POOL_ADDRESS_LENGTH = 255; const MAX_AUTHORITY_KEY_LENGTH = 128; const MAX_IDENTITY_LENGTH = 512; -export const MAX_FALLBACK_POOLS = 16; // These characters can escape or terminate a generated TOML basic string. // eslint-disable-next-line no-control-regex diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts index 6af2009b..4cf58d2b 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -87,6 +87,7 @@ function snapshot( return { running: true, poolName: 'Primary pool', + activePoolIndex: 0, hashrate: 125_000_000_000_000, workers: 3, sharesSubmitted: 42, @@ -193,6 +194,7 @@ test('reports only meaningful status changes', () => { const stopped = snapshot({ running: false, poolName: null, + activePoolIndex: null, hashrate: null, workers: null, sharesSubmitted: null, @@ -307,16 +309,37 @@ test('detects failover across a temporary unknown pool state', async (t) => { const { service, telegram } = await pairService(t); await service.poll(async () => snapshot()); - await service.poll(async () => snapshot({ poolName: null })); + await service.poll(async () => snapshot({ + poolName: null, + activePoolIndex: null, + })); assert.equal(telegram.callsFor('sendMessage').length, 1); - await service.poll(async () => snapshot({ poolName: 'Fallback pool' })); + await service.poll(async () => snapshot({ + poolName: 'Fallback pool', + activePoolIndex: 1, + })); const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); assert.match(alert, /^🔁 Pool failover/); assert.match(alert, /From: Primary pool/); assert.match(alert, /To: Fallback pool/); }); +test('detects failover between custom pools with the same name', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot({ poolName: 'Custom Pool' })); + await service.poll(async () => snapshot({ + poolName: 'Custom Pool', + activePoolIndex: 1, + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^🔁 Pool failover/); + assert.match(alert, /From: Custom Pool \(Primary\)/); + assert.match(alert, /To: Custom Pool \(Fallback 1\)/); +}); + test('configures alerts with the bot settings keyboard', async (t) => { const { service, telegram } = await pairService(t); diff --git a/server/src/telegram.ts b/server/src/telegram.ts index 1583d94c..52627415 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -105,6 +105,7 @@ export type TelegramMiningChannel = { export type TelegramActivitySnapshot = { running: boolean; poolName: string | null; + activePoolIndex: number | null; hashrate: number | null; workers: number | null; sharesSubmitted: number | null; @@ -298,6 +299,10 @@ function formatDifficulty(difficulty: number): string { }).format(difficulty); } +function formatPoolPriority(index: number): string { + return index === 0 ? 'Primary' : `Fallback ${index}`; +} + function getBestChannel(channels: TelegramMiningChannel[] | null): TelegramMiningChannel | null { if (!channels?.length) return null; return channels.reduce((best, channel) => @@ -360,8 +365,15 @@ export function getStatusChangeMessage( if ( current.running && - previous.poolName !== current.poolName && - current.poolName !== null + current.poolName !== null && + ( + previous.poolName !== current.poolName || + ( + previous.activePoolIndex !== null && + current.activePoolIndex !== null && + previous.activePoolIndex !== current.activePoolIndex + ) + ) ) { return formatTelegramStatus(current, '🔁 SV2 active pool changed'); } @@ -505,7 +517,7 @@ export class TelegramService { private channelBaselines = new Map(); private bestDifficultyHighWatermark = 0; private lastSummaryAt: number | null = null; - private lastKnownPoolName: string | null = null; + private lastKnownPool: { name: string; index: number } | null = null; private pollInProgress = false; constructor( @@ -742,7 +754,7 @@ export class TelegramService { if (!this.previousSnapshot) { this.previousSnapshot = current; this.updateChannelBaselines(current.channels); - this.lastKnownPoolName = current.poolName; + this.updateLastKnownPool(current); this.lastSummaryAt = now; return; } @@ -769,17 +781,30 @@ export class TelegramService { if (bestDifficultyMessage) messages.push(bestDifficultyMessage); } + const currentPool = current.poolName !== null && current.activePoolIndex !== null + ? { name: current.poolName, index: current.activePoolIndex } + : null; if ( settings.notifyOnPoolChange && current.running && - this.lastKnownPoolName && - current.poolName && - this.lastKnownPoolName !== current.poolName + this.lastKnownPool && + currentPool && + ( + this.lastKnownPool.index !== currentPool.index || + this.lastKnownPool.name !== currentPool.name + ) ) { + const duplicateName = this.lastKnownPool.name === currentPool.name; + const previousLabel = duplicateName + ? `${this.lastKnownPool.name} (${formatPoolPriority(this.lastKnownPool.index)})` + : this.lastKnownPool.name; + const currentLabel = duplicateName + ? `${currentPool.name} (${formatPoolPriority(currentPool.index)})` + : currentPool.name; messages.push([ '🔁 Pool failover', - `From: ${this.lastKnownPoolName}`, - `To: ${current.poolName}`, + `From: ${previousLabel}`, + `To: ${currentLabel}`, ].join('\n')); } @@ -835,7 +860,7 @@ export class TelegramService { ? { ...current, channels: this.previousSnapshot.channels } : current; this.updateChannelBaselines(current.channels); - if (current.poolName) this.lastKnownPoolName = current.poolName; + this.updateLastKnownPool(current); } finally { this.pollInProgress = false; } @@ -994,7 +1019,16 @@ export class TelegramService { this.channelBaselines.clear(); this.bestDifficultyHighWatermark = 0; this.lastSummaryAt = null; - this.lastKnownPoolName = null; + this.lastKnownPool = null; + } + + private updateLastKnownPool(snapshot: TelegramActivitySnapshot): void { + if (snapshot.poolName !== null && snapshot.activePoolIndex !== null) { + this.lastKnownPool = { + name: snapshot.poolName, + index: snapshot.activePoolIndex, + }; + } } private updateChannelBaselines(channels: TelegramMiningChannel[] | null): void { diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 7247dadc..ffabaec1 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -40,6 +40,7 @@ export const JDC_MONITORING_PORT = 9091; export const DEFAULT_SHARES_PER_MINUTE = 6; export const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4; export const DEFAULT_POOL_PORT = 34254; +export const MAX_FALLBACK_POOLS = 16; export function computeDefaultSocketPath(dataDir: string, network: BitcoinNetwork): string { return network === 'mainnet' ? `${dataDir}/node.sock` : `${dataDir}/${network}/node.sock`; diff --git a/src/components/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index 64f16e7f..62f29235 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -4,6 +4,7 @@ import { DEFAULT_POOL_PORT, type MiningMode, type PoolConfig } from '@sv2-ui/sha import { PoolIcon } from '@/components/ui/pool-icon'; import { appendEmptyCustomPool, + canAddPool, isSamePool, knownPoolToConfig, type KnownPool, @@ -34,6 +35,7 @@ export function PoolPriorityEditor({ const unselectedPresets = presets.filter((preset) => ( !pools.some((selectedPool) => isSamePool(selectedPool, preset)) )); + const poolLimitReached = !canAddPool(pools); const togglePreset = (preset: KnownPool) => { const selectedIndex = pools.findIndex((pool) => isSamePool(pool, preset)); @@ -42,7 +44,7 @@ export function PoolPriorityEditor({ return; } - if (preset.badge === 'coming-soon') return; + if (preset.badge === 'coming-soon' || poolLimitReached) return; onChange([ ...pools, @@ -205,7 +207,7 @@ export function PoolPriorityEditor({ })} {unselectedPresets.map((preset) => { - const isDisabled = preset.badge === 'coming-soon'; + const isDisabled = preset.badge === 'coming-soon' || poolLimitReached; return ( + ) : ( + + )} + + + + + {run && ( + + +
+
+ + {isActive ? 'Benchmark in progress' : 'Benchmark results'} + + + {run.status === 'stopping' + ? 'Stopping and restoring the original pool configuration...' + : run.status === 'completed' + ? 'Ranked by average latency. The original pool order has been restored.' + : run.status === 'cancelled' + ? 'The run was stopped and the original pool order was restored.' + : run.status === 'failed' + ? run.error ?? 'The benchmark could not be completed.' + : currentResult?.status === 'connecting' + ? `Connecting to ${currentResult.pool.name} and verifying SV2 negotiation...` + : currentResult + ? `Measuring ${currentResult.pool.name}` + : 'Preparing the next pool...'} + +
+ {isTerminal && rankedResults.some((result) => result.status === 'completed') && ( +
+ + Lowest average latency ranks first +
+ )} +
+ {isActive && currentEndsAt !== null && ( +
+
+ + Pool {(run.currentPoolIndex ?? 0) + 1} of {run.selectedPools.length} + + {formatDuration(remainingSeconds ?? 0)} remaining +
+
+
+
+
+ )} + + + + + + Rank + Pool + Status + Avg latency + Samples + Accepted + Rejected + Action + + + + {rankedResults.map((result, index) => { + const knownPool = getKnownPoolForConfig(result.pool); + const badge = resultBadge(result.status); + const key = endpointKey(result.pool); + const hasRank = isTerminal && result.averageLatencyMs !== null; + + return ( + + + {hasRank ? index + 1 : '—'} + + +
+ +
+
{result.pool.name}
+
+ {result.pool.address}:{result.pool.port} +
+
+
+
+ + {badge.label} + {result.error && ( +
+ {result.error} +
+ )} +
+ +
{formatLatency(result.averageLatencyMs)}
+ {result.averageLatencyMs !== null && ( +
+ min {formatLatency(result.minLatencyMs)} · p95 {formatLatency(result.p95LatencyMs)} +
+ )} +
+ + {result.successfulSamples}/{result.attemptedSamples} + + + {result.acceptedShares?.toLocaleString() ?? '—'} + + + {result.rejectedShares?.toLocaleString() ?? '—'} + + + {isTerminal && result.status === 'completed' ? ( + + ) : ( + + )} + +
+ ); + })} +
+
+

+ Accepted means acknowledged by the upstream pool. Average, minimum, and p95 are + TCP connection samples from the SV2 UI backend; they are not share-ack round-trip times. +

+
+ + )} + + )} +
+ + ); +} From 069a5ccf0072c2ee2e9d980ce7bdc093c81dade5 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:41:04 +0500 Subject: [PATCH 07/13] Use average latency only for benchmarks Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/benchmark.test.ts | 6 +----- server/src/benchmark.ts | 10 ---------- shared/src/types.ts | 2 -- src/pages/Benchmark.tsx | 10 +++------- 4 files changed, 4 insertions(+), 24 deletions(-) diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts index 6fa7a12f..080dcbfb 100644 --- a/server/src/benchmark.test.ts +++ b/server/src/benchmark.test.ts @@ -42,16 +42,12 @@ const SETUP: SetupData = { }, }; -test('summarizes latency with average as the primary score and rank-based p95', () => { +test('summarizes latency using the arithmetic average', () => { assert.deepEqual(summarizeLatencySamples([10, 12, 11, 50]), { averageLatencyMs: 20.8, - minLatencyMs: 10, - p95LatencyMs: 50, }); assert.deepEqual(summarizeLatencySamples([]), { averageLatencyMs: null, - minLatencyMs: null, - p95LatencyMs: null, }); }); diff --git a/server/src/benchmark.ts b/server/src/benchmark.ts index 19a20330..377fc4a3 100644 --- a/server/src/benchmark.ts +++ b/server/src/benchmark.ts @@ -35,8 +35,6 @@ export type BenchmarkDependencies = { type LatencySummary = { averageLatencyMs: number | null; - minLatencyMs: number | null; - p95LatencyMs: number | null; }; const DEFAULT_SAMPLE_INTERVAL_MS = 5_000; @@ -82,19 +80,13 @@ export function summarizeLatencySamples(samples: number[]): LatencySummary { if (samples.length === 0) { return { averageLatencyMs: null, - minLatencyMs: null, - p95LatencyMs: null, }; } - const sorted = [...samples].sort((a, b) => a - b); const average = samples.reduce((sum, sample) => sum + sample, 0) / samples.length; - const p95Index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1); return { averageLatencyMs: Math.round(average * 10) / 10, - minLatencyMs: Math.round(sorted[0] * 10) / 10, - p95LatencyMs: Math.round(sorted[p95Index] * 10) / 10, }; } @@ -218,8 +210,6 @@ export class BenchmarkManager { startedAt: null, completedAt: null, averageLatencyMs: null, - minLatencyMs: null, - p95LatencyMs: null, successfulSamples: 0, attemptedSamples: 0, acceptedShares: null, diff --git a/shared/src/types.ts b/shared/src/types.ts index 09da25f4..6513e1a0 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -69,8 +69,6 @@ export interface BenchmarkPoolResult { startedAt: string | null; completedAt: string | null; averageLatencyMs: number | null; - minLatencyMs: number | null; - p95LatencyMs: number | null; successfulSamples: number; attemptedSamples: number; acceptedShares: number | null; diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx index a96f02f1..1f8a85ca 100644 --- a/src/pages/Benchmark.tsx +++ b/src/pages/Benchmark.tsx @@ -444,11 +444,6 @@ export function Benchmark() {
{formatLatency(result.averageLatencyMs)}
- {result.averageLatencyMs !== null && ( -
- min {formatLatency(result.minLatencyMs)} · p95 {formatLatency(result.p95LatencyMs)} -
- )}
{result.successfulSamples}/{result.attemptedSamples} @@ -483,8 +478,9 @@ export function Benchmark() {

- Accepted means acknowledged by the upstream pool. Average, minimum, and p95 are - TCP connection samples from the SV2 UI backend; they are not share-ack round-trip times. + Accepted means acknowledged by the upstream pool. Average latency is calculated + from TCP connection samples taken by the SV2 UI backend; it is not a share-ack + round-trip time.

From ea981dda23d62aeeaf807d4f926488fc64f25525 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:51:17 +0500 Subject: [PATCH 08/13] Stream benchmark share totals during runs Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/benchmark.test.ts | 57 +++++++++++++++++++++++++++++++----- server/src/benchmark.ts | 29 ++++++++++++++---- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts index 080dcbfb..3441f448 100644 --- a/server/src/benchmark.test.ts +++ b/server/src/benchmark.test.ts @@ -80,12 +80,7 @@ test('rotates the existing ordered fallback list without mutating the saved setu test('runs each pool, records measurements, and restores the original setup', async () => { const applied: SetupData[] = []; - const counters = [ - { accepted: 10, rejected: 1 }, - { accepted: 13, rejected: 2 }, - { accepted: 20, rejected: 4 }, - { accepted: 22, rejected: 4 }, - ]; + const counterReads = new Map(); let settled = 0; const manager = new BenchmarkManager({ @@ -93,7 +88,21 @@ test('runs each pool, records measurements, and restores the original setup', as applied.push(structuredClone(data)); }, getActivePool: async () => ({ index: 0 }), - readShareCounters: async () => counters.shift() ?? { accepted: 0, rejected: 0 }, + readShareCounters: async () => { + const poolName = applied.at(-1)?.pool?.name ?? ''; + const readCount = (counterReads.get(poolName) ?? 0) + 1; + counterReads.set(poolName, readCount); + + if (poolName === 'Alpha') { + return readCount === 1 + ? { accepted: 10, rejected: 1 } + : { accepted: 13, rejected: 2 }; + } + + return readCount === 1 + ? { accepted: 20, rejected: 4 } + : { accepted: 22, rejected: 4 }; + }, measureLatency: async (pool) => pool.name === 'Alpha' ? 10 : 20, sampleIntervalMs: 1, activePoolPollIntervalMs: 1, @@ -120,6 +129,40 @@ test('runs each pool, records measurements, and restores the original setup', as assert.equal(settled, 1); }); +test('publishes accepted and rejected share deltas while a pool is still running', async () => { + let counterRead = 0; + const manager = new BenchmarkManager({ + applyConfiguration: async () => {}, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => { + counterRead += 1; + return counterRead === 1 + ? { accepted: 10, rejected: 1 } + : { accepted: 12, rejected: 2 }; + }, + measureLatency: async () => 10, + sampleIntervalMs: 1_000, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, POOLS, 1); + + let liveRun = manager.getSnapshot(); + for (let attempt = 0; attempt < 100 && liveRun?.results[0].acceptedShares !== 2; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)); + liveRun = manager.getSnapshot(); + } + + assert.equal(liveRun?.status, 'running'); + assert.equal(liveRun?.results[0].status, 'running'); + assert.equal(liveRun?.results[0].acceptedShares, 2); + assert.equal(liveRun?.results[0].rejectedShares, 1); + + assert.equal(manager.stop(), true); + await manager.waitForCompletion(); +}); + test('stopping a run cancels unfinished rows and still restores the setup', async () => { const applied: SetupData[] = []; const manager = new BenchmarkManager({ diff --git a/server/src/benchmark.ts b/server/src/benchmark.ts index 377fc4a3..1aeacddd 100644 --- a/server/src/benchmark.ts +++ b/server/src/benchmark.ts @@ -385,7 +385,27 @@ export class BenchmarkManager { this.run.currentPoolStartedAt = new Date(startedAtMs).toISOString(); this.run.currentPoolEndsAt = new Date(endsAtMs).toISOString(); - const beforeCounters = await this.tryReadShareCounters(mode); + let baselineCounters = await this.tryReadShareCounters(mode); + if (baselineCounters) { + result.acceptedShares = 0; + result.rejectedShares = 0; + } + + const refreshShareCounters = async () => { + const counters = await this.tryReadShareCounters(mode); + if (!counters) return; + + if (!baselineCounters) { + baselineCounters = counters; + result.acceptedShares = 0; + result.rejectedShares = 0; + return; + } + + const shareDelta = getCounterDelta(baselineCounters, counters); + result.acceptedShares = shareDelta?.accepted ?? null; + result.rejectedShares = shareDelta?.rejected ?? null; + }; while (Date.now() < endsAtMs) { if (signal.aborted) throw abortError(); @@ -409,16 +429,15 @@ export class BenchmarkManager { lastSampleError = errorMessage(error); } + await refreshShareCounters(); + const remainingMs = endsAtMs - Date.now(); if (remainingMs > 0) { await sleep(Math.min(sampleIntervalMs, remainingMs), signal); } } - const afterCounters = await this.tryReadShareCounters(mode); - const shareDelta = getCounterDelta(beforeCounters, afterCounters); - result.acceptedShares = shareDelta?.accepted ?? null; - result.rejectedShares = shareDelta?.rejected ?? null; + await refreshShareCounters(); result.completedAt = new Date().toISOString(); if (samples.length === 0) { From edd07ea53e69eb59f3149a8f207582b666881700 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:53:03 +0500 Subject: [PATCH 09/13] Make pool benchmark results resilient Cap and spread latency sampling, reject partial-success rankings, retain per-channel share totals across reconnects, and clarify the benchmark's limits. Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/benchmark.test.ts | 94 ++++++++++++++++------- server/src/benchmark.ts | 142 ++++++++++++++++++++++++----------- server/src/index.ts | 60 ++++++++++----- src/pages/Benchmark.tsx | 46 +++++++++--- 4 files changed, 244 insertions(+), 98 deletions(-) diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts index 3441f448..33a995c6 100644 --- a/server/src/benchmark.test.ts +++ b/server/src/benchmark.test.ts @@ -4,8 +4,8 @@ import test from 'node:test'; import type { PoolConfig, SetupData } from '@sv2-ui/shared'; import { BenchmarkManager, - getCounterDelta, rotatePoolsForBenchmark, + ShareCounterAccumulator, summarizeLatencySamples, } from './benchmark.js'; @@ -51,22 +51,28 @@ test('summarizes latency using the arithmetic average', () => { }); }); -test('computes non-negative share counter deltas', () => { - assert.deepEqual( - getCounterDelta( - { accepted: 10, rejected: 2 }, - { accepted: 14, rejected: 3 } - ), - { accepted: 4, rejected: 1 } - ); - assert.deepEqual( - getCounterDelta( - { accepted: 10, rejected: 2 }, - { accepted: 1, rejected: 0 } - ), - { accepted: 0, rejected: 0 } - ); - assert.equal(getCounterDelta(null, { accepted: 1, rejected: 0 }), null); +test('accumulates shares when channels disconnect, reconnect, or reset', () => { + const accumulator = new ShareCounterAccumulator(); + + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 10, rejected: 1 }, + { key: 'extended:2', accepted: 5, rejected: 0 }, + ]), { accepted: 0, rejected: 0 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 12, rejected: 2 }, + { key: 'extended:2', accepted: 6, rejected: 0 }, + ]), { accepted: 3, rejected: 1 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 13, rejected: 2 }, + ]), { accepted: 4, rejected: 1 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 14, rejected: 2 }, + { key: 'extended:3', accepted: 2, rejected: 1 }, + ]), { accepted: 7, rejected: 2 }); + assert.deepEqual(accumulator.update([ + { key: 'extended:1', accepted: 1, rejected: 0 }, + { key: 'extended:3', accepted: 3, rejected: 1 }, + ]), { accepted: 9, rejected: 2 }); }); test('rotates the existing ordered fallback list without mutating the saved setup', () => { @@ -95,16 +101,17 @@ test('runs each pool, records measurements, and restores the original setup', as if (poolName === 'Alpha') { return readCount === 1 - ? { accepted: 10, rejected: 1 } - : { accepted: 13, rejected: 2 }; + ? [{ key: 'extended:1', accepted: 10, rejected: 1 }] + : [{ key: 'extended:1', accepted: 13, rejected: 2 }]; } return readCount === 1 - ? { accepted: 20, rejected: 4 } - : { accepted: 22, rejected: 4 }; + ? [{ key: 'extended:1', accepted: 20, rejected: 4 }] + : [{ key: 'extended:1', accepted: 22, rejected: 4 }]; }, measureLatency: async (pool) => pool.name === 'Alpha' ? 10 : 20, sampleIntervalMs: 1, + maxLatencySamples: 2, activePoolPollIntervalMs: 1, activePoolTimeoutMs: 20, onSettled: () => { @@ -137,11 +144,12 @@ test('publishes accepted and rejected share deltas while a pool is still running readShareCounters: async () => { counterRead += 1; return counterRead === 1 - ? { accepted: 10, rejected: 1 } - : { accepted: 12, rejected: 2 }; + ? [{ key: 'extended:1', accepted: 10, rejected: 1 }] + : [{ key: 'extended:1', accepted: 12, rejected: 2 }]; }, measureLatency: async () => 10, - sampleIntervalMs: 1_000, + sampleIntervalMs: 1, + maxLatencySamples: 2, activePoolPollIntervalMs: 1, activePoolTimeoutMs: 20, }); @@ -163,6 +171,38 @@ test('publishes accepted and rejected share deltas while a pool is still running await manager.waitForCompletion(); }); +test('spreads latency attempts across the interval and does not rank partial success', async () => { + const attemptTimes: number[] = []; + const manager = new BenchmarkManager({ + applyConfiguration: async () => {}, + getActivePool: async () => ({ index: 0 }), + readShareCounters: async () => [], + measureLatency: async () => { + attemptTimes.push(Date.now()); + if (attemptTimes.length === 2) { + throw new Error('connection refused'); + } + return 10; + }, + sampleIntervalMs: 5, + maxLatencySamples: 3, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, [POOLS[0]], 0.06); + await manager.waitForCompletion(); + + const result = manager.getSnapshot()?.results[0]; + assert.equal(result?.status, 'failed'); + assert.equal(result?.attemptedSamples, 3); + assert.equal(result?.successfulSamples, 2); + assert.equal(result?.averageLatencyMs, null); + assert.match(result?.error ?? '', /no latency rank was assigned/i); + assert.equal(attemptTimes.length, 3); + assert.ok(attemptTimes[2] - attemptTimes[0] >= 30); +}); + test('stopping a run cancels unfinished rows and still restores the setup', async () => { const applied: SetupData[] = []; const manager = new BenchmarkManager({ @@ -170,9 +210,10 @@ test('stopping a run cancels unfinished rows and still restores the setup', asyn applied.push(structuredClone(data)); }, getActivePool: async () => ({ index: 0 }), - readShareCounters: async () => ({ accepted: 0, rejected: 0 }), + readShareCounters: async () => [], measureLatency: async () => 10, sampleIntervalMs: 5, + maxLatencySamples: 2, activePoolPollIntervalMs: 1, activePoolTimeoutMs: 20, }); @@ -192,8 +233,9 @@ test('marks the intended pool failed when the fallback becomes active', async () const manager = new BenchmarkManager({ applyConfiguration: async () => {}, getActivePool: async () => ({ index: 1 }), - readShareCounters: async () => ({ accepted: 0, rejected: 0 }), + readShareCounters: async () => [], sampleIntervalMs: 1, + maxLatencySamples: 2, activePoolPollIntervalMs: 1, activePoolTimeoutMs: 20, }); diff --git a/server/src/benchmark.ts b/server/src/benchmark.ts index 1aeacddd..fedc7c9a 100644 --- a/server/src/benchmark.ts +++ b/server/src/benchmark.ts @@ -15,6 +15,10 @@ export type ShareCounters = { rejected: number; }; +export type ShareChannelCounters = ShareCounters & { + key: string; +}; + export type ActivePoolSelection = { index: number; }; @@ -25,10 +29,11 @@ export type BenchmarkDependencies = { mode: SetupMode, pools: PoolConfig[] ) => Promise; - readShareCounters: (mode: SetupMode) => Promise; + readShareCounters: (mode: SetupMode) => Promise; measureLatency?: (pool: PoolConfig, signal: AbortSignal) => Promise; onSettled?: () => void; sampleIntervalMs?: number; + maxLatencySamples?: number; activePoolTimeoutMs?: number; activePoolPollIntervalMs?: number; }; @@ -38,6 +43,7 @@ type LatencySummary = { }; const DEFAULT_SAMPLE_INTERVAL_MS = 5_000; +const DEFAULT_MAX_LATENCY_SAMPLES = 10; const DEFAULT_ACTIVE_POOL_TIMEOUT_MS = 60_000; const DEFAULT_ACTIVE_POOL_POLL_INTERVAL_MS = 1_000; const TCP_CONNECT_TIMEOUT_MS = 5_000; @@ -90,16 +96,37 @@ export function summarizeLatencySamples(samples: number[]): LatencySummary { }; } -export function getCounterDelta( - before: ShareCounters | null, - after: ShareCounters | null -): ShareCounters | null { - if (!before || !after) return null; +function counterIncrement(before: number, after: number): number { + return after >= before ? after - before : after; +} - return { - accepted: Math.max(0, after.accepted - before.accepted), - rejected: Math.max(0, after.rejected - before.rejected), - }; +export class ShareCounterAccumulator { + private readonly previous = new Map(); + private readonly totals: ShareCounters = { accepted: 0, rejected: 0 }; + private initialized = false; + + update(channels: ShareChannelCounters[]): ShareCounters { + for (const channel of channels) { + const before = this.previous.get(channel.key); + + if (this.initialized) { + this.totals.accepted += before + ? counterIncrement(before.accepted, channel.accepted) + : channel.accepted; + this.totals.rejected += before + ? counterIncrement(before.rejected, channel.rejected) + : channel.rejected; + } + + this.previous.set(channel.key, { + accepted: channel.accepted, + rejected: channel.rejected, + }); + } + + this.initialized = true; + return { ...this.totals }; + } } export function rotatePoolsForBenchmark( @@ -375,38 +402,36 @@ export class BenchmarkManager { if (!this.run || !mode) throw new Error('Mining mode is not configured'); const sampleIntervalMs = this.dependencies.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS; + const maxLatencySamples = Math.max( + 1, + Math.floor(this.dependencies.maxLatencySamples ?? DEFAULT_MAX_LATENCY_SAMPLES) + ); const samples: number[] = []; const measureLatency = this.dependencies.measureLatency ?? measureTcpConnectLatency; const startedAtMs = Date.now(); const endsAtMs = startedAtMs + poolDurationSeconds * 1_000; + const latencySampleIntervalMs = (endsAtMs - startedAtMs) / maxLatencySamples; let lastSampleError: string | null = null; + let nextLatencySampleAtMs = startedAtMs; + let nextShareRefreshAtMs = startedAtMs + sampleIntervalMs; result.status = 'running'; this.run.currentPoolStartedAt = new Date(startedAtMs).toISOString(); this.run.currentPoolEndsAt = new Date(endsAtMs).toISOString(); - let baselineCounters = await this.tryReadShareCounters(mode); - if (baselineCounters) { - result.acceptedShares = 0; - result.rejectedShares = 0; - } + const shareCounters = new ShareCounterAccumulator(); const refreshShareCounters = async () => { - const counters = await this.tryReadShareCounters(mode); - if (!counters) return; - - if (!baselineCounters) { - baselineCounters = counters; - result.acceptedShares = 0; - result.rejectedShares = 0; - return; - } + const snapshot = await this.tryReadShareCounters(mode); + if (!snapshot) return; - const shareDelta = getCounterDelta(baselineCounters, counters); - result.acceptedShares = shareDelta?.accepted ?? null; - result.rejectedShares = shareDelta?.rejected ?? null; + const totals = shareCounters.update(snapshot); + result.acceptedShares = totals.accepted; + result.rejectedShares = totals.rejected; }; + await refreshShareCounters(); + while (Date.now() < endsAtMs) { if (signal.aborted) throw abortError(); @@ -418,40 +443,67 @@ export class BenchmarkManager { ); } - result.attemptedSamples += 1; - try { - const latency = await measureLatency(result.pool, signal); - samples.push(latency); - result.successfulSamples = samples.length; - Object.assign(result, summarizeLatencySamples(samples)); - } catch (error) { - if (isAbortError(error)) throw error; - lastSampleError = errorMessage(error); + if ( + result.attemptedSamples < maxLatencySamples && + Date.now() >= nextLatencySampleAtMs + ) { + result.attemptedSamples += 1; + try { + const latency = await measureLatency(result.pool, signal); + samples.push(latency); + result.successfulSamples = samples.length; + Object.assign(result, summarizeLatencySamples(samples)); + } catch (error) { + if (isAbortError(error)) throw error; + lastSampleError = errorMessage(error); + } + nextLatencySampleAtMs = + startedAtMs + result.attemptedSamples * latencySampleIntervalMs; } - await refreshShareCounters(); + if (Date.now() >= nextShareRefreshAtMs) { + await refreshShareCounters(); + nextShareRefreshAtMs = Date.now() + sampleIntervalMs; + } - const remainingMs = endsAtMs - Date.now(); - if (remainingMs > 0) { - await sleep(Math.min(sampleIntervalMs, remainingMs), signal); + const wakeAtMs = Math.min( + endsAtMs, + nextShareRefreshAtMs, + result.attemptedSamples < maxLatencySamples + ? nextLatencySampleAtMs + : endsAtMs + ); + const sleepMs = wakeAtMs - Date.now(); + if (sleepMs > 0) { + await sleep(sleepMs, signal); } } await refreshShareCounters(); result.completedAt = new Date().toISOString(); - if (samples.length === 0) { + if (result.attemptedSamples < maxLatencySamples) { + result.averageLatencyMs = null; + result.status = 'failed'; + result.error = + `Only ${result.attemptedSamples} of ${maxLatencySamples} TCP latency samples completed`; + return; + } + + if (result.successfulSamples < result.attemptedSamples) { + const failedSamples = result.attemptedSamples - result.successfulSamples; + result.averageLatencyMs = null; result.status = 'failed'; - result.error = lastSampleError - ? `No successful TCP latency samples: ${lastSampleError}` - : 'No successful TCP latency samples'; + result.error = + `${failedSamples} of ${result.attemptedSamples} TCP latency samples failed; ` + + `no latency rank was assigned${lastSampleError ? `: ${lastSampleError}` : ''}`; return; } result.status = 'completed'; } - private async tryReadShareCounters(mode: SetupMode): Promise { + private async tryReadShareCounters(mode: SetupMode): Promise { try { return await this.dependencies.readShareCounters(mode); } catch { diff --git a/server/src/index.ts b/server/src/index.ts index ff1050cc..ffd956dd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,7 +35,7 @@ import { } from './docker.js'; import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; -import { BenchmarkManager, type ShareCounters } from './benchmark.js'; +import { BenchmarkManager, type ShareChannelCounters } from './benchmark.js'; import { getPoolConfigError } from './pool-validation.js'; import { collectPaginatedMonitoringItems, @@ -201,37 +201,61 @@ async function getBenchmarkActivePool(mode: SetupMode, pools: PoolConfig[]) { } type BenchmarkMonitoringServerChannel = { + channel_id: number; shares_acknowledged?: number; shares_rejected?: number; }; type BenchmarkMonitoringServerChannelsResponse = { + total_extended?: number; + total_standard?: number; extended_channels?: BenchmarkMonitoringServerChannel[]; standard_channels?: BenchmarkMonitoringServerChannel[]; }; -async function readBenchmarkShareCounters(mode: SetupMode): Promise { +async function readBenchmarkShareCounters(mode: SetupMode): Promise { const containerName = mode === 'jd' ? 'sv2-jdc' : 'sv2-translator'; const port = mode === 'jd' ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; - const response = await fetch( - `${getContainerUrl(containerName, port)}/api/v1/server/channels?offset=0&limit=100`, - { signal: AbortSignal.timeout(5_000) } - ); + const channels: ShareChannelCounters[] = []; + const limit = 100; + let offset = 0; + let totalChannels = 0; + + do { + const response = await fetch( + `${getContainerUrl(containerName, port)}/api/v1/server/channels?offset=${offset}&limit=${limit}`, + { signal: AbortSignal.timeout(5_000) } + ); - if (!response.ok) { - throw new Error(`Monitoring API returned HTTP ${response.status}`); - } + if (!response.ok) { + throw new Error(`Monitoring API returned HTTP ${response.status}`); + } + + const data = await response.json() as BenchmarkMonitoringServerChannelsResponse; + const extendedChannels = data.extended_channels ?? []; + const standardChannels = data.standard_channels ?? []; - const data = await response.json() as BenchmarkMonitoringServerChannelsResponse; - const channels = [ - ...(data.extended_channels ?? []), - ...(data.standard_channels ?? []), - ]; + channels.push( + ...extendedChannels.map((channel) => ({ + key: `extended:${channel.channel_id}`, + accepted: channel.shares_acknowledged ?? 0, + rejected: channel.shares_rejected ?? 0, + })), + ...standardChannels.map((channel) => ({ + key: `standard:${channel.channel_id}`, + accepted: channel.shares_acknowledged ?? 0, + rejected: channel.shares_rejected ?? 0, + })) + ); + + totalChannels = Math.max( + data.total_extended ?? extendedChannels.length, + data.total_standard ?? standardChannels.length + ); + offset += limit; + } while (offset < totalChannels); - return channels.reduce((total, channel) => ({ - accepted: total.accepted + (channel.shares_acknowledged ?? 0), - rejected: total.rejected + (channel.shares_rejected ?? 0), - }), { accepted: 0, rejected: 0 }); + return channels; } function configuredPools(data: SetupData): PoolConfig[] { diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx index 1f8a85ca..eedc2465 100644 --- a/src/pages/Benchmark.tsx +++ b/src/pages/Benchmark.tsx @@ -50,6 +50,22 @@ function formatLatency(value: number | null): string { return value === null ? '—' : `${value.toFixed(1)} ms`; } +function formatRejectedRate(result: BenchmarkPoolResult): string | null { + if (result.acceptedShares === null || result.rejectedShares === null) return null; + const total = result.acceptedShares + result.rejectedShares; + if (total === 0) return '0.00%'; + return `${((result.rejectedShares / total) * 100).toFixed(2)}%`; +} + +function isRankable(result: BenchmarkPoolResult): boolean { + return ( + result.status === 'completed' && + result.averageLatencyMs !== null && + result.attemptedSamples > 0 && + result.successfulSamples === result.attemptedSamples + ); +} + function resultBadge(status: BenchmarkPoolStatus): { label: string; variant: BadgeProps['variant']; @@ -155,6 +171,8 @@ export function Benchmark() { if (!isTerminal) return run.results; return [...run.results].sort((left, right) => { + if (isRankable(left) && !isRankable(right)) return -1; + if (!isRankable(left) && isRankable(right)) return 1; if (left.averageLatencyMs === null && right.averageLatencyMs === null) return 0; if (left.averageLatencyMs === null) return 1; if (right.averageLatencyMs === null) return -1; @@ -213,8 +231,13 @@ export function Benchmark() {

Pool Benchmark

- Compare pools from this machine using average TCP connection latency and - the shares acknowledged or rejected during equal mining intervals. + Compare what works best for this setup using average TCP connection latency + and upstream share outcomes observed during equal mining intervals. +

+

+ Mining benchmarks have intrinsic variance. Latency and rejected-share rate + are the most useful signals here, but they are time-sensitive and say + nothing about a pool's internal performance.

@@ -399,7 +422,7 @@ export function Benchmark() { Avg latency Samples Accepted - Rejected + Rejected (rate) Action @@ -408,7 +431,8 @@ export function Benchmark() { const knownPool = getKnownPoolForConfig(result.pool); const badge = resultBadge(result.status); const key = endpointKey(result.pool); - const hasRank = isTerminal && result.averageLatencyMs !== null; + const hasRank = isTerminal && isRankable(result); + const rejectedRate = formatRejectedRate(result); return ( @@ -452,10 +476,13 @@ export function Benchmark() { {result.acceptedShares?.toLocaleString() ?? '—'} - {result.rejectedShares?.toLocaleString() ?? '—'} +
{result.rejectedShares?.toLocaleString() ?? '—'}
+ {rejectedRate && ( +
{rejectedRate}
+ )}
- {isTerminal && result.status === 'completed' ? ( + {isTerminal && isRankable(result) ? ( + + + { + assert.deepEqual(normalizeExperimentalFeatures(undefined), { benchmark: false }); + assert.deepEqual(normalizeExperimentalFeatures({}), { benchmark: false }); +}); + +test('benchmark is enabled only by an explicit boolean', () => { + assert.deepEqual(normalizeExperimentalFeatures({ benchmark: true }), { benchmark: true }); + assert.deepEqual(normalizeExperimentalFeatures({ benchmark: 'true' }), { benchmark: false }); +}); diff --git a/src/hooks/useExperimentalFeatures.ts b/src/hooks/useExperimentalFeatures.ts new file mode 100644 index 00000000..52c5bd44 --- /dev/null +++ b/src/hooks/useExperimentalFeatures.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react'; + +export interface ExperimentalFeatures { + benchmark: boolean; +} + +const STORAGE_KEY = 'sv2-ui-experimental-features'; +const CHANGE_EVENT = 'sv2-ui-experimental-features-change'; +const DEFAULT_FEATURES: ExperimentalFeatures = { + benchmark: false, +}; + +export function normalizeExperimentalFeatures(value: unknown): ExperimentalFeatures { + if (!value || typeof value !== 'object') return DEFAULT_FEATURES; + + return { + benchmark: (value as Partial).benchmark === true, + }; +} + +function readExperimentalFeatures(): ExperimentalFeatures { + if (typeof window === 'undefined') return DEFAULT_FEATURES; + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored ? normalizeExperimentalFeatures(JSON.parse(stored)) : DEFAULT_FEATURES; + } catch { + return DEFAULT_FEATURES; + } +} + +function storeExperimentalFeatures(features: ExperimentalFeatures): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(features)); + } catch { + // Keep the in-memory state usable if browser storage is unavailable. + } +} + +export function useExperimentalFeatures() { + const [features, setFeatures] = useState(readExperimentalFeatures); + + useEffect(() => { + const syncFeatures = () => setFeatures(readExperimentalFeatures()); + + window.addEventListener('storage', syncFeatures); + window.addEventListener(CHANGE_EVENT, syncFeatures); + + return () => { + window.removeEventListener('storage', syncFeatures); + window.removeEventListener(CHANGE_EVENT, syncFeatures); + }; + }, []); + + const setFeature = useCallback( + (feature: keyof ExperimentalFeatures, enabled: boolean) => { + const nextFeatures = { + ...readExperimentalFeatures(), + [feature]: enabled, + }; + + storeExperimentalFeatures(nextFeatures); + setFeatures(nextFeatures); + window.dispatchEvent(new Event(CHANGE_EVENT)); + }, + [], + ); + + return { features, setFeature }; +} diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx index eedc2465..fed0dbfa 100644 --- a/src/pages/Benchmark.tsx +++ b/src/pages/Benchmark.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation } from 'wouter'; import { AlertTriangle, - Gauge, Loader2, Play, Square, @@ -226,10 +225,7 @@ export function Benchmark() { >
-
- -

Pool Benchmark

-
+

Pool Benchmark

Compare what works best for this setup using average TCP connection latency and upstream share outcomes observed during equal mining intervals. diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6e5aee4a..1febe32a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -20,12 +20,21 @@ import { ExperimentalTab } from '@/components/settings/ExperimentalTab'; /** * Settings page with Configuration, Logs, Appearance, and Experimental tabs. */ +function getInitialTab(): string { + if (typeof window === 'undefined') return 'configuration'; + + const requestedTab = new URLSearchParams(window.location.search).get('tab'); + return ['configuration', 'logs', 'appearance', 'experimental'].includes(requestedTab ?? '') + ? requestedTab! + : 'configuration'; +} + export function Settings() { const { config, updateConfig, resetConfig } = useUiConfig(); const { status: connectionStatus, statusLabel: connectionLabel, poolName, uptime } = useConnectionStatus(); const { mode } = useSetupStatus(); const isJdMode = mode === 'jd'; - const [activeTab, setActiveTab] = useState('configuration'); + const [activeTab, setActiveTab] = useState(getInitialTab); const { data: rawLogs, isLoading: logsLoading } = useContainerLogs(activeTab === 'logs'); const logoInputRef = useRef(null); From 536eeecf41422179ceb3631894d5ac3c0b8a25d6 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:23:07 +0500 Subject: [PATCH 11/13] feat: improve pool benchmark metrics and results U --- server/src/active-pool.test.ts | 10 +- server/src/active-pool.ts | 21 +++- server/src/benchmark.test.ts | 26 +++++ server/src/benchmark.ts | 35 +++++- server/src/docker.ts | 28 ++++- server/src/index.ts | 7 +- shared/src/types.ts | 1 + src/pages/Benchmark.tsx | 204 +++++++++++++++++---------------- 8 files changed, 220 insertions(+), 112 deletions(-) diff --git a/server/src/active-pool.test.ts b/server/src/active-pool.test.ts index a637577d..eaf5d10d 100644 --- a/server/src/active-pool.test.ts +++ b/server/src/active-pool.test.ts @@ -72,7 +72,12 @@ test('an invalid attempt cannot clear a previously connected pool', () => { const result = detectActivePool( POOLS, [log('Trying upstream 2 of 2: attacker.example.com:4444')], - { activeIndex: 1, pendingIndex: null, connectedIndex: null } + { + activeIndex: 1, + activeNegotiatedAt: '2026-07-17T09:59:59.000Z', + pendingIndex: null, + connectedIndex: null, + } ); assert.equal(result.activeIndex, 1); @@ -139,10 +144,12 @@ test('tracker retains the connected fallback across incremental polls and log fa assert.deepEqual(await tracker.getActivePool('translator', POOLS), { name: 'Fallback', index: 1, + negotiatedAt: '2026-07-17T10:00:00.000Z', }); assert.deepEqual(await tracker.getActivePool('translator', POOLS), { name: 'Fallback', index: 1, + negotiatedAt: '2026-07-17T10:00:00.000Z', }); assert.equal(calls[0], undefined); assert.equal(typeof calls[1]?.since, 'number'); @@ -166,5 +173,6 @@ test('tracker carries a pending connection across incremental polls', async () = assert.deepEqual(await tracker.getActivePool('translator', POOLS), { name: 'Fallback', index: 1, + negotiatedAt: '2026-07-17T10:00:00.000Z', }); }); diff --git a/server/src/active-pool.ts b/server/src/active-pool.ts index d126b997..5ce996d0 100644 --- a/server/src/active-pool.ts +++ b/server/src/active-pool.ts @@ -4,6 +4,7 @@ import type { ContainerLogLine, LogContainerRole } from './logs/types.js'; export type ActivePool = { name: string; index: number; + negotiatedAt: string | null; }; export type ActivePoolLogOptions = { @@ -17,6 +18,7 @@ export type ActivePoolLogProvider = ( type DetectionState = { activeIndex: number | null; + activeNegotiatedAt: string | null; pendingIndex: number | null; connectedIndex: number | null; }; @@ -54,11 +56,13 @@ export function detectActivePool( lines: ContainerLogLine[], initialState: DetectionState = { activeIndex: null, + activeNegotiatedAt: null, pendingIndex: null, connectedIndex: null, } ): DetectionState { let activeIndex = initialState.activeIndex; + let activeNegotiatedAt = initialState.activeNegotiatedAt; let pendingIndex = initialState.pendingIndex; let connectedIndex = initialState.connectedIndex; @@ -80,6 +84,7 @@ export function detectActivePool( connectedIndex = null; // A new attempt means the previous upstream is no longer current. activeIndex = null; + activeNegotiatedAt = null; continue; } @@ -99,13 +104,14 @@ export function detectActivePool( if (SETUP_CONNECTION_SUCCESS_PATTERN.test(line.message)) { if (connectedIndex !== null && pools[connectedIndex]) { activeIndex = connectedIndex; + activeNegotiatedAt = line.timestamp; } pendingIndex = null; connectedIndex = null; } } - return { activeIndex, pendingIndex, connectedIndex }; + return { activeIndex, activeNegotiatedAt, pendingIndex, connectedIndex }; } function getConfigKey(container: LogContainerRole, pools: PoolConfig[]): string { @@ -154,6 +160,7 @@ export class ActivePoolTracker { this.state = { configKey, activeIndex, + activeNegotiatedAt: detected.activeNegotiatedAt, pendingIndex: detected.pendingIndex, connectedIndex: detected.connectedIndex, // Docker's `since` value is inclusive and has one-second precision. @@ -163,7 +170,11 @@ export class ActivePoolTracker { return activeIndex === null ? null - : { name: pools[activeIndex].name, index: activeIndex }; + : { + name: pools[activeIndex].name, + index: activeIndex, + negotiatedAt: detected.activeNegotiatedAt, + }; } catch { const activeIndex = previous?.activeIndex !== null && previous?.activeIndex !== undefined && @@ -172,7 +183,11 @@ export class ActivePoolTracker { : null; return activeIndex === null ? null - : { name: pools[activeIndex].name, index: activeIndex }; + : { + name: pools[activeIndex].name, + index: activeIndex, + negotiatedAt: previous?.activeNegotiatedAt ?? null, + }; } } } diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts index 33a995c6..2f587577 100644 --- a/server/src/benchmark.test.ts +++ b/server/src/benchmark.test.ts @@ -136,6 +136,32 @@ test('runs each pool, records measurements, and restores the original setup', as assert.equal(settled, 1); }); +test('records SV2 negotiation time from pool-client launch to SetupConnectionSuccess', async () => { + let poolClientStartedAtMs = 0; + + const manager = new BenchmarkManager({ + applyConfiguration: async (_data, onPoolClientStarting) => { + poolClientStartedAtMs = Date.now(); + onPoolClientStarting?.(); + }, + getActivePool: async () => ({ + index: 0, + negotiatedAt: new Date(poolClientStartedAtMs + 125).toISOString(), + }), + readShareCounters: async () => [], + measureLatency: async () => 10, + sampleIntervalMs: 1, + maxLatencySamples: 1, + activePoolPollIntervalMs: 1, + activePoolTimeoutMs: 20, + }); + + manager.start(SETUP, [POOLS[0]], 0.01); + await manager.waitForCompletion(); + + assert.equal(manager.getSnapshot()?.results[0].sv2NegotiationMs, 125); +}); + test('publishes accepted and rejected share deltas while a pool is still running', async () => { let counterRead = 0; const manager = new BenchmarkManager({ diff --git a/server/src/benchmark.ts b/server/src/benchmark.ts index fedc7c9a..d2253466 100644 --- a/server/src/benchmark.ts +++ b/server/src/benchmark.ts @@ -21,10 +21,14 @@ export type ShareChannelCounters = ShareCounters & { export type ActivePoolSelection = { index: number; + negotiatedAt?: string | null; }; export type BenchmarkDependencies = { - applyConfiguration: (data: SetupData) => Promise; + applyConfiguration: ( + data: SetupData, + onPoolClientStarting?: () => void + ) => Promise; getActivePool: ( mode: SetupMode, pools: PoolConfig[] @@ -237,6 +241,7 @@ export class BenchmarkManager { startedAt: null, completedAt: null, averageLatencyMs: null, + sv2NegotiationMs: null, successfulSamples: 0, attemptedSamples: 0, acceptedShares: null, @@ -345,8 +350,16 @@ export class BenchmarkManager { result.startedAt = new Date().toISOString(); try { - await this.dependencies.applyConfiguration(rotatedData); - await this.waitForIntendedPool(rotatedData.mode, rotatedPools, signal); + let poolClientStartedAtMs: number | null = null; + await this.dependencies.applyConfiguration(rotatedData, () => { + poolClientStartedAtMs = Date.now(); + }); + result.sv2NegotiationMs = await this.waitForIntendedPool( + rotatedData.mode, + rotatedPools, + poolClientStartedAtMs, + signal + ); await this.measurePool(result, rotatedData.mode, rotatedPools, poolDurationSeconds, signal); } catch (error) { if (isAbortError(error)) throw error; @@ -365,8 +378,9 @@ export class BenchmarkManager { private async waitForIntendedPool( mode: SetupMode | null, pools: PoolConfig[], + poolClientStartedAtMs: number | null, signal: AbortSignal - ): Promise { + ): Promise { if (!mode) throw new Error('Mining mode is not configured'); const timeoutMs = this.dependencies.activePoolTimeoutMs ?? DEFAULT_ACTIVE_POOL_TIMEOUT_MS; @@ -378,7 +392,18 @@ export class BenchmarkManager { if (signal.aborted) throw abortError(); const activePool = await this.dependencies.getActivePool(mode, pools); - if (activePool?.index === 0) return; + if (activePool?.index === 0) { + const observedAtMs = Date.now(); + const loggedAtMs = activePool.negotiatedAt + ? Date.parse(activePool.negotiatedAt) + : Number.NaN; + const completedAtMs = Number.isFinite(loggedAtMs) + ? loggedAtMs + : observedAtMs; + const startedAtMs = poolClientStartedAtMs ?? observedAtMs; + + return Math.round(Math.max(0, completedAtMs - startedAtMs) * 10) / 10; + } if (activePool && activePool.index > 0) { const fallback = pools[activePool.index]; throw new Error( diff --git a/server/src/docker.ts b/server/src/docker.ts index 0d63afc3..4d6e5cfe 100644 --- a/server/src/docker.ts +++ b/server/src/docker.ts @@ -762,7 +762,11 @@ async function getContainerStatus(name: string): Promise * - In Docker: uses shared volume (sv2-config) for config * - In dev: bind-mounts config file from host filesystem */ -async function startTranslator(configPath: string, image: string): Promise { +async function startTranslator( + configPath: string, + image: string, + onPoolClientStarting?: () => void +): Promise { await removeContainer(TRANSLATOR_CONTAINER); const binds = isRunningInDocker @@ -790,6 +794,7 @@ async function startTranslator(configPath: string, image: string): Promise }, }); + onPoolClientStarting?.(); await container.start(); console.log('Translator container started'); } @@ -803,7 +808,8 @@ async function startJdc( configPath: string, bitcoinSocketPath: string, network: string, - image: string + image: string, + onPoolClientStarting?: () => void ): Promise { await removeContainer(JDC_CONTAINER); @@ -842,6 +848,7 @@ async function startJdc( }, }); + onPoolClientStarting?.(); await container.start(); console.log('JDC container started'); } @@ -852,7 +859,8 @@ async function startJdc( */ export async function startStack( data: SetupData, - configDir: string + configDir: string, + onPoolClientStarting?: () => void ): Promise { await ensureDockerAvailable(); @@ -878,13 +886,23 @@ export async function startStack( // Start JDC first if in JD mode (Translator connects to JDC) if (imageSelection.mode === 'jd' && data.bitcoin) { const socketPath = expandHomePath(data.bitcoin.socket_path); - await startJdc(`${configDir}/jdc.toml`, socketPath, data.bitcoin.network, imageSelection.jdc); + await startJdc( + `${configDir}/jdc.toml`, + socketPath, + data.bitcoin.network, + imageSelection.jdc, + onPoolClientStarting + ); console.log('Waiting for JDC to initialize...'); await new Promise(resolve => setTimeout(resolve, 3000)); } // Start Translator - await startTranslator(`${configDir}/translator.toml`, imageSelection.translator); + await startTranslator( + `${configDir}/translator.toml`, + imageSelection.translator, + imageSelection.mode === 'no-jd' ? onPoolClientStarting : undefined + ); } /** diff --git a/server/src/index.ts b/server/src/index.ts index ffd956dd..21ef8b58 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -189,11 +189,14 @@ async function writeStackConfigFiles(data: SetupData): Promise { } } -async function applyBenchmarkConfiguration(data: SetupData): Promise { +async function applyBenchmarkConfiguration( + data: SetupData, + onPoolClientStarting?: () => void +): Promise { await writeStackConfigFiles(data); activePoolTracker.reset(); await stopStack(); - await startStack(data, CONFIG_DIR); + await startStack(data, CONFIG_DIR, onPoolClientStarting); } async function getBenchmarkActivePool(mode: SetupMode, pools: PoolConfig[]) { diff --git a/shared/src/types.ts b/shared/src/types.ts index 6513e1a0..31906d4c 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -69,6 +69,7 @@ export interface BenchmarkPoolResult { startedAt: string | null; completedAt: string | null; averageLatencyMs: number | null; + sv2NegotiationMs: number | null; successfulSamples: number; attemptedSamples: number; acceptedShares: number | null; diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx index fed0dbfa..8fa8ff9f 100644 --- a/src/pages/Benchmark.tsx +++ b/src/pages/Benchmark.tsx @@ -13,6 +13,7 @@ import { Shell } from '@/components/layout/Shell'; import { PoolPriorityEditor } from '@/components/pools/PoolPriorityEditor'; import { Badge, type BadgeProps } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { InfoPopover } from '@/components/ui/info-popover'; import { Card, CardContent, @@ -45,8 +46,8 @@ function formatDuration(totalSeconds: number): string { return `${hours} hr ${minutes} min`; } -function formatLatency(value: number | null): string { - return value === null ? '—' : `${value.toFixed(1)} ms`; +function formatLatency(value: number | null | undefined): string { + return value == null ? '—' : `${value.toFixed(1)} ms`; } function formatRejectedRate(result: BenchmarkPoolResult): string | null { @@ -89,6 +90,23 @@ function endpointKey(pool: PoolConfig): string { return `${pool.address.toLowerCase()}:${pool.port}`; } +function MetricHeader({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( + + {label} + +

{description}

+ + + ); +} + export function Benchmark() { const [, navigate] = useLocation(); const { @@ -227,13 +245,10 @@ export function Benchmark() {

Pool Benchmark

- Compare what works best for this setup using average TCP connection latency - and upstream share outcomes observed during equal mining intervals. + Compare pools by connection speed and rejected shares.

- Mining benchmarks have intrinsic variance. Latency and rejected-share rate - are the most useful signals here, but they are time-sensitive and say - nothing about a pool's internal performance. + Experimental feature. Results can change with network conditions and benchmark duration.

@@ -257,41 +272,16 @@ export function Benchmark() {
)} - - - Benchmark setup - - Select at least two pools and drag them into test order. Each duration - applies per pool. - - - - {isActive && run ? ( -
- {run.selectedPools.map((pool, index) => { - const knownPool = getKnownPoolForConfig(pool); - return ( -
- -
-
{pool.name}
-
Run {index + 1}
-
-
- ); - })} -
- ) : ( + {!isActive && ( + + + Benchmark setup + + Select at least two pools and drag them into test order. Each duration + applies per pool. + + + `Run ${index + 1}`} /> - )} -
-
- - -
+
+
+ + +
-
- Estimated total: - {formatDuration(pools.length * durationMinutes * 60)} - -
- Opening this page downloads nothing. Mining services are only rotated after Start is clicked. +
+ Estimated total: + {formatDuration(pools.length * durationMinutes * 60)} + +
+ Opening this page downloads nothing. Mining services are only rotated after Start is clicked. +
-
- {isActive ? ( - - ) : ( - )} -
- - +
+ + + )} {run && ( @@ -372,7 +346,7 @@ export function Benchmark() { {run.status === 'stopping' ? 'Stopping and restoring the original pool configuration...' : run.status === 'completed' - ? 'Ranked by average latency. The original pool order has been restored.' + ? 'Ranked by average TCP connect time. The original pool order has been restored.' : run.status === 'cancelled' ? 'The run was stopped and the original pool order was restored.' : run.status === 'failed' @@ -387,9 +361,23 @@ export function Benchmark() { {isTerminal && rankedResults.some((result) => result.status === 'completed') && (
- Lowest average latency ranks first + Lowest TCP connect time ranks first
)} + {isActive && ( + + )}
{isActive && currentEndsAt !== null && (
@@ -415,10 +403,31 @@ export function Benchmark() { Rank Pool Status - Avg latency + + + + + + Samples - Accepted - Rejected (rate) + + + + + + Action @@ -465,6 +474,9 @@ export function Benchmark() {
{formatLatency(result.averageLatencyMs)}
+ +
{formatLatency(result.sv2NegotiationMs)}
+
{result.successfulSamples}/{result.attemptedSamples} @@ -501,10 +513,10 @@ export function Benchmark() {

- Accepted means acknowledged by the upstream pool. Accepted totals are context, - not a cross-pool score, because assigned share difficulty may differ. Average - latency uses ten TCP connection attempts spread across each interval; it is not - a share-ack round-trip time. A pool receives no latency rank if any attempt fails. + Results reflect conditions during this run. Pools are ranked only by TCP connect + time, and a pool receives no rank if any TCP attempt fails. Compare SV2 setup time + and rejected-share rate separately rather than treating the rank as an overall + pool-quality score.

From 355cb94d0bb9b12e64e27459010e744e6c6e898f Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:45:46 +0500 Subject: [PATCH 12/13] improve status indicator --- src/components/layout/Shell.tsx | 2 +- src/pages/Benchmark.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/layout/Shell.tsx b/src/components/layout/Shell.tsx index 756af932..b15d8239 100644 --- a/src/components/layout/Shell.tsx +++ b/src/components/layout/Shell.tsx @@ -217,7 +217,7 @@ export function Shell({ )} ) : connectionStatus === 'connecting' - ? 'Connecting...' + ? (connectionLabel || 'Connecting...') : 'Disconnected'} diff --git a/src/pages/Benchmark.tsx b/src/pages/Benchmark.tsx index 8fa8ff9f..2eed53cb 100644 --- a/src/pages/Benchmark.tsx +++ b/src/pages/Benchmark.tsx @@ -236,8 +236,8 @@ export function Benchmark() { return ( From 5469aeccb2697c0c72c0ac6c94d6bb06f8591af8 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:03:49 +0500 Subject: [PATCH 13/13] fix tests --- server/src/benchmark.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/benchmark.test.ts b/server/src/benchmark.test.ts index 2f587577..1daa48ad 100644 --- a/server/src/benchmark.test.ts +++ b/server/src/benchmark.test.ts @@ -111,7 +111,9 @@ test('runs each pool, records measurements, and restores the original setup', as }, measureLatency: async (pool) => pool.name === 'Alpha' ? 10 : 20, sampleIntervalMs: 1, - maxLatencySamples: 2, + // Multi-sample scheduling is covered separately. Keep this orchestration + // test independent of sub-millisecond timer precision on slower CI hosts. + maxLatencySamples: 1, activePoolPollIntervalMs: 1, activePoolTimeoutMs: 20, onSettled: () => {