From 860f3adb2ee031f8c727e54eeaf8a0595eedaac1 Mon Sep 17 00:00:00 2001 From: GitGab19 Date: Mon, 29 Jun 2026 19:31:20 +0200 Subject: [PATCH] Support optional miner telemetry CIDR and dashboard fields Use the miner telemetry additions from stratum-mining/sv2-apps#615 to optionally configure management-interface discovery and surface matched telemetry in the dashboard. - accept an optional private miners' LAN CIDR during setup/settings and write [miner_telemetry] cidrs for Translator and JDC only when provided - set Translator/JDC monitoring cache refresh to 5 seconds for fresher dashboard telemetry - update OpenAPI/generated types for management_ip, miner_telemetry_status, and client_kind - show management IP, telemetry status, and miner telemetry columns in the worker table - use client_kind to exclude Translator Proxy aggregate rows while keeping vardiff hashrate fallback Upstream: https://github.com/stratum-mining/sv2-apps/pull/615 --- server/src/config-generator.test.ts | 35 +++ server/src/config-generator.ts | 22 +- server/src/images.test.ts | 1 + server/src/index.ts | 6 + shared/openapi.json | 2 +- shared/src/index.ts | 1 + shared/src/miner-telemetry.ts | 45 +++ shared/src/types.ts | 1 + src/components/data/DownstreamWorkerTable.tsx | 154 +++++++++- src/components/settings/ConfigurationTab.tsx | 60 +++- src/components/setup/steps/HashrateStep.tsx | 35 ++- src/components/setup/steps/ReviewStart.tsx | 14 + src/components/setup/types.ts | 1 + src/hooks/usePoolData.ts | 12 +- src/lib/minerTelemetry.test.ts | 46 +++ src/lib/minerTelemetry.ts | 39 +++ src/pages/UnifiedDashboard.tsx | 276 +++++++++--------- src/types/api-generated.ts | 108 +++++++ src/types/api.ts | 5 +- 19 files changed, 707 insertions(+), 156 deletions(-) create mode 100644 shared/src/miner-telemetry.ts create mode 100644 src/lib/minerTelemetry.test.ts create mode 100644 src/lib/minerTelemetry.ts diff --git a/server/src/config-generator.test.ts b/server/src/config-generator.test.ts index 9e43eb49..dc1cf7ff 100644 --- a/server/src/config-generator.test.ts +++ b/server/src/config-generator.test.ts @@ -6,6 +6,7 @@ import type { SetupData } from './types.js'; const BASE_DATA_30: SetupData = { miningMode: 'pool', mode: 'jd', + miner_telemetry_cidr: '192.168.1.0/24', pool: { name: 'Custom Pool', address: 'pool.example.com', @@ -48,6 +49,7 @@ const BASE_DATA_31_SOLO: SetupData = { const NO_JD_DATA: SetupData = { miningMode: 'pool', mode: 'no-jd', + miner_telemetry_cidr: '192.168.1.0/24', pool: { name: 'Remote Pool', address: 'remote.pool.com', @@ -73,6 +75,8 @@ test('translator config uses advanced setup values', () => { assert.match(config, /min_individual_miner_hashrate = 100000000000000\.0/); assert.match(config, /downstream_extranonce2_size = 8/); assert.match(config, /shares_per_minute = 12\.5/); + assert.match(config, /monitoring_cache_refresh_secs = 5/); + assert.match(config, /\[miner_telemetry\]\ncidrs = \["192\.168\.1\.0\/24"\]/); }); test('translator config omits payout verification for pool mining', () => { @@ -151,11 +155,28 @@ test('jdc config uses shared shares-per-minute and miner signature', () => { assert.ok(config); assert.match(config, /shares_per_minute = 12\.5/); assert.match(config, /jdc_signature = "custom-miner-tag"/); + assert.match(config, /monitoring_cache_refresh_secs = 5/); + assert.match(config, /\[miner_telemetry\]\ncidrs = \["192\.168\.1\.0\/24"\]/); +}); + +test('miner telemetry config is omitted when LAN CIDR is blank', () => { + const data = { + ...BASE_DATA_30, + miner_telemetry_cidr: '', + }; + + const translatorConfig = generateTranslatorConfig(data); + const jdcConfig = generateJdcConfig(data); + + assert.doesNotMatch(translatorConfig, /\[miner_telemetry\]/); + assert.ok(jdcConfig); + assert.doesNotMatch(jdcConfig, /\[miner_telemetry\]/); }); test('normalization backfills advanced defaults for old saved configs', () => { const data = { ...BASE_DATA_30, + miner_telemetry_cidr: undefined, translator: { ...BASE_DATA_30.translator, min_hashrate: undefined, @@ -168,6 +189,7 @@ test('normalization backfills advanced defaults for old saved configs', () => { assert.ok(normalized.translator); assert.equal(normalized.translator.min_hashrate, 100_000_000_000_000); + assert.equal(normalized.miner_telemetry_cidr, ''); assert.equal(normalized.translator.shares_per_minute, 6); assert.equal(normalized.translator.downstream_extranonce2_size, 4); assert.equal('verify_payout' in normalized.translator, false); @@ -328,6 +350,19 @@ test('jdc in solo mode omits user_identity entirely', () => { assert.match(config, /upstreams = \[\]/); }); +test('jdc in solo mode emits upstreams before miner telemetry table', () => { + const config = generateJdcConfig(BASE_DATA_31_SOLO); + + assert.ok(config); + + const upstreamsIndex = config.indexOf('upstreams = []'); + const telemetryIndex = config.indexOf('[miner_telemetry]'); + + assert.notEqual(upstreamsIndex, -1); + assert.notEqual(telemetryIndex, -1); + assert.ok(upstreamsIndex < telemetryIndex); +}); + test('no-jd mode: translator uses new format (user_identity inside [[upstreams]])', () => { const config = generateTranslatorConfig(NO_JD_DATA); const upstreamIdx = config.indexOf('[[upstreams]]'); diff --git a/server/src/config-generator.ts b/server/src/config-generator.ts index da07adfa..64fd69e9 100644 --- a/server/src/config-generator.ts +++ b/server/src/config-generator.ts @@ -14,9 +14,12 @@ import { DEFAULT_MIN_HASHRATE, bitcoinCoreVersionToIpcMajor, formatSupportedVersions, + normalizeMinerTelemetryCidr, } from '@sv2-ui/shared'; import type { JdcConfig, PoolConfig, SetupData, TranslatorConfig } from './types.js'; +const MONITORING_CACHE_REFRESH_SECS = 5; + function positiveNumber(value: number | undefined, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value @@ -96,6 +99,15 @@ function shouldVerifyPayout(data: SetupData): boolean { && pools.every((pool) => !isFullDonationIdentity(pool.user_identity)); } +function minerTelemetryConfig(cidr: string): string { + if (!cidr) return ''; + + return `# LAN subnet containing ASIC miner web/API addresses (for example, 192.168.1.0/24). +[miner_telemetry] +cidrs = ["${cidr}"] +`; +} + export function normalizeSetupData(data: SetupData): SetupData { const fallbackIdentity = legacyIdentity(data); const pool = normalizePool(data.pool, fallbackIdentity); @@ -119,6 +131,7 @@ export function normalizeSetupData(data: SetupData): SetupData { return { miningMode: data.miningMode, mode: data.mode, + miner_telemetry_cidr: normalizeMinerTelemetryCidr(data.miner_telemetry_cidr), pool, fallbackPools, bitcoin: data.bitcoin, @@ -130,6 +143,7 @@ export function normalizeSetupData(data: SetupData): SetupData { return { miningMode: data.miningMode, mode: data.mode, + miner_telemetry_cidr: normalizeMinerTelemetryCidr(data.miner_telemetry_cidr), pool, fallbackPools, bitcoin: data.bitcoin, @@ -224,8 +238,9 @@ required_extensions = [] # Monitoring HTTP server address monitoring_address = "0.0.0.0:9092" -monitoring_cache_refresh_secs = 15 +monitoring_cache_refresh_secs = ${MONITORING_CACHE_REFRESH_SECS} +${minerTelemetryConfig(normalizedData.miner_telemetry_cidr)} # Difficulty params [downstream_difficulty_config] min_individual_miner_hashrate = ${minHashrate} @@ -320,9 +335,10 @@ required_extensions = [] # Monitoring HTTP server address monitoring_address = "0.0.0.0:9091" -monitoring_cache_refresh_secs = 15 +monitoring_cache_refresh_secs = ${MONITORING_CACHE_REFRESH_SECS} -${upstreamsConfig}# Bitcoin Core IPC config +${upstreamsConfig}${minerTelemetryConfig(normalizedData.miner_telemetry_cidr)} +# Bitcoin Core IPC config [template_provider_type.BitcoinCoreIpc] version = ${bitcoinCoreIpcVersion} network = "${bitcoin.network}" diff --git a/server/src/images.test.ts b/server/src/images.test.ts index cc459d28..bc62cadb 100644 --- a/server/src/images.test.ts +++ b/server/src/images.test.ts @@ -6,6 +6,7 @@ import type { SetupData } from './types.js'; const BASE_SETUP_DATA: SetupData = { miningMode: 'pool', mode: 'jd', + miner_telemetry_cidr: '192.168.1.0/24', pool: { name: 'Custom Pool', address: 'pool.example.com', diff --git a/server/src/index.ts b/server/src/index.ts index 49e76dcf..124d6bde 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { normalizeBitcoinCoreVersion, TRANSLATOR_MONITORING_PORT, JDC_MONITORING_PORT, + getMinerTelemetryCidrError, } from '@sv2-ui/shared'; import { BITCOIN_ERROR_MESSAGES } from './messages.js'; import { @@ -161,6 +162,11 @@ function getSetupValidationError(data: SetupData): string | null { return `No more than ${MAX_FALLBACK_POOLS} fallback pools may be configured`; } + const minerTelemetryCidrError = getMinerTelemetryCidrError(data.miner_telemetry_cidr); + if (minerTelemetryCidrError) { + return minerTelemetryCidrError; + } + const pools = configuredPools(data); for (const [index, pool] of pools.entries()) { const error = getPoolConfigError(pool, index === 0 ? 'Primary pool' : `Fallback pool ${index}`); diff --git a/shared/openapi.json b/shared/openapi.json index a51f0a0e..8acc944e 100644 --- a/shared/openapi.json +++ b/shared/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"SRI Monitoring API","description":"HTTP JSON API for monitoring SV2 applications","contact":{"name":"The Stratum V2 Developers"},"license":{"name":"MIT OR Apache-2.0","identifier":"MIT OR Apache-2.0"},"version":"0.1.0"},"paths":{"/api/v1/clients":{"get":{"tags":["clients"],"summary":"Get all Sv2 clients (downstream) - returns metadata only, use /clients/{id}/channels for\nchannels","operationId":"handle_clients","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"List of Sv2 clients (metadata only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientsResponse"}}}},"404":{"description":"Sv2 clients monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/clients/{client_id}":{"get":{"tags":["clients"],"summary":"Get a single Sv2 client by ID - returns metadata only, use /clients/{id}/channels for channels","operationId":"handle_client_by_id","parameters":[{"name":"client_id","in":"path","description":"Sv2 Client ID","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Sv2 client metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientResponse"}}}},"404":{"description":"Sv2 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/clients/{client_id}/channels":{"get":{"tags":["clients"],"summary":"Get channels for a specific Sv2 client (paginated)","operationId":"handle_client_channels","parameters":[{"name":"client_id","in":"path","description":"Sv2 Client ID","required":true,"schema":{"type":"integer","minimum":0}},{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"Sv2 client channels (paginated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientChannelsResponse"}}}},"404":{"description":"Sv2 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/global":{"get":{"tags":["global"],"summary":"Get global statistics","description":"Returns aggregated statistics for the server (upstream) and clients (downstream).\nFields are omitted from the response if that type of monitoring is not enabled.\n\n**Typical responses:**\n- **Pool/JDC**: `server` + `clients` (Sv2 downstream)\n- **tProxy**: `server` + `sv1_clients` (Sv1 miners)","operationId":"handle_global","responses":{"200":{"description":"Global statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalInfo"}}}}}}},"/api/v1/health":{"get":{"tags":["health"],"summary":"Health check endpoint","operationId":"handle_health","responses":{"200":{"description":"Service is healthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/api/v1/server":{"get":{"tags":["server"],"summary":"Get server (upstream) metadata - use /server/channels for channel details","operationId":"handle_server","responses":{"200":{"description":"Server metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerResponse"}}}},"404":{"description":"Server monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/server/channels":{"get":{"tags":["server"],"summary":"Get server channels (paginated)","operationId":"handle_server_channels","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"Server channels (paginated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerChannelsResponse"}}}},"404":{"description":"Server monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/sv1/clients":{"get":{"tags":["sv1"],"summary":"Get Sv1 clients (Translator Proxy only)","operationId":"handle_sv1_clients","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"List of Sv1 clients","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv1ClientsResponse"}}}},"404":{"description":"Sv1 monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/sv1/clients/{client_id}":{"get":{"tags":["sv1"],"summary":"Get a single Sv1 client by ID","operationId":"handle_sv1_client_by_id","parameters":[{"name":"client_id","in":"path","description":"Sv1 client ID","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Sv1 client details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv1ClientInfo"}}}},"404":{"description":"Sv1 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"schemas":{"ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"ExtendedChannelInfo":{"type":"object","description":"Information about an extended channel","required":["channel_id","user_identity","nominal_hashrate","stable_hashrate","target_hex","requested_max_target_hex","extranonce_prefix_hex","full_extranonce_size","rollable_extranonce_size","expected_shares_per_minute","shares_accepted","shares_rejected","shares_rejected_by_reason","share_work_sum","last_share_sequence_number","best_diff","last_batch_accepted","last_batch_work_sum","share_batch_size","blocks_found"],"properties":{"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"expected_shares_per_minute":{"type":"number","format":"float"},"extranonce_prefix_hex":{"type":"string"},"full_extranonce_size":{"type":"integer","minimum":0},"last_batch_accepted":{"type":"integer","format":"int32","minimum":0},"last_batch_work_sum":{"type":"integer","format":"int64","minimum":0},"last_share_sequence_number":{"type":"integer","format":"int32","minimum":0},"nominal_hashrate":{"type":"number","format":"float"},"requested_max_target_hex":{"type":"string"},"rollable_extranonce_size":{"type":"integer","format":"int32","minimum":0},"share_batch_size":{"type":"integer","minimum":0},"share_work_sum":{"type":"number","format":"double"},"shares_accepted":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"}}},"GlobalInfo":{"type":"object","description":"Global statistics from `/api/v1/global` endpoint\n\nFields are `Option` to distinguish \"not monitored\" (`None`) from \"monitored but empty\" (`Some`\nwith zeros).\n\nTypical configurations:\n- **Pool/JDC**: `server` and `sv2_clients` are `Some`, `sv1_clients` is `None`\n- **tProxy**: `server` and `sv1_clients` are `Some`, `sv2_clients` is `None`","required":["uptime_secs"],"properties":{"server":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/ServerSummary","description":"Server (upstream) summary - `None` if server monitoring is not enabled"}]},"sv1_clients":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Sv1ClientsSummary","description":"Sv1 clients summary - `None` if Sv1 monitoring is not enabled (e.g., Pool/JDC)"}]},"sv2_clients":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Sv2ClientsSummary","description":"Sv2 clients (downstream) summary - `None` if Sv2 client monitoring is not enabled (e.g.,\ntProxy)"}]},"uptime_secs":{"type":"integer","format":"int64","description":"Uptime in seconds since the application started","minimum":0}}},"HealthResponse":{"type":"object","required":["status","timestamp"],"properties":{"status":{"type":"string"},"timestamp":{"type":"integer","format":"int64","minimum":0}}},"ServerChannelsResponse":{"type":"object","required":["offset","limit","total_extended","total_standard","extended_channels","standard_channels"],"properties":{"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ServerExtendedChannelInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/ServerStandardChannelInfo"}},"total_extended":{"type":"integer","minimum":0},"total_standard":{"type":"integer","minimum":0}}},"ServerExtendedChannelInfo":{"type":"object","description":"Information about an extended channel opened with the server","required":["channel_id","user_identity","target_hex","extranonce_prefix_hex","full_extranonce_size","rollable_extranonce_size","version_rolling","shares_acknowledged","shares_submitted","shares_rejected","shares_rejected_by_reason","acknowledged_work_sum","validated_work_sum","best_diff","blocks_found"],"properties":{"acknowledged_work_sum":{"type":"integer","format":"int64","description":"Work acknowledged by upstream via `SubmitSharesSuccess.new_shares_sum`.","minimum":0},"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"extranonce_prefix_hex":{"type":"string"},"full_extranonce_size":{"type":"integer","minimum":0},"nominal_hashrate":{"type":["number","null"],"format":"float","description":"None when vardiff is disabled and hashrate cannot be reliably tracked"},"rollable_extranonce_size":{"type":"integer","format":"int32","minimum":0},"shares_acknowledged":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"shares_submitted":{"type":"integer","format":"int32","minimum":0},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"validated_work_sum":{"type":"number","format":"double","description":"Work locally validated by the client channel."},"version_rolling":{"type":"boolean"}}},"ServerResponse":{"type":"object","required":["extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"extended_channels_count":{"type":"integer","minimum":0},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"ServerStandardChannelInfo":{"type":"object","description":"Information about a standard channel opened with the server","required":["channel_id","user_identity","target_hex","extranonce_prefix_hex","shares_acknowledged","shares_submitted","shares_rejected","shares_rejected_by_reason","acknowledged_work_sum","validated_work_sum","best_diff","blocks_found"],"properties":{"acknowledged_work_sum":{"type":"integer","format":"int64","description":"Work acknowledged by upstream via `SubmitSharesSuccess.new_shares_sum`.","minimum":0},"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"extranonce_prefix_hex":{"type":"string"},"nominal_hashrate":{"type":["number","null"],"format":"float","description":"None when vardiff is disabled and hashrate cannot be reliably tracked"},"shares_acknowledged":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"shares_submitted":{"type":"integer","format":"int32","minimum":0},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"validated_work_sum":{"type":"number","format":"double","description":"Work locally validated by the client channel."}}},"ServerSummary":{"type":"object","description":"Aggregate information about the server connection","required":["total_channels","extended_channels","standard_channels","total_hashrate"],"properties":{"extended_channels":{"type":"integer","minimum":0},"standard_channels":{"type":"integer","minimum":0},"total_channels":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"StandardChannelInfo":{"type":"object","description":"Information about a standard channel","required":["channel_id","user_identity","nominal_hashrate","stable_hashrate","target_hex","requested_max_target_hex","extranonce_prefix_hex","expected_shares_per_minute","shares_accepted","shares_rejected","shares_rejected_by_reason","share_work_sum","last_share_sequence_number","best_diff","last_batch_accepted","last_batch_work_sum","share_batch_size","blocks_found"],"properties":{"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"expected_shares_per_minute":{"type":"number","format":"float"},"extranonce_prefix_hex":{"type":"string"},"last_batch_accepted":{"type":"integer","format":"int32","minimum":0},"last_batch_work_sum":{"type":"integer","format":"int64","minimum":0},"last_share_sequence_number":{"type":"integer","format":"int32","minimum":0},"nominal_hashrate":{"type":"number","format":"float"},"requested_max_target_hex":{"type":"string"},"share_batch_size":{"type":"integer","minimum":0},"share_work_sum":{"type":"number","format":"double"},"shares_accepted":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"}}},"Sv1ClientInfo":{"type":"object","description":"Information about a single SV1 client connection","required":["client_id","authorized_worker_name","user_identity","target_hex","stable_hashrate","extranonce1_hex","extranonce2_len"],"properties":{"authorized_worker_name":{"type":"string"},"channel_id":{"type":["integer","null"],"format":"int32","minimum":0},"client_id":{"type":"integer","minimum":0},"extranonce1_hex":{"type":"string"},"extranonce2_len":{"type":"integer","minimum":0},"hashrate":{"type":["number","null"],"format":"float"},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"version_rolling_mask":{"type":["string","null"]},"version_rolling_min_bit":{"type":["string","null"]}}},"Sv1ClientsResponse":{"type":"object","required":["offset","limit","total","items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Sv1ClientInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"total":{"type":"integer","minimum":0}}},"Sv1ClientsSummary":{"type":"object","description":"Aggregate information about SV1 client connections","required":["total_clients","total_hashrate"],"properties":{"total_clients":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientChannelsResponse":{"type":"object","required":["client_id","offset","limit","total_extended","total_standard","extended_channels","standard_channels"],"properties":{"client_id":{"type":"integer","minimum":0},"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ExtendedChannelInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/StandardChannelInfo"}},"total_extended":{"type":"integer","minimum":0},"total_standard":{"type":"integer","minimum":0}}},"Sv2ClientInfo":{"type":"object","description":"Full information about a single Sv2 client including all channels","required":["client_id","extended_channels","standard_channels"],"properties":{"client_id":{"type":"integer","minimum":0},"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ExtendedChannelInfo"}},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/StandardChannelInfo"}}}},"Sv2ClientMetadata":{"type":"object","description":"Sv2 client metadata without channel arrays (for listings)","required":["client_id","extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"client_id":{"type":"integer","minimum":0},"extended_channels_count":{"type":"integer","minimum":0},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientResponse":{"type":"object","required":["client_id","extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"client_id":{"type":"integer","minimum":0},"extended_channels_count":{"type":"integer","minimum":0},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientsResponse":{"type":"object","required":["offset","limit","total","items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Sv2ClientMetadata"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"total":{"type":"integer","minimum":0}}},"Sv2ClientsSummary":{"type":"object","description":"Aggregate information about all Sv2 clients","required":["total_clients","total_channels","extended_channels","standard_channels","total_hashrate"],"properties":{"extended_channels":{"type":"integer","minimum":0},"standard_channels":{"type":"integer","minimum":0},"total_channels":{"type":"integer","minimum":0},"total_clients":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}}}},"tags":[{"name":"health","description":"Health check endpoints"},{"name":"global","description":"Global statistics"},{"name":"server","description":"Server (upstream) monitoring"},{"name":"clients","description":"Clients (downstream) monitoring"},{"name":"sv1","description":"Sv1 clients monitoring (Translator Proxy only)"}]} +{"openapi":"3.1.0","info":{"title":"SRI Monitoring API","description":"HTTP JSON API for monitoring SV2 applications","contact":{"name":"The Stratum V2 Developers"},"license":{"name":"MIT OR Apache-2.0","identifier":"MIT OR Apache-2.0"},"version":"0.1.0"},"paths":{"/api/v1/clients":{"get":{"tags":["clients"],"summary":"Get all Sv2 clients (downstream) - returns metadata only, use /clients/{id}/channels for\nchannels","operationId":"handle_clients","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"List of Sv2 clients (metadata only)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientsResponse"}}}},"404":{"description":"Sv2 clients monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/clients/{client_id}":{"get":{"tags":["clients"],"summary":"Get a single Sv2 client by ID - returns metadata only, use /clients/{id}/channels for channels","operationId":"handle_client_by_id","parameters":[{"name":"client_id","in":"path","description":"Sv2 Client ID","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Sv2 client metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientResponse"}}}},"404":{"description":"Sv2 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/clients/{client_id}/channels":{"get":{"tags":["clients"],"summary":"Get channels for a specific Sv2 client (paginated)","operationId":"handle_client_channels","parameters":[{"name":"client_id","in":"path","description":"Sv2 Client ID","required":true,"schema":{"type":"integer","minimum":0}},{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"Sv2 client channels (paginated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv2ClientChannelsResponse"}}}},"404":{"description":"Sv2 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/global":{"get":{"tags":["global"],"summary":"Get global statistics","description":"Returns aggregated statistics for the server (upstream) and clients (downstream).\nFields are omitted from the response if that type of monitoring is not enabled.\n\n**Typical responses:**\n- **Pool/JDC**: `server` + `clients` (Sv2 downstream)\n- **tProxy**: `server` + `sv1_clients` (Sv1 miners)","operationId":"handle_global","responses":{"200":{"description":"Global statistics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GlobalInfo"}}}}}}},"/api/v1/health":{"get":{"tags":["health"],"summary":"Health check endpoint","operationId":"handle_health","responses":{"200":{"description":"Service is healthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/api/v1/server":{"get":{"tags":["server"],"summary":"Get server (upstream) metadata - use /server/channels for channel details","operationId":"handle_server","responses":{"200":{"description":"Server metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerResponse"}}}},"404":{"description":"Server monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/server/channels":{"get":{"tags":["server"],"summary":"Get server channels (paginated)","operationId":"handle_server_channels","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"Server channels (paginated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerChannelsResponse"}}}},"404":{"description":"Server monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/sv1/clients":{"get":{"tags":["sv1"],"summary":"Get Sv1 clients (Translator Proxy only)","operationId":"handle_sv1_clients","parameters":[{"name":"offset","in":"query","description":"Offset for pagination (default: 0)","required":false,"schema":{"type":"integer","minimum":0}},{"name":"limit","in":"query","description":"Limit for pagination (default: 25, max: 100)","required":false,"schema":{"type":["integer","null"],"minimum":0}}],"responses":{"200":{"description":"List of Sv1 clients","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv1ClientsResponse"}}}},"404":{"description":"Sv1 monitoring not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/v1/sv1/clients/{client_id}":{"get":{"tags":["sv1"],"summary":"Get a single Sv1 client by ID","operationId":"handle_sv1_client_by_id","parameters":[{"name":"client_id","in":"path","description":"Sv1 client ID","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"Sv1 client details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sv1ClientInfo"}}}},"404":{"description":"Sv1 client not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"components":{"schemas":{"ErrorResponse":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"ExtendedChannelInfo":{"type":"object","description":"Information about an extended channel","required":["channel_id","user_identity","nominal_hashrate","stable_hashrate","target_hex","requested_max_target_hex","extranonce_prefix_hex","full_extranonce_size","rollable_extranonce_size","expected_shares_per_minute","shares_accepted","shares_rejected","shares_rejected_by_reason","share_work_sum","last_share_sequence_number","best_diff","last_batch_accepted","last_batch_work_sum","share_batch_size","blocks_found"],"properties":{"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"expected_shares_per_minute":{"type":"number","format":"float"},"extranonce_prefix_hex":{"type":"string"},"full_extranonce_size":{"type":"integer","minimum":0},"last_batch_accepted":{"type":"integer","format":"int32","minimum":0},"last_batch_work_sum":{"type":"integer","format":"int64","minimum":0},"last_share_sequence_number":{"type":"integer","format":"int32","minimum":0},"nominal_hashrate":{"type":"number","format":"float"},"requested_max_target_hex":{"type":"string"},"rollable_extranonce_size":{"type":"integer","format":"int32","minimum":0},"share_batch_size":{"type":"integer","minimum":0},"share_work_sum":{"type":"number","format":"double"},"shares_accepted":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"}}},"GlobalInfo":{"type":"object","description":"Global statistics from `/api/v1/global` endpoint\n\nFields are `Option` to distinguish \"not monitored\" (`None`) from \"monitored but empty\" (`Some`\nwith zeros).\n\nTypical configurations:\n- **Pool/JDC**: `server` and `sv2_clients` are `Some`, `sv1_clients` is `None`\n- **tProxy**: `server` and `sv1_clients` are `Some`, `sv2_clients` is `None`","required":["uptime_secs"],"properties":{"server":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/ServerSummary","description":"Server (upstream) summary - `None` if server monitoring is not enabled"}]},"sv1_clients":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Sv1ClientsSummary","description":"Sv1 clients summary - `None` if Sv1 monitoring is not enabled (e.g., Pool/JDC)"}]},"sv2_clients":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Sv2ClientsSummary","description":"Sv2 clients (downstream) summary - `None` if Sv2 client monitoring is not enabled (e.g.,\ntProxy)"}]},"uptime_secs":{"type":"integer","format":"int64","description":"Uptime in seconds since the application started","minimum":0}}},"HealthResponse":{"type":"object","required":["status","timestamp"],"properties":{"status":{"type":"string"},"timestamp":{"type":"integer","format":"int64","minimum":0}}},"MinerTelemetry":{"type":"object","description":"Telemetry reported by the miner's management interface.","properties":{"average_temperature_c":{"type":["number","null"],"format":"double","description":"Average miner temperature in degrees Celsius."},"efficiency_j_per_th":{"type":["number","null"],"format":"double","description":"Miner efficiency in joules per terahash."},"firmware_version":{"type":["string","null"],"description":"Firmware version reported by the miner, when exposed."},"is_mining":{"type":["boolean","null"],"description":"Whether the miner reports that hashing is currently running."},"make":{"type":["string","null"],"description":"Miner manufacturer or brand reported by the management interface."},"model":{"type":["string","null"],"description":"Miner model reported by the management interface."},"power_consumption_w":{"type":["number","null"],"format":"double","description":"Current miner power consumption in watts."},"reported_hashrate_hs":{"type":["number","null"],"format":"double","description":"Miner-reported hashrate in hashes per second."},"uptime_secs":{"type":["integer","null"],"format":"int64","description":"Total miner system uptime in seconds.","minimum":0}}},"MinerTelemetryStatus":{"type":"string","description":"Status of matching a connected miner to discovered management telemetry.","enum":["matched","unmatched","duplicate_worker_name","fetch_failed"]},"ServerChannelsResponse":{"type":"object","required":["offset","limit","total_extended","total_standard","extended_channels","standard_channels"],"properties":{"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ServerExtendedChannelInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/ServerStandardChannelInfo"}},"total_extended":{"type":"integer","minimum":0},"total_standard":{"type":"integer","minimum":0}}},"ServerExtendedChannelInfo":{"type":"object","description":"Information about an extended channel opened with the server","required":["channel_id","user_identity","target_hex","extranonce_prefix_hex","full_extranonce_size","rollable_extranonce_size","version_rolling","shares_acknowledged","shares_submitted","shares_rejected","shares_rejected_by_reason","acknowledged_work_sum","validated_work_sum","best_diff","blocks_found"],"properties":{"acknowledged_work_sum":{"type":"integer","format":"int64","description":"Work acknowledged by upstream via `SubmitSharesSuccess.new_shares_sum`.","minimum":0},"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"extranonce_prefix_hex":{"type":"string"},"full_extranonce_size":{"type":"integer","minimum":0},"nominal_hashrate":{"type":["number","null"],"format":"float","description":"None when vardiff is disabled and hashrate cannot be reliably tracked"},"rollable_extranonce_size":{"type":"integer","format":"int32","minimum":0},"shares_acknowledged":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"shares_submitted":{"type":"integer","format":"int32","minimum":0},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"validated_work_sum":{"type":"number","format":"double","description":"Work locally validated by the client channel."},"version_rolling":{"type":"boolean"}}},"ServerResponse":{"type":"object","required":["extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"extended_channels_count":{"type":"integer","minimum":0},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"ServerStandardChannelInfo":{"type":"object","description":"Information about a standard channel opened with the server","required":["channel_id","user_identity","target_hex","extranonce_prefix_hex","shares_acknowledged","shares_submitted","shares_rejected","shares_rejected_by_reason","acknowledged_work_sum","validated_work_sum","best_diff","blocks_found"],"properties":{"acknowledged_work_sum":{"type":"integer","format":"int64","description":"Work acknowledged by upstream via `SubmitSharesSuccess.new_shares_sum`.","minimum":0},"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"extranonce_prefix_hex":{"type":"string"},"nominal_hashrate":{"type":["number","null"],"format":"float","description":"None when vardiff is disabled and hashrate cannot be reliably tracked"},"shares_acknowledged":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"shares_submitted":{"type":"integer","format":"int32","minimum":0},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"validated_work_sum":{"type":"number","format":"double","description":"Work locally validated by the client channel."}}},"ServerSummary":{"type":"object","description":"Aggregate information about the server connection","required":["total_channels","extended_channels","standard_channels","total_hashrate"],"properties":{"extended_channels":{"type":"integer","minimum":0},"standard_channels":{"type":"integer","minimum":0},"total_channels":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"StandardChannelInfo":{"type":"object","description":"Information about a standard channel","required":["channel_id","user_identity","nominal_hashrate","stable_hashrate","target_hex","requested_max_target_hex","extranonce_prefix_hex","expected_shares_per_minute","shares_accepted","shares_rejected","shares_rejected_by_reason","share_work_sum","last_share_sequence_number","best_diff","last_batch_accepted","last_batch_work_sum","share_batch_size","blocks_found"],"properties":{"best_diff":{"type":"number","format":"double"},"blocks_found":{"type":"integer","format":"int32","minimum":0},"channel_id":{"type":"integer","format":"int32","minimum":0},"expected_shares_per_minute":{"type":"number","format":"float"},"extranonce_prefix_hex":{"type":"string"},"last_batch_accepted":{"type":"integer","format":"int32","minimum":0},"last_batch_work_sum":{"type":"integer","format":"int64","minimum":0},"last_share_sequence_number":{"type":"integer","format":"int32","minimum":0},"nominal_hashrate":{"type":"number","format":"float"},"requested_max_target_hex":{"type":"string"},"share_batch_size":{"type":"integer","minimum":0},"share_work_sum":{"type":"number","format":"double"},"shares_accepted":{"type":"integer","format":"int32","minimum":0},"shares_rejected":{"type":"integer","format":"int32","minimum":0},"shares_rejected_by_reason":{"type":"object","additionalProperties":{"type":"integer","format":"int32","minimum":0},"propertyNames":{"type":"string"}},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"}}},"Sv1ClientInfo":{"type":"object","description":"Information about a single SV1 client connection","required":["client_id","connection_ip","authorized_worker_name","user_identity","target_hex","stable_hashrate","extranonce1_hex","extranonce2_len"],"properties":{"authorized_worker_name":{"type":"string"},"channel_id":{"type":["integer","null"],"format":"int32","minimum":0},"client_id":{"type":"integer","minimum":0},"connection_ip":{"type":"string"},"extranonce1_hex":{"type":"string"},"extranonce2_len":{"type":"integer","minimum":0},"hashrate":{"type":["number","null"],"format":"float"},"management_ip":{"type":["string","null"],"description":"Miner management IP used for matched telemetry, if discovery found one."},"miner_telemetry":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetry","description":"Latest telemetry fetched from the matched miner management interface."}]},"miner_telemetry_status":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetryStatus","description":"Current discovery and fetch status for miner telemetry matching."}]},"stable_hashrate":{"type":"boolean"},"target_hex":{"type":"string"},"user_identity":{"type":"string"},"version_rolling_mask":{"type":["string","null"]},"version_rolling_min_bit":{"type":["string","null"]}}},"Sv1ClientsResponse":{"type":"object","required":["offset","limit","total","items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Sv1ClientInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"total":{"type":"integer","minimum":0}}},"Sv1ClientsSummary":{"type":"object","description":"Aggregate information about SV1 client connections","required":["total_clients","total_hashrate"],"properties":{"total_clients":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientChannelsResponse":{"type":"object","required":["client_id","offset","limit","total_extended","total_standard","extended_channels","standard_channels"],"properties":{"client_id":{"type":"integer","minimum":0},"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ExtendedChannelInfo"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/StandardChannelInfo"}},"total_extended":{"type":"integer","minimum":0},"total_standard":{"type":"integer","minimum":0}}},"Sv2ClientInfo":{"type":"object","description":"Full information about a single Sv2 client including all channels","required":["client_id","client_kind","extended_channels","standard_channels"],"properties":{"client_id":{"type":"integer","minimum":0},"client_kind":{"$ref":"#/components/schemas/Sv2ClientKind","description":"Classification inferred from the client's SV2 SetupConnection metadata."},"extended_channels":{"type":"array","items":{"$ref":"#/components/schemas/ExtendedChannelInfo"}},"management_ip":{"type":["string","null"],"description":"Miner management IP used for matched telemetry, if discovery found one."},"miner_telemetry":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetry","description":"Latest telemetry fetched from the matched miner management interface."}]},"miner_telemetry_status":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetryStatus","description":"Current discovery and fetch status for miner telemetry matching."}]},"standard_channels":{"type":"array","items":{"$ref":"#/components/schemas/StandardChannelInfo"}}}},"Sv2ClientKind":{"type":"string","description":"Kind of SV2 downstream client connected to this node.","enum":["miner","translator_proxy","unknown"]},"Sv2ClientMetadata":{"type":"object","description":"Sv2 client metadata without channel arrays (for listings)","required":["client_id","client_kind","extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"client_id":{"type":"integer","minimum":0},"client_kind":{"$ref":"#/components/schemas/Sv2ClientKind","description":"Classification inferred from the client's SV2 SetupConnection metadata."},"extended_channels_count":{"type":"integer","minimum":0},"management_ip":{"type":["string","null"],"description":"Miner management IP used for matched telemetry, if discovery found one."},"miner_telemetry":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetry","description":"Latest telemetry fetched from the matched miner management interface."}]},"miner_telemetry_status":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetryStatus","description":"Current discovery and fetch status for miner telemetry matching."}]},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientResponse":{"type":"object","required":["client_id","client_kind","extended_channels_count","standard_channels_count","total_hashrate"],"properties":{"client_id":{"type":"integer","minimum":0},"client_kind":{"$ref":"#/components/schemas/Sv2ClientKind"},"extended_channels_count":{"type":"integer","minimum":0},"management_ip":{"type":["string","null"]},"miner_telemetry":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetry"}]},"miner_telemetry_status":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MinerTelemetryStatus"}]},"standard_channels_count":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}},"Sv2ClientsResponse":{"type":"object","required":["offset","limit","total","items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Sv2ClientMetadata"}},"limit":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"total":{"type":"integer","minimum":0}}},"Sv2ClientsSummary":{"type":"object","description":"Aggregate information about all Sv2 clients","required":["total_clients","total_channels","extended_channels","standard_channels","total_hashrate"],"properties":{"extended_channels":{"type":"integer","minimum":0},"standard_channels":{"type":"integer","minimum":0},"total_channels":{"type":"integer","minimum":0},"total_clients":{"type":"integer","minimum":0},"total_hashrate":{"type":"number","format":"float"}}}}},"tags":[{"name":"health","description":"Health check endpoints"},{"name":"global","description":"Global statistics"},{"name":"server","description":"Server (upstream) monitoring"},{"name":"clients","description":"Clients (downstream) monitoring"},{"name":"sv1","description":"Sv1 clients monitoring (Translator Proxy only)"}]} diff --git a/shared/src/index.ts b/shared/src/index.ts index 90bd9803..4b9ae208 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -3,3 +3,4 @@ export * from './constants.js'; export * from './images.js'; export * from './pools.js'; export * from './format.js'; +export * from './miner-telemetry.js'; diff --git a/shared/src/miner-telemetry.ts b/shared/src/miner-telemetry.ts new file mode 100644 index 00000000..cccd9977 --- /dev/null +++ b/shared/src/miner-telemetry.ts @@ -0,0 +1,45 @@ +const MIN_IPV4_PREFIX = 24; + +export function normalizeMinerTelemetryCidr(value: string | null | undefined): string { + return value?.trim() ?? ''; +} + +function isPrivateIpv4(parts: number[]): boolean { + const [a, b] = parts; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +export function getMinerTelemetryCidrError(value: string | null | undefined): string | null { + const cidr = normalizeMinerTelemetryCidr(value); + if (!cidr) { + return null; + } + + const [address, prefix, extra] = cidr.split('/'); + if (!address || !prefix || extra !== undefined) { + return 'Enter a private IPv4 CIDR such as 192.168.1.0/24'; + } + + const octets = address.split('.').map(Number); + if ( + octets.length !== 4 || + octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) + ) { + return 'Enter a valid IPv4 address'; + } + + const prefixNumber = Number(prefix); + if (!Number.isInteger(prefixNumber) || prefixNumber < MIN_IPV4_PREFIX || prefixNumber > 32) { + return `Use a /${MIN_IPV4_PREFIX} or narrower private IPv4 range`; + } + + if (!isPrivateIpv4(octets)) { + return 'Use the private LAN subnet where your miners expose their web/API interface'; + } + + return null; +} + +export function isValidMinerTelemetryCidr(value: string | null | undefined): boolean { + return getMinerTelemetryCidrError(value) === null; +} diff --git a/shared/src/types.ts b/shared/src/types.ts index ee2bf237..d2d55a7b 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -41,6 +41,7 @@ export interface TranslatorConfig { export interface SetupData { miningMode: MiningMode | null; mode: SetupMode | null; + miner_telemetry_cidr: string; pool: PoolConfig | null; fallbackPools: PoolConfig[]; bitcoin: BitcoinConfig | null; diff --git a/src/components/data/DownstreamWorkerTable.tsx b/src/components/data/DownstreamWorkerTable.tsx index ec1a6c2c..a6243282 100644 --- a/src/components/data/DownstreamWorkerTable.tsx +++ b/src/components/data/DownstreamWorkerTable.tsx @@ -8,16 +8,23 @@ import { TableRow, } from '@/components/ui/table'; import { InfoPopover } from '@/components/ui/info-popover'; -import { cn, formatDifficulty, formatHashrate } from '@/lib/utils'; +import { cn, formatDifficulty, formatHashrate, formatUptime } from '@/lib/utils'; +import type { MinerTelemetry, MinerTelemetryStatus, Sv2ClientKind } from '@/types/api'; export type ChannelType = 'sv1' | 'sv2_standard' | 'sv2_extended'; +export type WorkerHashrateSource = 'miner_telemetry' | 'estimated' | 'unavailable'; export interface DownstreamWorkerRow { connection_id: number; channel_id: number | null; channel_type: ChannelType; user_identity: string; + management_ip?: string | null; + miner_telemetry_status?: MinerTelemetryStatus | null; + miner_telemetry?: MinerTelemetry | null; + client_kind?: Sv2ClientKind | null; estimated_hashrate: number | null; + hashrate_source: WorkerHashrateSource; best_diff: number | null; } @@ -77,6 +84,89 @@ function getChannelTypeClassName(channelType: ChannelType) { } } +function formatWorkerHashrate(worker: DownstreamWorkerRow) { + if (worker.estimated_hashrate === null) return '-'; + const prefix = worker.hashrate_source === 'estimated' ? '~' : ''; + return `${prefix}${formatHashrate(worker.estimated_hashrate)}`; +} + +function getTelemetryStatusLabel(status: MinerTelemetryStatus | null | undefined) { + switch (status) { + case 'matched': + return 'Telemetry matched'; + case 'unmatched': + return 'Telemetry unmatched'; + case 'duplicate_worker_name': + return 'Duplicate worker name'; + case 'fetch_failed': + return 'Telemetry fetch failed'; + default: + return null; + } +} + +function formatTelemetryValue(value: number, maximumFractionDigits = 1) { + return value.toLocaleString(undefined, { + maximumFractionDigits, + }); +} + +function isTelemetryNumber(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function getTelemetryText(value: string | null | undefined) { + const trimmed = value?.trim(); + return trimmed || null; +} + +function getMinerLabel(telemetry: MinerTelemetry | null | undefined) { + if (!telemetry) return '-'; + return [getTelemetryText(telemetry.make), getTelemetryText(telemetry.model)] + .filter(Boolean) + .join(' ') || '-'; +} + +function getFirmwareLabel(telemetry: MinerTelemetry | null | undefined) { + return getTelemetryText(telemetry?.firmware_version) || '-'; +} + +function getMiningStateLabel(telemetry: MinerTelemetry | null | undefined) { + if (!telemetry || telemetry.is_mining === null || telemetry.is_mining === undefined) { + return '-'; + } + return telemetry.is_mining ? 'Mining' : 'Idle'; +} + +function formatTelemetryMetric(value: number | null | undefined, unit: string) { + return isTelemetryNumber(value) ? `${formatTelemetryValue(value)} ${unit}` : '-'; +} + +function getUptimeLabel(telemetry: MinerTelemetry | null | undefined) { + if (!isTelemetryNumber(telemetry?.uptime_secs)) return '-'; + return formatUptime(Math.max(0, Math.round(telemetry.uptime_secs))); +} + +function getTelemetryStatusClassName(status: MinerTelemetryStatus | null | undefined) { + switch (status) { + case 'matched': + return 'text-emerald-500'; + case 'unmatched': + case 'duplicate_worker_name': + case 'fetch_failed': + return 'text-amber-500'; + default: + return 'text-muted-foreground'; + } +} + +function getMiningStateClassName(telemetry: MinerTelemetry | null | undefined) { + if (telemetry?.is_mining !== null && telemetry?.is_mining !== undefined) { + return telemetry.is_mining ? 'text-emerald-500' : 'text-muted-foreground'; + } + return 'text-muted-foreground'; +} + /** * Shared worker table for downstream connections across dashboard modes. */ @@ -105,7 +195,7 @@ export function DownstreamWorkerTable({ No workers connected ) : ( - +
onSort('connection_id')}> @@ -133,17 +223,22 @@ export function DownstreamWorkerTable({ User Identity + Management IP + Telemetry + Miner + Firmware + State + Temp + Power + Efficiency + Uptime onSort('estimated_hashrate')}> - Estimated Hashrate + Hashrate - Your proxy cannot directly measure how fast your miner is hashing. It estimates - hashrate indirectly: it knows the difficulty of the work it assigned you, and it - counts the valid shares you submit. From those two values it calculates how much - hashing you must be doing. This is your estimated hashrate. Sampled every 5 - seconds. May take up to 60 seconds to reflect your miner's actual output after - connecting. + Uses miner-reported telemetry when available. Otherwise falls back to the + proxy's vardiff estimate from submitted shares. @@ -181,8 +276,45 @@ export function DownstreamWorkerTable({ {worker.user_identity || '-'} - - {worker.estimated_hashrate !== null ? `~${formatHashrate(worker.estimated_hashrate)}` : '-'} + + {worker.management_ip || '-'} + + + {getTelemetryStatusLabel(worker.miner_telemetry_status) || '-'} + + + {getMinerLabel(worker.miner_telemetry)} + + + {getFirmwareLabel(worker.miner_telemetry)} + + + {getMiningStateLabel(worker.miner_telemetry)} + + + {formatTelemetryMetric(worker.miner_telemetry?.average_temperature_c, 'C')} + + + {formatTelemetryMetric(worker.miner_telemetry?.power_consumption_w, 'W')} + + + {formatTelemetryMetric(worker.miner_telemetry?.efficiency_j_per_th, 'J/TH')} + + + {getUptimeLabel(worker.miner_telemetry)} + + + {formatWorkerHashrate(worker)} {showBestDiff && ( diff --git a/src/components/settings/ConfigurationTab.tsx b/src/components/settings/ConfigurationTab.tsx index 684b6645..4ea51d52 100644 --- a/src/components/settings/ConfigurationTab.tsx +++ b/src/components/settings/ConfigurationTab.tsx @@ -34,7 +34,9 @@ import { DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE, DEFAULT_MIN_HASHRATE, formatBitcoinCoreVersion, + getMinerTelemetryCidrError, normalizeBitcoinCoreVersion, + normalizeMinerTelemetryCidr, } from '@sv2-ui/shared'; import { Loader2, @@ -73,7 +75,7 @@ function clearPersistedDashboardState() { const SETUP_TARGET_STEP_STORAGE_KEY = 'sv2-ui-setup-target-step'; -type EditingField = null | 'pools' | 'mode' | 'signature' | 'hashrate' | 'advanced'; +type EditingField = null | 'pools' | 'mode' | 'signature' | 'hashrate' | 'telemetry' | 'advanced'; function isPositiveNumber(value: string): boolean { const parsed = Number(value); @@ -120,6 +122,7 @@ export function ConfigurationTab() { const [editSignature, setEditSignature] = useState(''); const [editHashrate, setEditHashrate] = useState(null); const [hashrateInputValid, setHashrateInputValid] = useState(true); + const [editMinerTelemetryCidr, setEditMinerTelemetryCidr] = useState(''); const [editAdvanced, setEditAdvanced] = useState<{ shares_per_minute: string; downstream_extranonce2_size: string; @@ -225,6 +228,11 @@ export function ConfigurationTab() { setEditing('hashrate'); }; + const startEditTelemetry = () => { + setEditMinerTelemetryCidr(config?.miner_telemetry_cidr ?? ''); + setEditing('telemetry'); + }; + const startEditAdvanced = () => { if (!config?.translator) return; const configIsSoloPool = config.miningMode === 'solo' && config.mode === 'no-jd'; @@ -246,6 +254,7 @@ export function ConfigurationTab() { setEditSignature(''); setEditHashrate(null); setHashrateInputValid(true); + setEditMinerTelemetryCidr(''); setEditAdvanced(null); }; @@ -261,6 +270,8 @@ export function ConfigurationTab() { editHashrate !== null && Number.isFinite(editHashrate) && editHashrate > 0; + const minerTelemetryCidrError = getMinerTelemetryCidrError(editMinerTelemetryCidr); + const isMinerTelemetryCidrValid = !minerTelemetryCidrError; const isAdvancedValid = !!editAdvanced && isPositiveNumber(editAdvanced.shares_per_minute) && @@ -294,6 +305,9 @@ export function ConfigurationTab() { ...config.translator, min_hashrate: editHashrate, }; + } else if (editing === 'telemetry') { + if (!isMinerTelemetryCidrValid) return; + updated.miner_telemetry_cidr = normalizeMinerTelemetryCidr(editMinerTelemetryCidr); } else if (editing === 'advanced') { if (!isAdvancedValid || !config.translator || !editAdvanced) return; const configIsSoloPool = config.miningMode === 'solo' && config.mode === 'no-jd'; @@ -773,6 +787,50 @@ export function ConfigurationTab() { /> )} + +

+ LAN CIDR:{' '} + + {config.miner_telemetry_cidr || 'Not set'} + +

+

Recommended to discover miner web/API telemetry.

+ + } + editContent={ +
+ + setEditMinerTelemetryCidr(event.target.value)} + placeholder="192.168.1.0/24" + autoComplete="off" + className="w-full h-9 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> + {minerTelemetryCidrError && ( +

{minerTelemetryCidrError}

+ )} +

+ Private LAN subnet where miners expose their web/API interface. +

+
+ } + /> + {/* Advanced mining configuration */} {config.translator && ( { updateData({ + miner_telemetry_cidr: normalizeMinerTelemetryCidr(minerTelemetryCidr), translator: { enable_vardiff: true, aggregate_channels: data.translator?.aggregate_channels ?? false, @@ -84,7 +89,7 @@ export function HashrateStep({ data, updateData, onNext }: StepProps) { }); // intentionally excluded: data.translator and updateData cause infinite loop when included // eslint-disable-next-line react-hooks/exhaustive-deps -}, [hashrate, sharesPerMinute, downstreamExtranonce2Size, verifyPayout, isSoloPool]); +}, [hashrate, minerTelemetryCidr, sharesPerMinute, downstreamExtranonce2Size, verifyPayout, isSoloPool]); return (
@@ -147,6 +152,34 @@ export function HashrateStep({ data, updateData, onNext }: StepProps) { ); })()} +
+
+ + setMinerTelemetryCidr(event.target.value)} + placeholder="192.168.1.0/24" + autoComplete="off" + aria-invalid={Boolean(minerTelemetryCidrError)} + aria-describedby="miner-telemetry-cidr-desc miner-telemetry-cidr-error" + className="w-full h-10 px-3 rounded-lg border border-input bg-background font-mono text-sm focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/15 outline-none transition-all" + /> + {minerTelemetryCidrError && ( +

+ {minerTelemetryCidrError} +

+ )} +

+ Recommended for better telemetry. Use the private LAN subnet where miners expose + their web/API interface. +

+
+
+