Skip to main content

stratum_apps/monitoring/
http_server.rs

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