Skip to main content

stratum_apps/monitoring/
http_server.rs

1//! HTTP server for exposing monitoring data using Axum
2
3#[cfg(feature = "asic-rs-telemetry")]
4use super::miner_telemetry::MinerTelemetry;
5use super::{
6    client::{
7        ExtendedChannelInfo, StandardChannelInfo, Sv2ClientInfo, Sv2ClientMetadata,
8        Sv2ClientsMonitoring, Sv2ClientsSummary,
9    },
10    prometheus_metrics::PrometheusMetrics,
11    routes,
12    server::{
13        ServerExtendedChannelInfo, ServerMonitoring, ServerStandardChannelInfo, ServerSummary,
14    },
15    snapshot_cache::SnapshotCache,
16    sv1::{Sv1ClientInfo, Sv1ClientsMonitoring, Sv1ClientsSummary},
17    GlobalInfo,
18};
19use axum::{
20    extract::{Path, Query, State},
21    http::StatusCode,
22    response::{IntoResponse, Json, Response},
23    routing::get,
24    Router,
25};
26use prometheus::{Encoder, TextEncoder};
27use serde::{Deserialize, Serialize};
28use std::{
29    future::Future,
30    net::SocketAddr,
31    sync::Arc,
32    time::{Duration, SystemTime, UNIX_EPOCH},
33};
34use tokio::net::TcpListener;
35use tracing::info;
36use utoipa::{IntoParams, OpenApi, ToSchema};
37use utoipa_swagger_ui::SwaggerUi;
38
39#[derive(OpenApi)]
40#[cfg_attr(
41    feature = "asic-rs-telemetry",
42    openapi(components(schemas(MinerTelemetry)))
43)]
44#[openapi(
45    info(
46        // This `info` block is the single source of truth for the API title
47        // and version. `handle_root` reads them back from `ApiDoc::openapi()`
48        // at runtime instead of duplicating the literals.
49        title = "SRI Monitoring API",
50        version = "0.1.0",
51        description = "HTTP JSON API for monitoring SV2 applications"
52    ),
53    paths(
54        handle_health,
55        handle_global,
56        handle_server,
57        handle_server_channels,
58        handle_clients,
59        handle_client_by_id,
60        handle_client_channels,
61        handle_sv1_clients,
62        handle_sv1_client_by_id,
63    ),
64    components(schemas(
65        GlobalInfo,
66        ServerSummary,
67        Sv2ClientsSummary,
68        ServerExtendedChannelInfo,
69        ServerStandardChannelInfo,
70        Sv2ClientInfo,
71        Sv2ClientMetadata,
72        ExtendedChannelInfo,
73        StandardChannelInfo,
74        Sv1ClientInfo,
75        Sv1ClientsSummary,
76        HealthResponse,
77        ErrorResponse,
78        ServerResponse,
79        ServerChannelsResponse,
80        Sv2ClientsResponse,
81        Sv2ClientResponse,
82        Sv2ClientChannelsResponse,
83        Sv1ClientsResponse,
84    )),
85    tags(
86        (name = "health", description = "Health check endpoints"),
87        (name = "global", description = "Global statistics"),
88        (name = "server", description = "Server (upstream) monitoring"),
89        (name = "clients", description = "Clients (downstream) monitoring"),
90        (name = "sv1", description = "Sv1 clients monitoring (Translator Proxy only)")
91    )
92)]
93pub struct ApiDoc;
94
95/// Shared state for all HTTP handlers
96#[derive(Clone)]
97struct ServerState {
98    cache: Arc<SnapshotCache>,
99    start_time: u64,
100    metrics: PrometheusMetrics,
101}
102
103const DEFAULT_LIMIT: usize = 25;
104const MAX_LIMIT: usize = 100;
105
106#[derive(Deserialize, IntoParams)]
107struct Pagination {
108    /// Offset for pagination (default: 0)
109    #[serde(default)]
110    offset: usize,
111    /// Limit for pagination (default: 25, max: 100)
112    #[serde(default)]
113    limit: Option<usize>,
114}
115
116impl Pagination {
117    fn effective_limit(&self) -> usize {
118        self.limit
119            .map(|l| l.min(MAX_LIMIT))
120            .unwrap_or(DEFAULT_LIMIT)
121    }
122}
123
124fn paginate<T: Clone>(items: &[T], params: &Pagination) -> (usize, Vec<T>) {
125    let total = items.len();
126    let limit = params.effective_limit();
127    let offset = params.offset.min(total);
128    let sliced = items
129        .iter()
130        .skip(offset)
131        .take(limit)
132        .cloned()
133        .collect::<Vec<_>>();
134    (total, sliced)
135}
136
137/// HTTP server that exposes monitoring data as JSON
138pub struct MonitoringServer {
139    bind_address: SocketAddr,
140    state: ServerState,
141    refresh_interval: Duration,
142}
143
144impl MonitoringServer {
145    /// Create a new monitoring server with automatic cache refresh.
146    ///
147    /// This constructor creates a snapshot cache that decouples monitoring API
148    /// requests from business logic locks, eliminating the DoS vulnerability where
149    /// rapid API requests could cause lock contention with share validation and
150    /// job distribution.
151    ///
152    /// The cache is automatically refreshed in the background at the specified interval.
153    ///
154    /// # Arguments
155    ///
156    /// * `bind_address` - Address to bind the HTTP server to
157    /// * `server_monitoring` - Optional server (upstream) monitoring trait object
158    /// * `sv2_clients_monitoring` - Optional Sv2 clients (downstream) monitoring trait object
159    /// * `refresh_interval` - How often to refresh the cache (e.g., Duration::from_secs(15))
160    pub fn new(
161        bind_address: SocketAddr,
162        server_monitoring: Option<Arc<dyn ServerMonitoring + Send + Sync + 'static>>,
163        sv2_clients_monitoring: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync + 'static>>,
164        refresh_interval: Duration,
165    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
166        let start_time = SystemTime::now()
167            .duration_since(UNIX_EPOCH)
168            .unwrap_or_default()
169            .as_secs();
170
171        let has_server = server_monitoring.is_some();
172        let has_sv2_clients = sv2_clients_monitoring.is_some();
173
174        let metrics = PrometheusMetrics::new(has_server, has_sv2_clients, false)?;
175
176        // Create the snapshot cache with metrics attached so refresh()
177        // updates Prometheus gauges atomically alongside the snapshot data.
178        let cache = Arc::new(
179            SnapshotCache::new(refresh_interval, server_monitoring, sv2_clients_monitoring)
180                .with_metrics(metrics.clone()),
181        );
182
183        // Do initial refresh (populates both snapshot and Prometheus gauges)
184        cache.refresh();
185
186        Ok(Self {
187            bind_address,
188            refresh_interval,
189            state: ServerState {
190                cache,
191                start_time,
192                metrics,
193            },
194        })
195    }
196
197    /// Add Sv1 clients monitoring (optional, for Translator Proxy only)
198    ///
199    /// This must be called before `run()` if you want SV1 monitoring.
200    pub fn with_sv1_monitoring(
201        mut self,
202        sv1_monitoring: Arc<dyn Sv1ClientsMonitoring + Send + Sync + 'static>,
203    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
204        // Determine what sources the cache already has
205        let snapshot = self.state.cache.get_snapshot();
206        let has_server = snapshot.server_info.is_some();
207        let has_sv2_clients = snapshot.sv2_clients_summary.is_some();
208
209        // Create metrics with SV1 monitoring enabled
210        let metrics = PrometheusMetrics::new(has_server, has_sv2_clients, true)?;
211
212        // Add Sv1 clients source and attach new metrics to the cache
213        let cache = Arc::new(
214            Arc::try_unwrap(self.state.cache)
215                .unwrap_or_else(|arc| (*arc).clone())
216                .with_sv1_clients_source(sv1_monitoring)
217                .with_metrics(metrics.clone()),
218        );
219
220        // Refresh cache with new SV1 data (also updates Prometheus gauges)
221        cache.refresh();
222
223        self.state.metrics = metrics;
224        self.state.cache = cache;
225
226        Ok(self)
227    }
228
229    /// Run the monitoring server until the shutdown signal completes
230    ///
231    /// Starts an HTTP server that exposes monitoring data as JSON.
232    /// Also starts a background task that refreshes the snapshot cache periodically.
233    /// Both tasks shut down gracefully when `shutdown_signal` completes.
234    ///
235    /// Automatically exposes:
236    /// - Swagger UI at `/swagger-ui`
237    /// - OpenAPI spec at `/api-docs/openapi.json`
238    /// - Prometheus metrics at `/metrics`
239    pub async fn run(
240        self,
241        shutdown_signal: impl Future<Output = ()> + Send + 'static,
242    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
243        info!("Starting monitoring server on http://{}", self.bind_address);
244        info!("Cache refresh interval: {:?}", self.refresh_interval);
245
246        let listener = TcpListener::bind(self.bind_address).await?;
247
248        // Spawn background task to refresh cache periodically
249        let cache_for_refresh = self.state.cache.clone();
250        let refresh_interval = self.refresh_interval;
251        let refresh_handle = tokio::spawn(async move {
252            let mut interval = tokio::time::interval(refresh_interval);
253            loop {
254                interval.tick().await;
255                cache_for_refresh.refresh();
256            }
257        });
258
259        // Versioned JSON API under /api/v1
260        let api_v1 = Router::new()
261            .route(routes::segments::HEALTH, get(handle_health))
262            .route(routes::segments::GLOBAL, get(handle_global))
263            .route(routes::segments::SERVER, get(handle_server))
264            .route(
265                routes::segments::SERVER_CHANNELS,
266                get(handle_server_channels),
267            )
268            .route(routes::segments::CLIENTS, get(handle_clients))
269            .route(routes::segments::CLIENT_BY_ID, get(handle_client_by_id))
270            .route(
271                routes::segments::CLIENT_CHANNELS,
272                get(handle_client_channels),
273            )
274            .route(routes::segments::SV1_CLIENTS, get(handle_sv1_clients))
275            .route(
276                routes::segments::SV1_CLIENT_BY_ID,
277                get(handle_sv1_client_by_id),
278            );
279
280        let app = Router::new()
281            .route(routes::ROOT, get(handle_root))
282            .merge(SwaggerUi::new(routes::SWAGGER_UI).url(routes::OPENAPI_SPEC, ApiDoc::openapi()))
283            .nest(routes::API_V1_PREFIX, api_v1)
284            .route(routes::METRICS, get(handle_prometheus_metrics))
285            .with_state(self.state);
286
287        info!(
288            "Swagger UI available at http://{}/swagger-ui",
289            self.bind_address
290        );
291        info!(
292            "Prometheus metrics available at http://{}/metrics",
293            self.bind_address
294        );
295
296        let server_handle = axum::serve(listener, app).with_graceful_shutdown(async move {
297            shutdown_signal.await;
298            info!("Monitoring server received shutdown signal, stopping...");
299        });
300
301        // Run server and wait for shutdown
302        let result = server_handle.await;
303
304        // Stop the refresh task
305        refresh_handle.abort();
306
307        info!("Monitoring server stopped");
308        result.map_err(|e| e.into())
309    }
310}
311
312// Response types — used for both actual responses, OpenAPI documentation,
313// and as the canonical types for JSON deserialization in tests (both
314// in-crate unit tests and downstream integration tests).
315#[derive(Serialize, Deserialize, Debug, ToSchema)]
316pub struct HealthResponse {
317    pub status: String,
318    pub timestamp: u64,
319}
320
321#[derive(Serialize, Deserialize, Debug, ToSchema)]
322pub struct ErrorResponse {
323    pub error: String,
324}
325
326#[derive(Serialize, Deserialize, Debug, ToSchema)]
327pub struct ServerResponse {
328    pub extended_channels_count: usize,
329    pub standard_channels_count: usize,
330    pub total_hashrate: f32,
331}
332
333#[derive(Serialize, Deserialize, Debug, ToSchema)]
334pub struct ServerChannelsResponse {
335    pub offset: usize,
336    pub limit: usize,
337    pub total_extended: usize,
338    pub total_standard: usize,
339    pub extended_channels: Vec<ServerExtendedChannelInfo>,
340    pub standard_channels: Vec<ServerStandardChannelInfo>,
341}
342
343#[derive(Serialize, Deserialize, Debug, ToSchema)]
344pub struct Sv2ClientsResponse {
345    pub offset: usize,
346    pub limit: usize,
347    pub total: usize,
348    pub items: Vec<Sv2ClientMetadata>,
349}
350
351#[derive(Serialize, Deserialize, Debug, ToSchema)]
352pub struct Sv2ClientResponse {
353    pub client_id: usize,
354    pub extended_channels_count: usize,
355    pub standard_channels_count: usize,
356    pub total_hashrate: f32,
357    #[cfg(feature = "asic-rs-telemetry")]
358    pub miner_telemetry: Option<MinerTelemetry>,
359}
360
361#[derive(Serialize, Deserialize, Debug, ToSchema)]
362pub struct Sv2ClientChannelsResponse {
363    pub client_id: usize,
364    pub offset: usize,
365    pub limit: usize,
366    pub total_extended: usize,
367    pub total_standard: usize,
368    pub extended_channels: Vec<ExtendedChannelInfo>,
369    pub standard_channels: Vec<StandardChannelInfo>,
370}
371
372#[derive(Serialize, Deserialize, Debug, ToSchema)]
373pub struct Sv1ClientsResponse {
374    pub offset: usize,
375    pub limit: usize,
376    pub total: usize,
377    pub items: Vec<Sv1ClientInfo>,
378}
379
380/// Response shape for the root `/` endpoint: a service banner plus a map of
381/// available endpoints to their human-readable descriptions.
382#[derive(Serialize, Deserialize, Debug)]
383pub struct RootResponse {
384    pub service: String,
385    pub version: String,
386    pub endpoints: std::collections::BTreeMap<String, String>,
387}
388
389/// Root endpoint - lists all available APIs
390async fn handle_root() -> Json<RootResponse> {
391    let mut endpoints = std::collections::BTreeMap::new();
392    endpoints.insert(
393        routes::ROOT.to_string(),
394        "This endpoint - API listing".to_string(),
395    );
396    endpoints.insert(
397        routes::SWAGGER_UI.to_string(),
398        "Swagger UI (interactive API documentation)".to_string(),
399    );
400    endpoints.insert(
401        routes::OPENAPI_SPEC.to_string(),
402        "OpenAPI specification".to_string(),
403    );
404    endpoints.insert(routes::HEALTH.to_string(), "Health check".to_string());
405    endpoints.insert(routes::GLOBAL.to_string(), "Global statistics".to_string());
406    endpoints.insert(routes::SERVER.to_string(), "Server metadata".to_string());
407    endpoints.insert(
408        routes::SERVER_CHANNELS.to_string(),
409        "Server channels (paginated)".to_string(),
410    );
411    endpoints.insert(
412        routes::CLIENTS.to_string(),
413        "All Sv2 clients metadata (paginated)".to_string(),
414    );
415    endpoints.insert(
416        routes::CLIENT_BY_ID_PATTERN.to_string(),
417        "Single Sv2 client metadata".to_string(),
418    );
419    endpoints.insert(
420        routes::CLIENT_CHANNELS_PATTERN.to_string(),
421        "Sv2 client channels (paginated)".to_string(),
422    );
423    endpoints.insert(
424        routes::SV1_CLIENTS.to_string(),
425        "Sv1 clients (Translator Proxy only, paginated)".to_string(),
426    );
427    endpoints.insert(
428        routes::SV1_CLIENT_BY_ID_PATTERN.to_string(),
429        "Single Sv1 client (Translator Proxy only)".to_string(),
430    );
431    endpoints.insert(
432        routes::METRICS.to_string(),
433        "Prometheus metrics".to_string(),
434    );
435
436    // Pull title/version from the OpenAPI spec so the `/` listing, the
437    // OpenAPI document, and Swagger UI always agree.
438    let info = ApiDoc::openapi().info;
439    Json(RootResponse {
440        service: info.title,
441        version: info.version,
442        endpoints,
443    })
444}
445
446// Note: the `path = "..."` arguments to `#[utoipa::path(...)]` below must be
447// string literals — utoipa parses them at macro-expansion time and does not
448// accept `const` references. They must be kept in sync with `routes::*`.
449
450/// Health check endpoint
451#[utoipa::path(
452    get,
453    path = "/api/v1/health",
454    tag = "health",
455    responses(
456        (status = 200, description = "Service is healthy", body = HealthResponse)
457    )
458)]
459async fn handle_health() -> Json<HealthResponse> {
460    Json(HealthResponse {
461        status: "ok".to_string(),
462        timestamp: SystemTime::now()
463            .duration_since(UNIX_EPOCH)
464            .unwrap_or_default()
465            .as_secs(),
466    })
467}
468
469/// Get global statistics
470///
471/// Returns aggregated statistics for the server (upstream) and clients (downstream).
472/// Fields are omitted from the response if that type of monitoring is not enabled.
473///
474/// **Typical responses:**
475/// - **Pool/JDC**: `server` + `clients` (Sv2 downstream)
476/// - **tProxy**: `server` + `sv1_clients` (Sv1 miners)
477#[utoipa::path(
478    get,
479    path = "/api/v1/global",
480    tag = "global",
481    responses(
482        (status = 200, description = "Global statistics", body = GlobalInfo)
483    )
484)]
485async fn handle_global(State(state): State<ServerState>) -> Json<GlobalInfo> {
486    let uptime_secs = SystemTime::now()
487        .duration_since(UNIX_EPOCH)
488        .unwrap_or_default()
489        .as_secs()
490        - state.start_time;
491
492    let snapshot = state.cache.get_snapshot();
493
494    Json(GlobalInfo {
495        server: snapshot.server_summary,
496        sv2_clients: snapshot.sv2_clients_summary,
497        sv1_clients: snapshot.sv1_clients_summary,
498        uptime_secs,
499    })
500}
501
502/// Get server (upstream) metadata - use /server/channels for channel details
503#[utoipa::path(
504    get,
505    path = "/api/v1/server",
506    tag = "server",
507    responses(
508        (status = 200, description = "Server metadata", body = ServerResponse),
509        (status = 404, description = "Server monitoring not available", body = ErrorResponse)
510    )
511)]
512async fn handle_server(State(state): State<ServerState>) -> Response {
513    let snapshot = state.cache.get_snapshot();
514
515    match snapshot.server_summary {
516        Some(summary) => Json(ServerResponse {
517            extended_channels_count: summary.extended_channels,
518            standard_channels_count: summary.standard_channels,
519            total_hashrate: summary.total_hashrate,
520        })
521        .into_response(),
522        None => (
523            StatusCode::NOT_FOUND,
524            Json(ErrorResponse {
525                error: "Server monitoring not available".to_string(),
526            }),
527        )
528            .into_response(),
529    }
530}
531
532/// Get server channels (paginated)
533#[utoipa::path(
534    get,
535    path = "/api/v1/server/channels",
536    tag = "server",
537    params(Pagination),
538    responses(
539        (status = 200, description = "Server channels (paginated)", body = ServerChannelsResponse),
540        (status = 404, description = "Server monitoring not available", body = ErrorResponse)
541    )
542)]
543async fn handle_server_channels(
544    Query(params): Query<Pagination>,
545    State(state): State<ServerState>,
546) -> Response {
547    let snapshot = state.cache.get_snapshot();
548
549    match snapshot.server_info {
550        Some(server) => {
551            let (total_extended, extended_channels) = paginate(&server.extended_channels, &params);
552            let (total_standard, standard_channels) = paginate(&server.standard_channels, &params);
553
554            Json(ServerChannelsResponse {
555                offset: params.offset,
556                limit: params.effective_limit(),
557                total_extended,
558                total_standard,
559                extended_channels,
560                standard_channels,
561            })
562            .into_response()
563        }
564        None => (
565            StatusCode::NOT_FOUND,
566            Json(ErrorResponse {
567                error: "Server monitoring not available".to_string(),
568            }),
569        )
570            .into_response(),
571    }
572}
573
574/// Get all Sv2 clients (downstream) - returns metadata only, use /clients/{id}/channels for
575/// channels
576#[utoipa::path(
577    get,
578    path = "/api/v1/clients",
579    tag = "clients",
580    params(Pagination),
581    responses(
582        (status = 200, description = "List of Sv2 clients (metadata only)", body = Sv2ClientsResponse),
583        (status = 404, description = "Sv2 clients monitoring not available", body = ErrorResponse)
584    )
585)]
586async fn handle_clients(
587    Query(params): Query<Pagination>,
588    State(state): State<ServerState>,
589) -> Response {
590    let snapshot = state.cache.get_snapshot();
591
592    match snapshot.sv2_clients {
593        Some(ref sv2_clients) => {
594            let metadata: Vec<Sv2ClientMetadata> =
595                sv2_clients.iter().map(|c| c.to_metadata()).collect();
596            let (total, items) = paginate(&metadata, &params);
597
598            Json(Sv2ClientsResponse {
599                offset: params.offset,
600                limit: params.effective_limit(),
601                total,
602                items,
603            })
604            .into_response()
605        }
606        None => (
607            StatusCode::NOT_FOUND,
608            Json(ErrorResponse {
609                error: "Sv2 clients monitoring not available".to_string(),
610            }),
611        )
612            .into_response(),
613    }
614}
615
616/// Get a single Sv2 client by ID - returns metadata only, use /clients/{id}/channels for channels
617#[utoipa::path(
618    get,
619    path = "/api/v1/clients/{client_id}",
620    tag = "clients",
621    params(
622        ("client_id" = usize, Path, description = "Sv2 Client ID")
623    ),
624    responses(
625        (status = 200, description = "Sv2 client metadata", body = Sv2ClientResponse),
626        (status = 404, description = "Sv2 client not found", body = ErrorResponse)
627    )
628)]
629async fn handle_client_by_id(
630    Path(client_id): Path<usize>,
631    State(state): State<ServerState>,
632) -> Response {
633    let snapshot = state.cache.get_snapshot();
634
635    let sv2_clients = match snapshot.sv2_clients {
636        Some(ref clients) => clients,
637        None => {
638            return (
639                StatusCode::NOT_FOUND,
640                Json(ErrorResponse {
641                    error: "Sv2 clients monitoring not available".to_string(),
642                }),
643            )
644                .into_response();
645        }
646    };
647
648    match sv2_clients.iter().find(|c| c.client_id == client_id) {
649        Some(client) => Json(Sv2ClientResponse {
650            client_id,
651            extended_channels_count: client.extended_channels.len(),
652            standard_channels_count: client.standard_channels.len(),
653            total_hashrate: client.total_hashrate(),
654            #[cfg(feature = "asic-rs-telemetry")]
655            miner_telemetry: client.miner_telemetry.clone(),
656        })
657        .into_response(),
658        None => (
659            StatusCode::NOT_FOUND,
660            Json(ErrorResponse {
661                error: format!("Sv2 client {client_id} not found"),
662            }),
663        )
664            .into_response(),
665    }
666}
667
668/// Get channels for a specific Sv2 client (paginated)
669#[utoipa::path(
670    get,
671    path = "/api/v1/clients/{client_id}/channels",
672    tag = "clients",
673    params(
674        ("client_id" = usize, Path, description = "Sv2 Client ID"),
675        Pagination
676    ),
677    responses(
678        (status = 200, description = "Sv2 client channels (paginated)", body = Sv2ClientChannelsResponse),
679        (status = 404, description = "Sv2 client not found", body = ErrorResponse)
680    )
681)]
682async fn handle_client_channels(
683    Path(client_id): Path<usize>,
684    Query(params): Query<Pagination>,
685    State(state): State<ServerState>,
686) -> Response {
687    let snapshot = state.cache.get_snapshot();
688
689    let sv2_clients = match snapshot.sv2_clients {
690        Some(ref clients) => clients,
691        None => {
692            return (
693                StatusCode::NOT_FOUND,
694                Json(ErrorResponse {
695                    error: "Sv2 clients monitoring not available".to_string(),
696                }),
697            )
698                .into_response();
699        }
700    };
701
702    match sv2_clients.iter().find(|c| c.client_id == client_id) {
703        Some(client) => {
704            let (total_extended, extended_channels) = paginate(&client.extended_channels, &params);
705            let (total_standard, standard_channels) = paginate(&client.standard_channels, &params);
706
707            Json(Sv2ClientChannelsResponse {
708                client_id,
709                offset: params.offset,
710                limit: params.effective_limit(),
711                total_extended,
712                total_standard,
713                extended_channels,
714                standard_channels,
715            })
716            .into_response()
717        }
718        None => (
719            StatusCode::NOT_FOUND,
720            Json(ErrorResponse {
721                error: format!("Sv2 client {client_id} not found"),
722            }),
723        )
724            .into_response(),
725    }
726}
727
728/// Get Sv1 clients (Translator Proxy only)
729#[utoipa::path(
730    get,
731    path = "/api/v1/sv1/clients",
732    tag = "sv1",
733    params(Pagination),
734    responses(
735        (status = 200, description = "List of Sv1 clients", body = Sv1ClientsResponse),
736        (status = 404, description = "Sv1 monitoring not available", body = ErrorResponse)
737    )
738)]
739async fn handle_sv1_clients(
740    Query(params): Query<Pagination>,
741    State(state): State<ServerState>,
742) -> Response {
743    let snapshot = state.cache.get_snapshot();
744
745    match snapshot.sv1_clients {
746        Some(ref sv1_clients) => {
747            let (total, items) = paginate(sv1_clients, &params);
748
749            Json(Sv1ClientsResponse {
750                offset: params.offset,
751                limit: params.effective_limit(),
752                total,
753                items,
754            })
755            .into_response()
756        }
757        None => (
758            StatusCode::NOT_FOUND,
759            Json(ErrorResponse {
760                error: "Sv1 client monitoring not available".to_string(),
761            }),
762        )
763            .into_response(),
764    }
765}
766
767/// Get a single Sv1 client by ID
768#[utoipa::path(
769    get,
770    path = "/api/v1/sv1/clients/{client_id}",
771    tag = "sv1",
772    params(
773        ("client_id" = usize, Path, description = "Sv1 client ID")
774    ),
775    responses(
776        (status = 200, description = "Sv1 client details", body = Sv1ClientInfo),
777        (status = 404, description = "Sv1 client not found", body = ErrorResponse)
778    )
779)]
780async fn handle_sv1_client_by_id(
781    Path(client_id): Path<usize>,
782    State(state): State<ServerState>,
783) -> Response {
784    let snapshot = state.cache.get_snapshot();
785
786    let sv1_clients = match snapshot.sv1_clients {
787        Some(ref clients) => clients,
788        None => {
789            return (
790                StatusCode::NOT_FOUND,
791                Json(ErrorResponse {
792                    error: "Sv1 client monitoring not available".to_string(),
793                }),
794            )
795                .into_response();
796        }
797    };
798
799    match sv1_clients.iter().find(|c| c.client_id == client_id) {
800        Some(client) => Json(client.clone()).into_response(),
801        None => (
802            StatusCode::NOT_FOUND,
803            Json(ErrorResponse {
804                error: format!("Sv1 client {client_id} not found"),
805            }),
806        )
807            .into_response(),
808    }
809}
810
811/// Handler for Prometheus metrics endpoint.
812///
813/// All GaugeVec metric values are updated atomically by the background cache refresh
814/// task in `SnapshotCache::refresh()`. This handler only needs to:
815/// 1. Set the uptime gauge (requires wall-clock time at scrape time)
816/// 2. Gather and encode all registered metrics
817///
818/// Because metric values are always kept in sync with the snapshot data, there is
819/// never a gap where label series momentarily disappear. Tests can assert on metrics
820/// directly after a cache refresh without polling for transient states.
821async fn handle_prometheus_metrics(State(state): State<ServerState>) -> Response {
822    // Uptime is the only metric set at scrape time (needs current wall clock)
823    let uptime_secs = SystemTime::now()
824        .duration_since(UNIX_EPOCH)
825        .unwrap_or_default()
826        .as_secs()
827        - state.start_time;
828    state.metrics.sv2_uptime_seconds.set(uptime_secs as f64);
829
830    // Gather and encode — all other metrics were set by the last cache refresh
831    let encoder = TextEncoder::new();
832    let metric_families = state.metrics.registry.gather();
833    let mut buffer = Vec::new();
834
835    match encoder.encode(&metric_families, &mut buffer) {
836        Ok(_) => match String::from_utf8(buffer) {
837            Ok(metrics_text) => (StatusCode::OK, metrics_text).into_response(),
838            Err(e) => (
839                StatusCode::INTERNAL_SERVER_ERROR,
840                Json(ErrorResponse {
841                    error: format!("UTF-8 error: {e}"),
842                }),
843            )
844                .into_response(),
845        },
846        Err(e) => (
847            StatusCode::INTERNAL_SERVER_ERROR,
848            Json(ErrorResponse {
849                error: format!("Encoding error: {e}"),
850            }),
851        )
852            .into_response(),
853    }
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859    use crate::monitoring::server::ServerInfo;
860    use axum::body::Body;
861    use http_body_util::BodyExt;
862    use std::{collections::HashMap, sync::Mutex};
863    use stratum_core::mining_sv2::ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE;
864    use tower::ServiceExt;
865
866    // ── helpers ──────────────────────────────────────────────────────
867
868    fn create_extended_channel_info(channel_id: u32, hashrate: f32) -> ExtendedChannelInfo {
869        ExtendedChannelInfo {
870            channel_id,
871            user_identity: format!("user-ext-{}", channel_id),
872            nominal_hashrate: hashrate,
873            stable_hashrate: false,
874            target_hex: "00ff".into(),
875            requested_max_target_hex: "00ff".into(),
876            extranonce_prefix_hex: "aa".into(),
877            full_extranonce_size: 16,
878            rollable_extranonce_size: 4,
879            expected_shares_per_minute: 1.0,
880            shares_accepted: 10,
881            shares_rejected: 0,
882            shares_rejected_by_reason: HashMap::new(),
883            share_work_sum: 100.0,
884            last_share_sequence_number: 5,
885            best_diff: 50.0,
886            last_batch_accepted: 3,
887            last_batch_work_sum: 30,
888            share_batch_size: 10,
889            blocks_found: 0,
890        }
891    }
892
893    fn create_standard_channel_info(channel_id: u32, hashrate: f32) -> StandardChannelInfo {
894        StandardChannelInfo {
895            channel_id,
896            user_identity: format!("user-std-{}", channel_id),
897            nominal_hashrate: hashrate,
898            stable_hashrate: false,
899            target_hex: "00ff".into(),
900            requested_max_target_hex: "00ff".into(),
901            extranonce_prefix_hex: "bb".into(),
902            expected_shares_per_minute: 2.0,
903            shares_accepted: 20,
904            shares_rejected: 1,
905            shares_rejected_by_reason: HashMap::from([(
906                ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(),
907                1,
908            )]),
909            share_work_sum: 200.0,
910            last_share_sequence_number: 8,
911            best_diff: 80.0,
912            last_batch_accepted: 5,
913            last_batch_work_sum: 50,
914            share_batch_size: 20,
915            blocks_found: 0,
916        }
917    }
918
919    fn create_server_extended_channel_info(
920        channel_id: u32,
921        hashrate: Option<f32>,
922    ) -> ServerExtendedChannelInfo {
923        ServerExtendedChannelInfo {
924            channel_id,
925            user_identity: format!("pool-ext-{}", channel_id),
926            nominal_hashrate: hashrate,
927            target_hex: "00ff".into(),
928            extranonce_prefix_hex: "aa".into(),
929            full_extranonce_size: 16,
930            rollable_extranonce_size: 4,
931            version_rolling: true,
932            shares_acknowledged: 10,
933            shares_rejected: 0,
934            shares_rejected_by_reason: HashMap::new(),
935            acknowledged_work_sum: 100,
936            validated_work_sum: 100.0,
937            shares_submitted: 12,
938            best_diff: 50.0,
939            blocks_found: 0,
940        }
941    }
942
943    fn create_server_standard_channel_info(
944        channel_id: u32,
945        hashrate: Option<f32>,
946    ) -> ServerStandardChannelInfo {
947        ServerStandardChannelInfo {
948            channel_id,
949            user_identity: format!("pool-std-{}", channel_id),
950            nominal_hashrate: hashrate,
951            target_hex: "00ff".into(),
952            extranonce_prefix_hex: "bb".into(),
953            shares_acknowledged: 20,
954            shares_submitted: 22,
955            shares_rejected: 1,
956            shares_rejected_by_reason: HashMap::from([(
957                ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(),
958                1,
959            )]),
960            acknowledged_work_sum: 200,
961            validated_work_sum: 200.0,
962            best_diff: 80.0,
963            blocks_found: 0,
964        }
965    }
966
967    #[cfg(feature = "asic-rs-telemetry")]
968    fn create_miner_telemetry() -> MinerTelemetry {
969        MinerTelemetry {
970            make: Some("Acme".into()),
971            model: Some("HashBox".into()),
972            firmware_version: Some("1.2.3".into()),
973            reported_hashrate_hs: Some(100_000_000_000_000.0),
974            power_consumption_w: Some(3200.0),
975            efficiency_j_per_th: Some(32.0),
976            average_temperature_c: Some(68.0),
977            uptime_secs: Some(3600),
978            is_mining: Some(true),
979        }
980    }
981
982    fn create_sv1_client_info(id: usize, hashrate: Option<f32>) -> Sv1ClientInfo {
983        Sv1ClientInfo {
984            client_id: id,
985            channel_id: Some(id as u32),
986            connection_ip: format!("192.0.2.{}", id)
987                .parse()
988                .expect("test IP address must be valid"),
989            authorized_worker_name: format!("worker-{}", id),
990            user_identity: format!("miner-{}", id),
991            target_hex: "00ff".into(),
992            hashrate,
993            #[cfg(feature = "asic-rs-telemetry")]
994            miner_telemetry: None,
995            stable_hashrate: false,
996            extranonce1_hex: "aabb".into(),
997            extranonce2_len: 8,
998            version_rolling_mask: Some("ffffffff".into()),
999            version_rolling_min_bit: Some("00000000".into()),
1000        }
1001    }
1002
1003    struct MockServer(ServerInfo);
1004    impl ServerMonitoring for MockServer {
1005        fn get_server(&self) -> ServerInfo {
1006            self.0.clone()
1007        }
1008    }
1009
1010    struct MockClients(Vec<Sv2ClientInfo>);
1011    impl Sv2ClientsMonitoring for MockClients {
1012        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
1013            self.0.clone()
1014        }
1015    }
1016
1017    struct MockSv1Clients(Vec<Sv1ClientInfo>);
1018    impl Sv1ClientsMonitoring for MockSv1Clients {
1019        fn get_sv1_clients(&self) -> Vec<Sv1ClientInfo> {
1020            self.0.clone()
1021        }
1022    }
1023
1024    /// Build a full Router with mock data for integration testing.
1025    fn build_test_app(
1026        server: Option<Arc<dyn ServerMonitoring + Send + Sync>>,
1027        clients: Option<Arc<dyn Sv2ClientsMonitoring + Send + Sync>>,
1028        sv1: Option<Arc<dyn Sv1ClientsMonitoring + Send + Sync>>,
1029    ) -> Router {
1030        let has_server = server.is_some();
1031        let has_clients = clients.is_some();
1032        let has_sv1 = sv1.is_some();
1033
1034        let metrics = PrometheusMetrics::new(has_server, has_clients, has_sv1).unwrap();
1035
1036        let cache = Arc::new(
1037            SnapshotCache::new(Duration::from_secs(60), server, clients)
1038                .with_metrics(metrics.clone()),
1039        );
1040
1041        let cache = if let Some(sv1_source) = sv1 {
1042            Arc::new(
1043                Arc::try_unwrap(cache)
1044                    .unwrap_or_else(|arc| (*arc).clone())
1045                    .with_sv1_clients_source(sv1_source),
1046            )
1047        } else {
1048            cache
1049        };
1050
1051        cache.refresh();
1052
1053        let start_time = SystemTime::now()
1054            .duration_since(UNIX_EPOCH)
1055            .unwrap_or_default()
1056            .as_secs();
1057
1058        let state = ServerState {
1059            cache,
1060            start_time,
1061            metrics,
1062        };
1063
1064        let api_v1 = Router::new()
1065            .route(routes::segments::HEALTH, get(handle_health))
1066            .route(routes::segments::GLOBAL, get(handle_global))
1067            .route(routes::segments::SERVER, get(handle_server))
1068            .route(
1069                routes::segments::SERVER_CHANNELS,
1070                get(handle_server_channels),
1071            )
1072            .route(routes::segments::CLIENTS, get(handle_clients))
1073            .route(routes::segments::CLIENT_BY_ID, get(handle_client_by_id))
1074            .route(
1075                routes::segments::CLIENT_CHANNELS,
1076                get(handle_client_channels),
1077            )
1078            .route(routes::segments::SV1_CLIENTS, get(handle_sv1_clients))
1079            .route(
1080                routes::segments::SV1_CLIENT_BY_ID,
1081                get(handle_sv1_client_by_id),
1082            );
1083
1084        Router::new()
1085            .route(routes::ROOT, get(handle_root))
1086            .nest(routes::API_V1_PREFIX, api_v1)
1087            .route(routes::METRICS, get(handle_prometheus_metrics))
1088            .with_state(state)
1089    }
1090
1091    async fn get_body(response: axum::response::Response) -> String {
1092        let body = response.into_body();
1093        let bytes = body.collect().await.unwrap().to_bytes();
1094        String::from_utf8(bytes.to_vec()).unwrap()
1095    }
1096
1097    fn make_request(uri: &str) -> axum::http::Request<Body> {
1098        axum::http::Request::builder()
1099            .uri(uri)
1100            .body(Body::empty())
1101            .unwrap()
1102    }
1103
1104    // ── Pagination unit tests ───────────────────────────────────────
1105
1106    #[test]
1107    fn pagination_effective_limit_default() {
1108        let p = Pagination {
1109            offset: 0,
1110            limit: None,
1111        };
1112        assert_eq!(p.effective_limit(), DEFAULT_LIMIT);
1113    }
1114
1115    #[test]
1116    fn pagination_effective_limit_capped_at_max() {
1117        let p = Pagination {
1118            offset: 0,
1119            limit: Some(500),
1120        };
1121        assert_eq!(p.effective_limit(), MAX_LIMIT);
1122    }
1123
1124    #[test]
1125    fn pagination_effective_limit_respects_small_value() {
1126        let p = Pagination {
1127            offset: 0,
1128            limit: Some(5),
1129        };
1130        assert_eq!(p.effective_limit(), 5);
1131    }
1132
1133    #[test]
1134    fn paginate_empty_slice() {
1135        let items: Vec<i32> = vec![];
1136        let params = Pagination {
1137            offset: 0,
1138            limit: Some(10),
1139        };
1140        let (total, result) = paginate(&items, &params);
1141        assert_eq!(total, 0);
1142        assert!(result.is_empty());
1143    }
1144
1145    #[test]
1146    fn paginate_basic() {
1147        let items: Vec<i32> = (0..50).collect();
1148        let params = Pagination {
1149            offset: 10,
1150            limit: Some(5),
1151        };
1152        let (total, result) = paginate(&items, &params);
1153        assert_eq!(total, 50);
1154        assert_eq!(result, vec![10, 11, 12, 13, 14]);
1155    }
1156
1157    #[test]
1158    fn paginate_offset_beyond_length() {
1159        let items: Vec<i32> = vec![1, 2, 3];
1160        let params = Pagination {
1161            offset: 100,
1162            limit: Some(10),
1163        };
1164        let (total, result) = paginate(&items, &params);
1165        assert_eq!(total, 3);
1166        assert!(result.is_empty());
1167    }
1168
1169    #[test]
1170    fn paginate_limit_exceeds_remaining() {
1171        let items: Vec<i32> = vec![1, 2, 3, 4, 5];
1172        let params = Pagination {
1173            offset: 3,
1174            limit: Some(10),
1175        };
1176        let (total, result) = paginate(&items, &params);
1177        assert_eq!(total, 5);
1178        assert_eq!(result, vec![4, 5]);
1179    }
1180
1181    // ── HTTP endpoint integration tests ─────────────────────────────
1182
1183    #[tokio::test]
1184    async fn health_endpoint_returns_ok() {
1185        let app = build_test_app(None, None, None);
1186        let response = app.oneshot(make_request(routes::HEALTH)).await.unwrap();
1187        assert_eq!(response.status(), StatusCode::OK);
1188
1189        let body = get_body(response).await;
1190        let resp: HealthResponse = serde_json::from_str(&body).unwrap();
1191        assert_eq!(resp.status, "ok");
1192    }
1193
1194    #[tokio::test]
1195    async fn root_endpoint_lists_endpoints() {
1196        let app = build_test_app(None, None, None);
1197        let response = app.oneshot(make_request(routes::ROOT)).await.unwrap();
1198        assert_eq!(response.status(), StatusCode::OK);
1199
1200        let body = get_body(response).await;
1201        let resp: RootResponse = serde_json::from_str(&body).unwrap();
1202        assert_eq!(resp.service, ApiDoc::openapi().info.title);
1203        assert!(resp.endpoints.contains_key(routes::HEALTH));
1204    }
1205
1206    #[tokio::test]
1207    async fn run_returns_error_when_bind_address_is_in_use() {
1208        let occupied_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1209        let occupied_addr = occupied_listener.local_addr().unwrap();
1210        let server =
1211            MonitoringServer::new(occupied_addr, None, None, Duration::from_millis(10)).unwrap();
1212
1213        let err = server.run(std::future::pending()).await.unwrap_err();
1214
1215        assert_eq!(
1216            err.downcast_ref::<std::io::Error>()
1217                .map(std::io::Error::kind),
1218            Some(std::io::ErrorKind::AddrInUse)
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn global_endpoint_with_no_sources() {
1224        let app = build_test_app(None, None, None);
1225        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
1226        assert_eq!(response.status(), StatusCode::OK);
1227
1228        let body = get_body(response).await;
1229        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
1230        assert!(resp.server.is_none());
1231        assert!(resp.sv2_clients.is_none());
1232    }
1233
1234    #[tokio::test]
1235    async fn global_endpoint_with_data() {
1236        let server = Arc::new(MockServer(ServerInfo {
1237            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1238            standard_channels: vec![],
1239        }));
1240        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1241            client_id: 1,
1242            extended_channels: vec![create_extended_channel_info(1, 50.0)],
1243            standard_channels: vec![],
1244            #[cfg(feature = "asic-rs-telemetry")]
1245            miner_telemetry: None,
1246        }]));
1247
1248        let app = build_test_app(
1249            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1250            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1251            None,
1252        );
1253        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
1254        assert_eq!(response.status(), StatusCode::OK);
1255
1256        let body = get_body(response).await;
1257        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
1258        assert_eq!(resp.server.as_ref().unwrap().extended_channels, 1);
1259        assert_eq!(resp.sv2_clients.as_ref().unwrap().total_clients, 1);
1260    }
1261
1262    #[tokio::test]
1263    async fn server_endpoint_not_available() {
1264        let app = build_test_app(None, None, None);
1265        let response = app.oneshot(make_request(routes::SERVER)).await.unwrap();
1266        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1267    }
1268
1269    #[tokio::test]
1270    async fn server_endpoint_with_data() {
1271        let server = Arc::new(MockServer(ServerInfo {
1272            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1273            standard_channels: vec![create_server_standard_channel_info(2, Some(50.0))],
1274        }));
1275
1276        let app = build_test_app(
1277            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1278            None,
1279            None,
1280        );
1281        let response = app.oneshot(make_request(routes::SERVER)).await.unwrap();
1282        assert_eq!(response.status(), StatusCode::OK);
1283
1284        let body = get_body(response).await;
1285        let resp: ServerResponse = serde_json::from_str(&body).unwrap();
1286        assert_eq!(resp.extended_channels_count, 1);
1287        assert_eq!(resp.standard_channels_count, 1);
1288    }
1289
1290    #[tokio::test]
1291    async fn server_channels_endpoint_with_pagination() {
1292        let server = Arc::new(MockServer(ServerInfo {
1293            extended_channels: vec![
1294                create_server_extended_channel_info(1, Some(100.0)),
1295                create_server_extended_channel_info(2, Some(200.0)),
1296                create_server_extended_channel_info(3, Some(300.0)),
1297            ],
1298            standard_channels: vec![],
1299        }));
1300
1301        let app = build_test_app(
1302            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1303            None,
1304            None,
1305        );
1306        let response = app
1307            .oneshot(make_request(&format!(
1308                "{}?offset=1&limit=1",
1309                routes::SERVER_CHANNELS
1310            )))
1311            .await
1312            .unwrap();
1313        assert_eq!(response.status(), StatusCode::OK);
1314
1315        let body = get_body(response).await;
1316        let resp: ServerChannelsResponse = serde_json::from_str(&body).unwrap();
1317        assert_eq!(resp.total_extended, 3);
1318        assert_eq!(resp.offset, 1);
1319        assert_eq!(resp.limit, 1);
1320        assert_eq!(resp.extended_channels.len(), 1);
1321    }
1322
1323    #[tokio::test]
1324    async fn server_channels_endpoint_keeps_rejected_shares_total_compatible() {
1325        let server = Arc::new(MockServer(ServerInfo {
1326            extended_channels: vec![],
1327            standard_channels: vec![create_server_standard_channel_info(1, Some(50.0))],
1328        }));
1329
1330        let app = build_test_app(
1331            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1332            None,
1333            None,
1334        );
1335        let response = app
1336            .oneshot(make_request(routes::SERVER_CHANNELS))
1337            .await
1338            .unwrap();
1339        assert_eq!(response.status(), StatusCode::OK);
1340
1341        let body = get_body(response).await;
1342        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1343        let channel = &json["standard_channels"][0];
1344
1345        assert_eq!(channel["shares_rejected"], 1);
1346        assert_eq!(
1347            channel["shares_rejected_by_reason"][ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE],
1348            1
1349        );
1350    }
1351
1352    #[tokio::test]
1353    async fn clients_endpoint_not_available() {
1354        let app = build_test_app(None, None, None);
1355        let response = app.oneshot(make_request(routes::CLIENTS)).await.unwrap();
1356        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1357    }
1358
1359    #[tokio::test]
1360    async fn clients_endpoint_returns_metadata() {
1361        let clients = Arc::new(MockClients(vec![
1362            Sv2ClientInfo {
1363                client_id: 1,
1364                extended_channels: vec![create_extended_channel_info(1, 100.0)],
1365                standard_channels: vec![],
1366                #[cfg(feature = "asic-rs-telemetry")]
1367                miner_telemetry: Some(create_miner_telemetry()),
1368            },
1369            Sv2ClientInfo {
1370                client_id: 2,
1371                extended_channels: vec![],
1372                standard_channels: vec![create_standard_channel_info(1, 50.0)],
1373                #[cfg(feature = "asic-rs-telemetry")]
1374                miner_telemetry: None,
1375            },
1376        ]));
1377
1378        let app = build_test_app(
1379            None,
1380            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1381            None,
1382        );
1383        let response = app.oneshot(make_request(routes::CLIENTS)).await.unwrap();
1384        assert_eq!(response.status(), StatusCode::OK);
1385
1386        let body = get_body(response).await;
1387        let resp: Sv2ClientsResponse = serde_json::from_str(&body).unwrap();
1388        assert_eq!(resp.total, 2);
1389        assert_eq!(resp.items.len(), 2);
1390        assert_eq!(resp.items[0].client_id, 1);
1391        #[cfg(feature = "asic-rs-telemetry")]
1392        assert_eq!(
1393            resp.items[0]
1394                .miner_telemetry
1395                .as_ref()
1396                .and_then(|telemetry| telemetry.model.as_deref()),
1397            Some("HashBox")
1398        );
1399    }
1400
1401    #[tokio::test]
1402    async fn client_by_id_found() {
1403        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1404            client_id: 42,
1405            extended_channels: vec![create_extended_channel_info(1, 100.0)],
1406            standard_channels: vec![create_standard_channel_info(2, 50.0)],
1407            #[cfg(feature = "asic-rs-telemetry")]
1408            miner_telemetry: Some(create_miner_telemetry()),
1409        }]));
1410
1411        let app = build_test_app(
1412            None,
1413            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1414            None,
1415        );
1416        let response = app
1417            .oneshot(make_request(&routes::client_by_id(42)))
1418            .await
1419            .unwrap();
1420        assert_eq!(response.status(), StatusCode::OK);
1421
1422        let body = get_body(response).await;
1423        let resp: Sv2ClientResponse = serde_json::from_str(&body).unwrap();
1424        assert_eq!(resp.client_id, 42);
1425        assert_eq!(resp.extended_channels_count, 1);
1426        assert_eq!(resp.standard_channels_count, 1);
1427        #[cfg(feature = "asic-rs-telemetry")]
1428        assert_eq!(
1429            resp.miner_telemetry
1430                .as_ref()
1431                .and_then(|telemetry| telemetry.model.as_deref()),
1432            Some("HashBox")
1433        );
1434    }
1435
1436    #[tokio::test]
1437    async fn client_by_id_not_found() {
1438        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1439            client_id: 1,
1440            extended_channels: vec![],
1441            standard_channels: vec![],
1442            #[cfg(feature = "asic-rs-telemetry")]
1443            miner_telemetry: None,
1444        }]));
1445
1446        let app = build_test_app(
1447            None,
1448            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1449            None,
1450        );
1451        let response = app
1452            .oneshot(make_request(&routes::client_by_id(999)))
1453            .await
1454            .unwrap();
1455        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1456    }
1457
1458    #[tokio::test]
1459    async fn client_channels_with_pagination() {
1460        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1461            client_id: 1,
1462            extended_channels: vec![
1463                create_extended_channel_info(10, 100.0),
1464                create_extended_channel_info(11, 200.0),
1465                create_extended_channel_info(12, 300.0),
1466            ],
1467            standard_channels: vec![create_standard_channel_info(20, 50.0)],
1468            #[cfg(feature = "asic-rs-telemetry")]
1469            miner_telemetry: None,
1470        }]));
1471
1472        let app = build_test_app(
1473            None,
1474            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1475            None,
1476        );
1477        let response = app
1478            .oneshot(make_request(&format!(
1479                "{}?offset=1&limit=2",
1480                routes::client_channels(1)
1481            )))
1482            .await
1483            .unwrap();
1484        assert_eq!(response.status(), StatusCode::OK);
1485
1486        let body = get_body(response).await;
1487        let resp: Sv2ClientChannelsResponse = serde_json::from_str(&body).unwrap();
1488        assert_eq!(resp.client_id, 1);
1489        assert_eq!(resp.total_extended, 3);
1490        assert_eq!(resp.total_standard, 1);
1491        assert_eq!(resp.extended_channels.len(), 2);
1492    }
1493
1494    #[tokio::test]
1495    async fn sv1_clients_not_available() {
1496        let app = build_test_app(None, None, None);
1497        let response = app
1498            .oneshot(make_request(routes::SV1_CLIENTS))
1499            .await
1500            .unwrap();
1501        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1502    }
1503
1504    #[tokio::test]
1505    async fn sv1_clients_with_data() {
1506        let sv1 = Arc::new(MockSv1Clients(vec![
1507            create_sv1_client_info(1, Some(100.0)),
1508            create_sv1_client_info(2, Some(200.0)),
1509        ]));
1510
1511        let app = build_test_app(
1512            None,
1513            None,
1514            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1515        );
1516        let response = app
1517            .oneshot(make_request(routes::SV1_CLIENTS))
1518            .await
1519            .unwrap();
1520        assert_eq!(response.status(), StatusCode::OK);
1521
1522        let body = get_body(response).await;
1523        let resp: Sv1ClientsResponse = serde_json::from_str(&body).unwrap();
1524        assert_eq!(resp.total, 2);
1525        assert_eq!(resp.items.len(), 2);
1526    }
1527
1528    #[tokio::test]
1529    async fn sv1_client_by_id_found() {
1530        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(7, Some(500.0))]));
1531
1532        let app = build_test_app(
1533            None,
1534            None,
1535            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1536        );
1537        let response = app
1538            .oneshot(make_request(&routes::sv1_client_by_id(7)))
1539            .await
1540            .unwrap();
1541        assert_eq!(response.status(), StatusCode::OK);
1542
1543        let body = get_body(response).await;
1544        let resp: Sv1ClientInfo = serde_json::from_str(&body).unwrap();
1545        assert_eq!(resp.client_id, 7);
1546    }
1547
1548    #[tokio::test]
1549    async fn sv1_client_by_id_not_found() {
1550        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(1, Some(100.0))]));
1551
1552        let app = build_test_app(
1553            None,
1554            None,
1555            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1556        );
1557        let response = app
1558            .oneshot(make_request(&routes::sv1_client_by_id(999)))
1559            .await
1560            .unwrap();
1561        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1562    }
1563
1564    #[tokio::test]
1565    async fn metrics_endpoint_returns_prometheus_format() {
1566        let server = Arc::new(MockServer(ServerInfo {
1567            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1568            standard_channels: vec![],
1569        }));
1570        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1571            client_id: 1,
1572            extended_channels: vec![create_extended_channel_info(1, 50.0)],
1573            standard_channels: vec![],
1574            #[cfg(feature = "asic-rs-telemetry")]
1575            miner_telemetry: None,
1576        }]));
1577
1578        let app = build_test_app(
1579            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1580            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1581            None,
1582        );
1583        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1584        assert_eq!(response.status(), StatusCode::OK);
1585
1586        let body = get_body(response).await;
1587        assert!(body.contains("sv2_uptime_seconds"));
1588        assert!(body.contains("sv2_server_channels"));
1589        assert!(body.contains("sv2_clients_total"));
1590    }
1591
1592    #[tokio::test]
1593    async fn metrics_endpoint_with_no_sources() {
1594        let app = build_test_app(None, None, None);
1595        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1596        assert_eq!(response.status(), StatusCode::OK);
1597
1598        let body = get_body(response).await;
1599        // Uptime is always present
1600        assert!(body.contains("sv2_uptime_seconds"));
1601        // Server/client metrics should NOT be present when sources are None
1602        assert!(!body.contains("sv2_server_channels"));
1603        assert!(!body.contains("sv2_clients_total"));
1604    }
1605
1606    // Mutable mock that allows changing data between requests
1607    struct MutableMockClients(Mutex<Vec<Sv2ClientInfo>>);
1608    impl Sv2ClientsMonitoring for MutableMockClients {
1609        fn get_sv2_clients(&self) -> Vec<Sv2ClientInfo> {
1610            self.0.lock().unwrap().clone()
1611        }
1612    }
1613
1614    /// Verify that stale channel labels are removed without a reset gap.
1615    ///
1616    /// Scenario: First scrape has client with channel 1 and channel 2.
1617    /// Second scrape: channel 2 is gone. The test verifies that:
1618    /// - Channel 1 metrics are still present (no gap)
1619    /// - Channel 2 metrics are removed (stale cleanup)
1620    #[tokio::test]
1621    async fn metrics_stale_labels_removed_without_reset_gap() {
1622        let mut channel_2 = create_extended_channel_info(2, 200.0);
1623        channel_2.shares_rejected = 1;
1624        channel_2.shares_rejected_by_reason =
1625            HashMap::from([(ERROR_CODE_SUBMIT_SHARES_DUPLICATE_SHARE.to_string(), 1)]);
1626
1627        let initial_clients = vec![Sv2ClientInfo {
1628            client_id: 1,
1629            extended_channels: vec![create_extended_channel_info(1, 100.0), channel_2],
1630            standard_channels: vec![],
1631            #[cfg(feature = "asic-rs-telemetry")]
1632            miner_telemetry: None,
1633        }];
1634
1635        let mock_clients = Arc::new(MutableMockClients(Mutex::new(initial_clients)));
1636        let metrics = PrometheusMetrics::new(false, true, false).unwrap();
1637        let cache = Arc::new(
1638            SnapshotCache::new(
1639                Duration::from_secs(60),
1640                None,
1641                Some(mock_clients.clone() as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1642            )
1643            .with_metrics(metrics.clone()),
1644        );
1645        cache.refresh();
1646
1647        let start_time = SystemTime::now()
1648            .duration_since(UNIX_EPOCH)
1649            .unwrap_or_default()
1650            .as_secs();
1651
1652        let state = ServerState {
1653            cache: cache.clone(),
1654            start_time,
1655            metrics,
1656        };
1657
1658        let app = Router::new()
1659            .route(routes::METRICS, get(handle_prometheus_metrics))
1660            .with_state(state);
1661
1662        // First scrape — both channels present
1663        let response = app
1664            .clone()
1665            .oneshot(make_request(routes::METRICS))
1666            .await
1667            .unwrap();
1668        let body = get_body(response).await;
1669        // Prometheus sorts label keys alphabetically: channel_id, client_id, user_identity
1670        assert!(
1671            body.contains("sv2_client_shares_accepted_total{channel_id=\"1\",client_id=\"1\""),
1672            "Channel 1 should be present on first scrape"
1673        );
1674        assert!(
1675            body.contains("sv2_client_shares_accepted_total{channel_id=\"2\",client_id=\"1\""),
1676            "Channel 2 should be present on first scrape"
1677        );
1678        assert!(
1679            body.contains("sv2_client_shares_rejected_total{channel_id=\"2\",client_id=\"1\",error_code=\"duplicate-share\""),
1680            "Channel 2 rejected-share metric should be present on first scrape"
1681        );
1682
1683        // Remove channel 2 from mock data and refresh cache
1684        {
1685            let mut clients = mock_clients.0.lock().unwrap();
1686            clients[0].extended_channels.retain(|c| c.channel_id == 1);
1687        }
1688        cache.refresh();
1689
1690        // Second scrape — channel 2 should be removed, channel 1 still present
1691        let response = app
1692            .clone()
1693            .oneshot(make_request(routes::METRICS))
1694            .await
1695            .unwrap();
1696        let body = get_body(response).await;
1697        assert!(
1698            body.contains("sv2_client_shares_accepted_total{channel_id=\"1\",client_id=\"1\""),
1699            "Channel 1 should still be present after stale removal"
1700        );
1701        assert!(
1702            !body.contains("sv2_client_shares_accepted_total{channel_id=\"2\",client_id=\"1\""),
1703            "Channel 2 should be removed as stale"
1704        );
1705        assert!(
1706            !body.contains("sv2_client_shares_rejected_total{channel_id=\"2\",client_id=\"1\",error_code=\"duplicate-share\""),
1707            "Channel 2 rejected-share metric should be removed as stale"
1708        );
1709    }
1710
1711    /// Regression test for lazy-loading of `sv2_*_shares_rejected_total`.
1712    ///
1713    /// A `GaugeVec` only emits a series after `with_label_values(...).set(...)` runs at
1714    /// least once. With the rejection metric populated only inside a loop over
1715    /// `channel.shares_rejected_by_reason`, a channel with zero rejections produces no
1716    /// series at all in `/metrics` — Grafana panels fail to load and alerting rules
1717    /// silently never fire.
1718    ///
1719    /// Pre-seeding the spec-defined error codes from `mining_sv2::ERROR_CODE_SUBMIT_SHARES_*`
1720    /// to `0` on every refresh fixes this. This test asserts the metric is emitted with
1721    /// zero rejections.
1722    #[tokio::test]
1723    async fn shares_rejected_metric_emitted_with_zero_rejections() {
1724        // Server channel with zero rejections.
1725        // Helper defaults for standard channel set shares_rejected=1; override to 0.
1726        let mut server_info = super::super::server::ServerInfo {
1727            extended_channels: vec![create_server_extended_channel_info(1, Some(100.0))],
1728            standard_channels: vec![create_server_standard_channel_info(2, Some(50.0))],
1729        };
1730        server_info.standard_channels[0].shares_rejected = 0;
1731        server_info.standard_channels[0].shares_rejected_by_reason = HashMap::new();
1732
1733        let server = Arc::new(MockServer(server_info));
1734
1735        // Client channel with zero rejections
1736        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1737            client_id: 1,
1738            extended_channels: vec![create_extended_channel_info(1, 100.0)],
1739            standard_channels: vec![],
1740            #[cfg(feature = "asic-rs-telemetry")]
1741            miner_telemetry: None,
1742        }]));
1743
1744        let app = build_test_app(
1745            Some(server as Arc<dyn ServerMonitoring + Send + Sync>),
1746            Some(clients as Arc<dyn super::super::client::Sv2ClientsMonitoring + Send + Sync>),
1747            None,
1748        );
1749
1750        let response = app.oneshot(make_request(routes::METRICS)).await.unwrap();
1751        let body = get_body(response).await;
1752
1753        // Both metrics MUST appear with the spec-defined error_code labels pre-seeded to 0.
1754        assert!(
1755            body.contains("sv2_server_shares_rejected_total{channel_id=\"1\",error_code=\"stale-share\""),
1756            "sv2_server_shares_rejected_total stale-share label must be pre-seeded to 0; got:\n{body}"
1757        );
1758        assert!(
1759            body.contains("sv2_server_shares_rejected_total{channel_id=\"1\",error_code=\"duplicate-share\""),
1760            "sv2_server_shares_rejected_total duplicate-share label must be pre-seeded to 0; got:\n{body}"
1761        );
1762        assert!(
1763            body.contains("sv2_client_shares_rejected_total{channel_id=\"1\",client_id=\"1\",error_code=\"stale-share\""),
1764            "sv2_client_shares_rejected_total stale-share label must be pre-seeded to 0; got:\n{body}"
1765        );
1766    }
1767
1768    // ── Edge-case unit tests (pagination, missing data, invalid params) ──
1769
1770    #[test]
1771    fn paginate_with_limit_zero() {
1772        // effective_limit(Some(0)) = 0.min(MAX_LIMIT) = 0, so take(0) returns nothing
1773        let items: Vec<i32> = (0..50).collect();
1774        let params = Pagination {
1775            offset: 0,
1776            limit: Some(0),
1777        };
1778        let (total, result) = paginate(&items, &params);
1779        assert_eq!(total, 50);
1780        assert!(result.is_empty(), "limit=0 should return no items");
1781    }
1782
1783    #[tokio::test]
1784    async fn server_channels_not_available() {
1785        let app = build_test_app(None, None, None);
1786        let response = app
1787            .oneshot(make_request(routes::SERVER_CHANNELS))
1788            .await
1789            .unwrap();
1790        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1791
1792        let body = get_body(response).await;
1793        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1794        assert!(!resp.error.is_empty());
1795    }
1796
1797    #[tokio::test]
1798    async fn client_by_id_no_monitoring() {
1799        // When client monitoring is not available at all, any client_id returns 404
1800        let app = build_test_app(None, None, None);
1801        let response = app
1802            .oneshot(make_request(&routes::client_by_id(1)))
1803            .await
1804            .unwrap();
1805        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1806
1807        let body = get_body(response).await;
1808        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1809        assert!(!resp.error.is_empty());
1810    }
1811
1812    #[tokio::test]
1813    async fn client_channels_client_not_found() {
1814        // Client monitoring is available but the specific client_id does not exist
1815        let clients = Arc::new(MockClients(vec![Sv2ClientInfo {
1816            client_id: 1,
1817            extended_channels: vec![],
1818            standard_channels: vec![],
1819            #[cfg(feature = "asic-rs-telemetry")]
1820            miner_telemetry: None,
1821        }]));
1822
1823        let app = build_test_app(
1824            None,
1825            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1826            None,
1827        );
1828        let response = app
1829            .oneshot(make_request(&routes::client_channels(999)))
1830            .await
1831            .unwrap();
1832        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1833
1834        let body = get_body(response).await;
1835        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1836        assert!(resp.error.contains("999"));
1837    }
1838
1839    #[tokio::test]
1840    async fn client_channels_no_monitoring() {
1841        // When client monitoring is not available at all
1842        let app = build_test_app(None, None, None);
1843        let response = app
1844            .oneshot(make_request(&routes::client_channels(1)))
1845            .await
1846            .unwrap();
1847        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1848    }
1849
1850    #[tokio::test]
1851    async fn sv1_client_by_id_no_monitoring() {
1852        // When SV1 monitoring is not available at all
1853        let app = build_test_app(None, None, None);
1854        let response = app
1855            .oneshot(make_request(&routes::sv1_client_by_id(1)))
1856            .await
1857            .unwrap();
1858        assert_eq!(response.status(), StatusCode::NOT_FOUND);
1859
1860        let body = get_body(response).await;
1861        let resp: ErrorResponse = serde_json::from_str(&body).unwrap();
1862        assert!(!resp.error.is_empty());
1863    }
1864
1865    #[tokio::test]
1866    async fn clients_pagination_offset_and_limit() {
1867        let clients = Arc::new(MockClients(vec![
1868            Sv2ClientInfo {
1869                client_id: 1,
1870                extended_channels: vec![create_extended_channel_info(1, 100.0)],
1871                standard_channels: vec![],
1872                #[cfg(feature = "asic-rs-telemetry")]
1873                miner_telemetry: None,
1874            },
1875            Sv2ClientInfo {
1876                client_id: 2,
1877                extended_channels: vec![],
1878                standard_channels: vec![create_standard_channel_info(1, 50.0)],
1879                #[cfg(feature = "asic-rs-telemetry")]
1880                miner_telemetry: None,
1881            },
1882            Sv2ClientInfo {
1883                client_id: 3,
1884                extended_channels: vec![create_extended_channel_info(2, 200.0)],
1885                standard_channels: vec![],
1886                #[cfg(feature = "asic-rs-telemetry")]
1887                miner_telemetry: None,
1888            },
1889        ]));
1890
1891        let app = build_test_app(
1892            None,
1893            Some(clients as Arc<dyn Sv2ClientsMonitoring + Send + Sync>),
1894            None,
1895        );
1896        let response = app
1897            .oneshot(make_request(&format!(
1898                "{}?offset=1&limit=1",
1899                routes::CLIENTS
1900            )))
1901            .await
1902            .unwrap();
1903        assert_eq!(response.status(), StatusCode::OK);
1904
1905        let body = get_body(response).await;
1906        let resp: Sv2ClientsResponse = serde_json::from_str(&body).unwrap();
1907        assert_eq!(resp.total, 3);
1908        assert_eq!(resp.offset, 1);
1909        assert_eq!(resp.limit, 1);
1910        assert_eq!(resp.items.len(), 1);
1911        assert_eq!(resp.items[0].client_id, 2);
1912    }
1913
1914    #[tokio::test]
1915    async fn sv1_clients_pagination() {
1916        let sv1 = Arc::new(MockSv1Clients(vec![
1917            create_sv1_client_info(1, Some(100.0)),
1918            create_sv1_client_info(2, Some(200.0)),
1919            create_sv1_client_info(3, Some(300.0)),
1920        ]));
1921
1922        let app = build_test_app(
1923            None,
1924            None,
1925            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1926        );
1927        let response = app
1928            .oneshot(make_request(&format!(
1929                "{}?offset=2&limit=10",
1930                routes::SV1_CLIENTS
1931            )))
1932            .await
1933            .unwrap();
1934        assert_eq!(response.status(), StatusCode::OK);
1935
1936        let body = get_body(response).await;
1937        let resp: Sv1ClientsResponse = serde_json::from_str(&body).unwrap();
1938        assert_eq!(resp.total, 3);
1939        assert_eq!(resp.offset, 2);
1940        assert_eq!(resp.items.len(), 1);
1941        assert_eq!(resp.items[0].client_id, 3);
1942    }
1943
1944    #[tokio::test]
1945    async fn global_endpoint_with_sv1_data() {
1946        let sv1 = Arc::new(MockSv1Clients(vec![create_sv1_client_info(1, Some(100.0))]));
1947
1948        let app = build_test_app(
1949            None,
1950            None,
1951            Some(sv1 as Arc<dyn Sv1ClientsMonitoring + Send + Sync>),
1952        );
1953        let response = app.oneshot(make_request(routes::GLOBAL)).await.unwrap();
1954        assert_eq!(response.status(), StatusCode::OK);
1955
1956        let body = get_body(response).await;
1957        let resp: GlobalInfo = serde_json::from_str(&body).unwrap();
1958        // Server and SV2 clients should be None
1959        assert!(resp.server.is_none());
1960        assert!(resp.sv2_clients.is_none());
1961        // SV1 clients should be present
1962        assert_eq!(resp.sv1_clients.as_ref().unwrap().total_clients, 1);
1963    }
1964}