From 61de6c6dddcb694e4909e0f5442bfbbad44d0d5b Mon Sep 17 00:00:00 2001 From: devitway Date: Fri, 13 Mar 2026 15:43:03 +0000 Subject: [PATCH] fix: persist dashboard metrics and count versions instead of repos Metrics (downloads, uploads, cache hits) were stored in-memory only and reset to zero on every restart. Now they persist to metrics.json in the storage directory with: - Load on startup from {storage_path}/metrics.json - Background save every 30 seconds - Final save on graceful shutdown - Atomic writes (tmp + rename) to prevent corruption Artifact count on dashboard now shows total tags/versions across all registries instead of just counting unique repository names. This matches user expectations when pushing multiple tags to the same image (e.g. myapp:v1, myapp:v2 now shows 2, not 1). --- nora-registry/src/dashboard_metrics.rs | 93 ++++++++++++++++++++++++++ nora-registry/src/main.rs | 15 ++++- nora-registry/src/ui/api.rs | 22 +++--- 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/nora-registry/src/dashboard_metrics.rs b/nora-registry/src/dashboard_metrics.rs index 61e016a..1f4dd94 100644 --- a/nora-registry/src/dashboard_metrics.rs +++ b/nora-registry/src/dashboard_metrics.rs @@ -1,8 +1,29 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; +use tracing::{info, warn}; + +/// Serializable snapshot of metrics for persistence +#[derive(Serialize, Deserialize, Default)] +struct MetricsSnapshot { + downloads: u64, + uploads: u64, + cache_hits: u64, + cache_misses: u64, + docker_downloads: u64, + docker_uploads: u64, + npm_downloads: u64, + maven_downloads: u64, + maven_uploads: u64, + cargo_downloads: u64, + pypi_downloads: u64, + raw_downloads: u64, + raw_uploads: u64, +} /// Dashboard metrics for tracking registry activity /// Uses atomic counters for thread-safe access without locks @@ -25,6 +46,9 @@ pub struct DashboardMetrics { pub raw_uploads: AtomicU64, pub start_time: Instant, + + /// Path to metrics.json for persistence + persist_path: Option, } impl DashboardMetrics { @@ -44,6 +68,75 @@ impl DashboardMetrics { raw_downloads: AtomicU64::new(0), raw_uploads: AtomicU64::new(0), start_time: Instant::now(), + persist_path: None, + } + } + + /// Create metrics with persistence — loads existing data from metrics.json + pub fn with_persistence(storage_path: &str) -> Self { + let path = Path::new(storage_path).join("metrics.json"); + let mut metrics = Self::new(); + metrics.persist_path = Some(path.clone()); + + // Load existing metrics if file exists + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(data) => match serde_json::from_str::(&data) { + Ok(snap) => { + metrics.downloads = AtomicU64::new(snap.downloads); + metrics.uploads = AtomicU64::new(snap.uploads); + metrics.cache_hits = AtomicU64::new(snap.cache_hits); + metrics.cache_misses = AtomicU64::new(snap.cache_misses); + metrics.docker_downloads = AtomicU64::new(snap.docker_downloads); + metrics.docker_uploads = AtomicU64::new(snap.docker_uploads); + metrics.npm_downloads = AtomicU64::new(snap.npm_downloads); + metrics.maven_downloads = AtomicU64::new(snap.maven_downloads); + metrics.maven_uploads = AtomicU64::new(snap.maven_uploads); + metrics.cargo_downloads = AtomicU64::new(snap.cargo_downloads); + metrics.pypi_downloads = AtomicU64::new(snap.pypi_downloads); + metrics.raw_downloads = AtomicU64::new(snap.raw_downloads); + metrics.raw_uploads = AtomicU64::new(snap.raw_uploads); + info!( + downloads = snap.downloads, + uploads = snap.uploads, + "Loaded persisted metrics" + ); + } + Err(e) => warn!("Failed to parse metrics.json: {}", e), + }, + Err(e) => warn!("Failed to read metrics.json: {}", e), + } + } + + metrics + } + + /// Save current metrics to disk + pub fn save(&self) { + let Some(path) = &self.persist_path else { + return; + }; + let snap = MetricsSnapshot { + downloads: self.downloads.load(Ordering::Relaxed), + uploads: self.uploads.load(Ordering::Relaxed), + cache_hits: self.cache_hits.load(Ordering::Relaxed), + cache_misses: self.cache_misses.load(Ordering::Relaxed), + docker_downloads: self.docker_downloads.load(Ordering::Relaxed), + docker_uploads: self.docker_uploads.load(Ordering::Relaxed), + npm_downloads: self.npm_downloads.load(Ordering::Relaxed), + maven_downloads: self.maven_downloads.load(Ordering::Relaxed), + maven_uploads: self.maven_uploads.load(Ordering::Relaxed), + cargo_downloads: self.cargo_downloads.load(Ordering::Relaxed), + pypi_downloads: self.pypi_downloads.load(Ordering::Relaxed), + raw_downloads: self.raw_downloads.load(Ordering::Relaxed), + raw_uploads: self.raw_uploads.load(Ordering::Relaxed), + }; + // Atomic write: write to tmp then rename + let tmp = path.with_extension("json.tmp"); + if let Ok(data) = serde_json::to_string_pretty(&snap) { + if std::fs::write(&tmp, &data).is_ok() { + let _ = std::fs::rename(&tmp, path); + } } } diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index d07261c..be4d5ec 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -336,7 +336,7 @@ async fn run_server(config: Config, storage: Storage) { start_time, auth, tokens, - metrics: DashboardMetrics::new(), + metrics: DashboardMetrics::with_persistence(&storage_path), activity: ActivityLog::new(50), audit: AuditLog::new(&storage_path), docker_auth, @@ -387,6 +387,16 @@ async fn run_server(config: Config, storage: Storage) { "Available endpoints" ); + // Background task: persist metrics every 30 seconds + let metrics_state = state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + metrics_state.metrics.save(); + } + }); + // Graceful shutdown on SIGTERM/SIGINT axum::serve( listener, @@ -396,6 +406,9 @@ async fn run_server(config: Config, storage: Storage) { .await .expect("Server error"); + // Save metrics on shutdown + state.metrics.save(); + info!( uptime_seconds = state.start_time.elapsed().as_secs(), "Nora shutdown complete" diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 0b374eb..e645457 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -141,11 +141,13 @@ pub async fn api_dashboard(State(state): State>) -> Json>) -> Json