From a13d7b8cfcd9ee7e1cec2421a436c0517cf07a8f Mon Sep 17 00:00:00 2001 From: DevITWay Date: Mon, 26 Jan 2026 16:21:25 +0000 Subject: [PATCH] Add dashboard metrics, activity log, and dark theme - Add DashboardMetrics for tracking downloads/uploads/cache hits per registry - Add ActivityLog for recent activity with bounded size (50 entries) - Instrument Docker, npm, Maven, and Cargo handlers with metrics - Add /api/ui/dashboard endpoint with global stats and activity - Implement dark theme dashboard with real-time polling (5s interval) - Add mount points table showing registry paths and proxy upstreams --- nora-registry/src/activity_log.rs | 103 +++++ nora-registry/src/dashboard_metrics.rs | 114 ++++++ nora-registry/src/main.rs | 8 + nora-registry/src/registry/cargo_registry.rs | 13 +- nora-registry/src/registry/docker.rs | 47 ++- nora-registry/src/registry/maven.rs | 36 +- nora-registry/src/registry/npm.rs | 29 ++ nora-registry/src/ui/api.rs | 154 ++++++++ nora-registry/src/ui/components.rs | 394 ++++++++++++++++++- nora-registry/src/ui/mod.rs | 5 +- nora-registry/src/ui/templates.rs | 198 ++++++---- 11 files changed, 1013 insertions(+), 88 deletions(-) create mode 100644 nora-registry/src/activity_log.rs create mode 100644 nora-registry/src/dashboard_metrics.rs diff --git a/nora-registry/src/activity_log.rs b/nora-registry/src/activity_log.rs new file mode 100644 index 0000000..e288054 --- /dev/null +++ b/nora-registry/src/activity_log.rs @@ -0,0 +1,103 @@ +use chrono::{DateTime, Utc}; +use parking_lot::RwLock; +use serde::Serialize; +use std::collections::VecDeque; + +/// Type of action that was performed +#[derive(Debug, Clone, Serialize, PartialEq)] +pub enum ActionType { + Pull, + Push, + CacheHit, + ProxyFetch, +} + +impl std::fmt::Display for ActionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ActionType::Pull => write!(f, "PULL"), + ActionType::Push => write!(f, "PUSH"), + ActionType::CacheHit => write!(f, "CACHE"), + ActionType::ProxyFetch => write!(f, "PROXY"), + } + } +} + +/// A single activity log entry +#[derive(Debug, Clone, Serialize)] +pub struct ActivityEntry { + pub timestamp: DateTime, + pub action: ActionType, + pub artifact: String, + pub registry: String, + pub source: String, // "LOCAL", "PROXY", "CACHE" +} + +impl ActivityEntry { + pub fn new(action: ActionType, artifact: String, registry: &str, source: &str) -> Self { + Self { + timestamp: Utc::now(), + action, + artifact, + registry: registry.to_string(), + source: source.to_string(), + } + } +} + +/// Thread-safe activity log with bounded size +pub struct ActivityLog { + entries: RwLock>, + max_entries: usize, +} + +impl ActivityLog { + pub fn new(max: usize) -> Self { + Self { + entries: RwLock::new(VecDeque::with_capacity(max)), + max_entries: max, + } + } + + /// Add a new entry to the log, removing oldest if at capacity + pub fn push(&self, entry: ActivityEntry) { + let mut entries = self.entries.write(); + if entries.len() >= self.max_entries { + entries.pop_front(); + } + entries.push_back(entry); + } + + /// Get the most recent N entries (newest first) + pub fn recent(&self, count: usize) -> Vec { + let entries = self.entries.read(); + entries + .iter() + .rev() + .take(count) + .cloned() + .collect() + } + + /// Get all entries (newest first) + pub fn all(&self) -> Vec { + let entries = self.entries.read(); + entries.iter().rev().cloned().collect() + } + + /// Get the total number of entries + pub fn len(&self) -> usize { + self.entries.read().len() + } + + /// Check if the log is empty + pub fn is_empty(&self) -> bool { + self.entries.read().is_empty() + } +} + +impl Default for ActivityLog { + fn default() -> Self { + Self::new(50) + } +} diff --git a/nora-registry/src/dashboard_metrics.rs b/nora-registry/src/dashboard_metrics.rs new file mode 100644 index 0000000..85bd0e9 --- /dev/null +++ b/nora-registry/src/dashboard_metrics.rs @@ -0,0 +1,114 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +/// Dashboard metrics for tracking registry activity +/// Uses atomic counters for thread-safe access without locks +pub struct DashboardMetrics { + // Global counters + pub downloads: AtomicU64, + pub uploads: AtomicU64, + pub cache_hits: AtomicU64, + pub cache_misses: AtomicU64, + + // Per-registry download counters + pub docker_downloads: AtomicU64, + pub docker_uploads: AtomicU64, + pub npm_downloads: AtomicU64, + pub maven_downloads: AtomicU64, + pub maven_uploads: AtomicU64, + pub cargo_downloads: AtomicU64, + pub pypi_downloads: AtomicU64, + + pub start_time: Instant, +} + +impl DashboardMetrics { + pub fn new() -> Self { + Self { + downloads: AtomicU64::new(0), + uploads: AtomicU64::new(0), + cache_hits: AtomicU64::new(0), + cache_misses: AtomicU64::new(0), + docker_downloads: AtomicU64::new(0), + docker_uploads: AtomicU64::new(0), + npm_downloads: AtomicU64::new(0), + maven_downloads: AtomicU64::new(0), + maven_uploads: AtomicU64::new(0), + cargo_downloads: AtomicU64::new(0), + pypi_downloads: AtomicU64::new(0), + start_time: Instant::now(), + } + } + + /// Record a download event for the specified registry + pub fn record_download(&self, registry: &str) { + self.downloads.fetch_add(1, Ordering::Relaxed); + match registry { + "docker" => self.docker_downloads.fetch_add(1, Ordering::Relaxed), + "npm" => self.npm_downloads.fetch_add(1, Ordering::Relaxed), + "maven" => self.maven_downloads.fetch_add(1, Ordering::Relaxed), + "cargo" => self.cargo_downloads.fetch_add(1, Ordering::Relaxed), + "pypi" => self.pypi_downloads.fetch_add(1, Ordering::Relaxed), + _ => 0, + }; + } + + /// Record an upload event for the specified registry + pub fn record_upload(&self, registry: &str) { + self.uploads.fetch_add(1, Ordering::Relaxed); + match registry { + "docker" => self.docker_uploads.fetch_add(1, Ordering::Relaxed), + "maven" => self.maven_uploads.fetch_add(1, Ordering::Relaxed), + _ => 0, + }; + } + + /// Record a cache hit + pub fn record_cache_hit(&self) { + self.cache_hits.fetch_add(1, Ordering::Relaxed); + } + + /// Record a cache miss + pub fn record_cache_miss(&self) { + self.cache_misses.fetch_add(1, Ordering::Relaxed); + } + + /// Calculate the cache hit rate as a percentage + pub fn cache_hit_rate(&self) -> f64 { + let hits = self.cache_hits.load(Ordering::Relaxed); + let misses = self.cache_misses.load(Ordering::Relaxed); + let total = hits + misses; + if total == 0 { + 0.0 + } else { + (hits as f64 / total as f64) * 100.0 + } + } + + /// Get download count for a specific registry + pub fn get_registry_downloads(&self, registry: &str) -> u64 { + match registry { + "docker" => self.docker_downloads.load(Ordering::Relaxed), + "npm" => self.npm_downloads.load(Ordering::Relaxed), + "maven" => self.maven_downloads.load(Ordering::Relaxed), + "cargo" => self.cargo_downloads.load(Ordering::Relaxed), + "pypi" => self.pypi_downloads.load(Ordering::Relaxed), + _ => 0, + } + } + + /// Get upload count for a specific registry + pub fn get_registry_uploads(&self, registry: &str) -> u64 { + match registry { + "docker" => self.docker_uploads.load(Ordering::Relaxed), + "maven" => self.maven_uploads.load(Ordering::Relaxed), + _ => 0, + } + } +} + +impl Default for DashboardMetrics { + fn default() -> Self { + Self::new() + } +} diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index 4a5d0af..a7b5c34 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -1,6 +1,8 @@ +mod activity_log; mod auth; mod backup; mod config; +mod dashboard_metrics; mod error; mod health; mod metrics; @@ -23,8 +25,10 @@ use tokio::signal; use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use activity_log::ActivityLog; use auth::HtpasswdAuth; use config::{Config, StorageMode}; +use dashboard_metrics::DashboardMetrics; pub use storage::Storage; use tokens::TokenStore; @@ -71,6 +75,8 @@ pub struct AppState { pub start_time: Instant, pub auth: Option, pub tokens: Option, + pub metrics: DashboardMetrics, + pub activity: ActivityLog, } #[tokio::main] @@ -205,6 +211,8 @@ async fn run_server(config: Config, storage: Storage) { start_time, auth, tokens, + metrics: DashboardMetrics::new(), + activity: ActivityLog::new(50), }); // Token routes with strict rate limiting (brute-force protection) diff --git a/nora-registry/src/registry/cargo_registry.rs b/nora-registry/src/registry/cargo_registry.rs index 9b2edf4..d737fe5 100644 --- a/nora-registry/src/registry/cargo_registry.rs +++ b/nora-registry/src/registry/cargo_registry.rs @@ -1,3 +1,4 @@ +use crate::activity_log::{ActionType, ActivityEntry}; use crate::AppState; use axum::{ extract::{Path, State}, @@ -37,7 +38,17 @@ async fn download( crate_name, version, crate_name, version ); match state.storage.get(&key).await { - Ok(data) => (StatusCode::OK, data).into_response(), + Ok(data) => { + state.metrics.record_download("cargo"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::Pull, + format!("{}@{}", crate_name, version), + "cargo", + "LOCAL", + )); + (StatusCode::OK, data).into_response() + } Err(_) => StatusCode::NOT_FOUND.into_response(), } } diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index dd7607d..a609485 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -1,3 +1,4 @@ +use crate::activity_log::{ActionType, ActivityEntry}; use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference}; use crate::AppState; use axum::{ @@ -75,12 +76,22 @@ async fn download_blob( let key = format!("docker/{}/blobs/{}", name, digest); match state.storage.get(&key).await { - Ok(data) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/octet-stream")], - data, - ) - .into_response(), + Ok(data) => { + state.metrics.record_download("docker"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::Pull, + format!("{}@{}", name, &digest[..19.min(digest.len())]), + "docker", + "LOCAL", + )); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/octet-stream")], + data, + ) + .into_response() + } Err(_) => StatusCode::NOT_FOUND.into_response(), } } @@ -176,6 +187,13 @@ async fn upload_blob( let key = format!("docker/{}/blobs/{}", name, digest); match state.storage.put(&key, &data).await { Ok(()) => { + state.metrics.record_upload("docker"); + state.activity.push(ActivityEntry::new( + ActionType::Push, + format!("{}@{}", name, &digest[..19.min(digest.len())]), + "docker", + "LOCAL", + )); let location = format!("/v2/{}/blobs/{}", name, digest); (StatusCode::CREATED, [(header::LOCATION, location)]).into_response() } @@ -197,6 +215,15 @@ async fn get_manifest( let key = format!("docker/{}/manifests/{}.json", name, reference); match state.storage.get(&key).await { Ok(data) => { + state.metrics.record_download("docker"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::Pull, + format!("{}:{}", name, reference), + "docker", + "LOCAL", + )); + // Calculate digest for Docker-Content-Digest header use sha2::Digest; let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data)); @@ -245,6 +272,14 @@ async fn put_manifest( return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } + state.metrics.record_upload("docker"); + state.activity.push(ActivityEntry::new( + ActionType::Push, + format!("{}:{}", name, reference), + "docker", + "LOCAL", + )); + let location = format!("/v2/{}/manifests/{}", name, reference); ( StatusCode::CREATED, diff --git a/nora-registry/src/registry/maven.rs b/nora-registry/src/registry/maven.rs index c16d8fb..02d2487 100644 --- a/nora-registry/src/registry/maven.rs +++ b/nora-registry/src/registry/maven.rs @@ -1,3 +1,4 @@ +use crate::activity_log::{ActionType, ActivityEntry}; use crate::AppState; use axum::{ body::Bytes, @@ -19,8 +20,19 @@ pub fn routes() -> Router> { async fn download(State(state): State>, Path(path): Path) -> Response { let key = format!("maven/{}", path); + // Extract artifact name for logging (last 2-3 path components) + let artifact_name = path.split('/').rev().take(3).collect::>().into_iter().rev().collect::>().join("/"); + // Try local storage first if let Ok(data) = state.storage.get(&key).await { + state.metrics.record_download("maven"); + state.metrics.record_cache_hit(); + state.activity.push(ActivityEntry::new( + ActionType::CacheHit, + artifact_name, + "maven", + "CACHE", + )); return with_content_type(&path, data).into_response(); } @@ -30,6 +42,15 @@ async fn download(State(state): State>, Path(path): Path) match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await { Ok(data) => { + state.metrics.record_download("maven"); + state.metrics.record_cache_miss(); + state.activity.push(ActivityEntry::new( + ActionType::ProxyFetch, + artifact_name, + "maven", + "PROXY", + )); + // Cache in local storage (fire and forget) let storage = state.storage.clone(); let key_clone = key.clone(); @@ -53,8 +74,21 @@ async fn upload( body: Bytes, ) -> StatusCode { let key = format!("maven/{}", path); + + // Extract artifact name for logging + let artifact_name = path.split('/').rev().take(3).collect::>().into_iter().rev().collect::>().join("/"); + match state.storage.put(&key, &body).await { - Ok(()) => StatusCode::CREATED, + Ok(()) => { + state.metrics.record_upload("maven"); + state.activity.push(ActivityEntry::new( + ActionType::Push, + artifact_name, + "maven", + "LOCAL", + )); + StatusCode::CREATED + } Err(_) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index af6e72d..96d57b3 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -1,3 +1,4 @@ +use crate::activity_log::{ActionType, ActivityEntry}; use crate::AppState; use axum::{ body::Bytes, @@ -29,8 +30,25 @@ async fn handle_request(State(state): State>, Path(path): Path>, Path(path): Path, } +#[derive(Serialize)] +pub struct DashboardResponse { + pub global_stats: GlobalStats, + pub registry_stats: Vec, + pub mount_points: Vec, + pub activity: Vec, + pub uptime_seconds: u64, +} + +#[derive(Serialize)] +pub struct GlobalStats { + pub downloads: u64, + pub uploads: u64, + pub artifacts: u64, + pub cache_hit_percent: f64, + pub storage_bytes: u64, +} + +#[derive(Serialize)] +pub struct RegistryCardStats { + pub name: String, + pub artifact_count: usize, + pub downloads: u64, + pub uploads: u64, + pub size_bytes: u64, +} + +#[derive(Serialize)] +pub struct MountPoint { + pub registry: String, + pub mount_path: String, + pub proxy_upstream: Option, +} + // ============ API Handlers ============ pub async fn api_stats(State(state): State>) -> Json { @@ -74,6 +110,124 @@ pub async fn api_stats(State(state): State>) -> Json>) -> Json { + let registry_stats = get_registry_stats(&state.storage).await; + + // Calculate total storage size + let all_keys = state.storage.list("").await; + let mut total_storage: u64 = 0; + let mut docker_size: u64 = 0; + let mut maven_size: u64 = 0; + let mut npm_size: u64 = 0; + let mut cargo_size: u64 = 0; + let mut pypi_size: u64 = 0; + + for key in &all_keys { + if let Some(meta) = state.storage.stat(key).await { + total_storage += meta.size; + if key.starts_with("docker/") { + docker_size += meta.size; + } else if key.starts_with("maven/") { + maven_size += meta.size; + } else if key.starts_with("npm/") { + npm_size += meta.size; + } else if key.starts_with("cargo/") { + cargo_size += meta.size; + } else if key.starts_with("pypi/") { + pypi_size += meta.size; + } + } + } + + let total_artifacts = registry_stats.docker + registry_stats.maven + + registry_stats.npm + registry_stats.cargo + registry_stats.pypi; + + let global_stats = GlobalStats { + downloads: state.metrics.downloads.load(Ordering::Relaxed), + uploads: state.metrics.uploads.load(Ordering::Relaxed), + artifacts: total_artifacts as u64, + cache_hit_percent: state.metrics.cache_hit_rate(), + storage_bytes: total_storage, + }; + + let registry_card_stats = vec![ + RegistryCardStats { + name: "docker".to_string(), + artifact_count: registry_stats.docker, + downloads: state.metrics.get_registry_downloads("docker"), + uploads: state.metrics.get_registry_uploads("docker"), + size_bytes: docker_size, + }, + RegistryCardStats { + name: "maven".to_string(), + artifact_count: registry_stats.maven, + downloads: state.metrics.get_registry_downloads("maven"), + uploads: state.metrics.get_registry_uploads("maven"), + size_bytes: maven_size, + }, + RegistryCardStats { + name: "npm".to_string(), + artifact_count: registry_stats.npm, + downloads: state.metrics.get_registry_downloads("npm"), + uploads: 0, + size_bytes: npm_size, + }, + RegistryCardStats { + name: "cargo".to_string(), + artifact_count: registry_stats.cargo, + downloads: state.metrics.get_registry_downloads("cargo"), + uploads: 0, + size_bytes: cargo_size, + }, + RegistryCardStats { + name: "pypi".to_string(), + artifact_count: registry_stats.pypi, + downloads: state.metrics.get_registry_downloads("pypi"), + uploads: 0, + size_bytes: pypi_size, + }, + ]; + + let mount_points = vec![ + MountPoint { + registry: "Docker".to_string(), + mount_path: "/v2/".to_string(), + proxy_upstream: None, + }, + MountPoint { + registry: "Maven".to_string(), + mount_path: "/maven2/".to_string(), + proxy_upstream: state.config.maven.proxies.first().cloned(), + }, + MountPoint { + registry: "npm".to_string(), + mount_path: "/npm/".to_string(), + proxy_upstream: state.config.npm.proxy.clone(), + }, + MountPoint { + registry: "Cargo".to_string(), + mount_path: "/cargo/".to_string(), + proxy_upstream: None, + }, + MountPoint { + registry: "PyPI".to_string(), + mount_path: "/simple/".to_string(), + proxy_upstream: None, + }, + ]; + + let activity = state.activity.recent(20); + let uptime_seconds = state.start_time.elapsed().as_secs(); + + Json(DashboardResponse { + global_stats, + registry_stats: registry_card_stats, + mount_points, + activity, + uptime_seconds, + }) +} + pub async fn api_list( State(state): State>, Path(registry_type): Path, diff --git a/nora-registry/src/ui/components.rs b/nora-registry/src/ui/components.rs index c6099a1..514b30f 100644 --- a/nora-registry/src/ui/components.rs +++ b/nora-registry/src/ui/components.rs @@ -60,6 +60,397 @@ pub fn layout(title: &str, content: &str, active_page: Option<&str>) -> String { ) } +/// Dark theme layout wrapper for dashboard +pub fn layout_dark(title: &str, content: &str, active_page: Option<&str>, extra_scripts: &str) -> String { + format!( + r##" + + + + + {} - Nora + + + + + +
+ + + + + {} + + +
+ + {} + + +
+ {} +
+
+
+ + + {} + +"##, + html_escape(title), + sidebar_dark(active_page), + header_dark(), + content, + extra_scripts + ) +} + +/// Dark theme sidebar +fn sidebar_dark(active_page: Option<&str>) -> String { + let active = active_page.unwrap_or(""); + + let docker_icon = r#""#; + let maven_icon = r#""#; + let npm_icon = r#""#; + let cargo_icon = r#""#; + let pypi_icon = r#""#; + + let nav_items = [ + ( + "dashboard", + "/ui/", + "Dashboard", + r#""#, + true, + ), + ("docker", "/ui/docker", "Docker", docker_icon, false), + ("maven", "/ui/maven", "Maven", maven_icon, false), + ("npm", "/ui/npm", "npm", npm_icon, false), + ("cargo", "/ui/cargo", "Cargo", cargo_icon, false), + ("pypi", "/ui/pypi", "PyPI", pypi_icon, false), + ]; + + let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| { + let is_active = active == *id; + let active_class = if is_active { + "bg-slate-700 text-white" + } else { + "text-slate-300 hover:bg-slate-700 hover:text-white" + }; + + let (fill_attr, stroke_attr) = if *is_stroke { + ("none", r#" stroke="currentColor""#) + } else { + ("currentColor", "") + }; + + format!(r##" + + + {} + + {} + + "##, href, active_class, fill_attr, stroke_attr, icon_path, label) + }).collect(); + + format!( + r#" + + "#, + super::logo::LOGO_BASE64, + nav_html + ) +} + +/// Dark theme header +fn header_dark() -> String { + r##" +
+
+ +
+ NRA +
+
+ +
+ "##.to_string() +} + +/// Render global stats row (5-column grid) +pub fn render_global_stats(downloads: u64, uploads: u64, artifacts: u64, cache_hit_percent: f64, storage_bytes: u64) -> String { + format!( + r##" +
+
+
Downloads
+
{}
+
+
+
Uploads
+
{}
+
+
+
Artifacts
+
{}
+
+
+
Cache Hit
+
{:.1}%
+
+
+
Storage
+
{}
+
+
+ "##, + downloads, + uploads, + artifacts, + cache_hit_percent, + format_size(storage_bytes) + ) +} + +/// Render registry card with extended metrics +pub fn render_registry_card(name: &str, icon_path: &str, artifact_count: usize, downloads: u64, uploads: u64, size_bytes: u64, href: &str) -> String { + format!( + r##" + +
+ + {} + + ACTIVE +
+
{}
+
+
+ Artifacts +
{}
+
+
+ Size +
{}
+
+
+ Downloads +
{}
+
+
+ Uploads +
{}
+
+
+
+ "##, + href, + name.to_lowercase(), + icon_path, + name, + artifact_count, + format_size(size_bytes), + downloads, + uploads + ) +} + +/// Render mount points table +pub fn render_mount_points_table(mount_points: &[(String, String, Option)]) -> String { + let rows: String = mount_points + .iter() + .map(|(registry, mount_path, proxy)| { + let proxy_display = proxy.as_deref().unwrap_or("-"); + format!( + r##" + + {} + {} + {} + + "##, + registry, mount_path, proxy_display + ) + }) + .collect(); + + format!( + r##" +
+
+

Mount Points

+
+ + + + + + + + + + {} + +
RegistryMount PathProxy Upstream
+
+ "##, + rows + ) +} + +/// Render a single activity log row +pub fn render_activity_row(timestamp: &str, action: &str, artifact: &str, registry: &str, source: &str) -> String { + let action_color = match action { + "PULL" => "text-blue-400", + "PUSH" => "text-green-400", + "CACHE" => "text-yellow-400", + "PROXY" => "text-purple-400", + _ => "text-slate-400", + }; + + format!( + r##" + + {} + {} + {} + {} + {} + + "##, + timestamp, action_color, action, html_escape(artifact), registry, source + ) +} + +/// Render the activity log container +pub fn render_activity_log(rows: &str) -> String { + format!( + r##" +
+
+

Recent Activity

+
+
+ + + + + + + + + + + + {} + +
TimeActionArtifactRegistrySource
+
+
+ "##, + rows + ) +} + +/// Render the polling script for auto-refresh +pub fn render_polling_script() -> String { + r##" + + "##.to_string() +} + /// Sidebar navigation component fn sidebar(active_page: Option<&str>) -> String { let active = active_page.unwrap_or(""); @@ -193,7 +584,8 @@ pub mod icons { pub const PYPI: &str = r#""#; } -/// Stat card for dashboard with SVG icon +/// Stat card for dashboard with SVG icon (used in light theme pages) +#[allow(dead_code)] pub fn stat_card(name: &str, icon_path: &str, count: usize, href: &str, unit: &str) -> String { format!( r##" diff --git a/nora-registry/src/ui/mod.rs b/nora-registry/src/ui/mod.rs index ad363d3..fa007f8 100644 --- a/nora-registry/src/ui/mod.rs +++ b/nora-registry/src/ui/mod.rs @@ -33,6 +33,7 @@ pub fn routes() -> Router> { .route("/ui/pypi/{name}", get(pypi_detail)) // API endpoints for HTMX .route("/api/ui/stats", get(api_stats)) + .route("/api/ui/dashboard", get(api_dashboard)) .route("/api/ui/{registry_type}/list", get(api_list)) .route("/api/ui/{registry_type}/{name}", get(api_detail)) .route("/api/ui/{registry_type}/search", get(api_search)) @@ -40,8 +41,8 @@ pub fn routes() -> Router> { // Dashboard page async fn dashboard(State(state): State>) -> impl IntoResponse { - let stats = get_registry_stats(&state.storage).await; - Html(render_dashboard(&stats)) + let response = api_dashboard(State(state)).await.0; + Html(render_dashboard(&response)) } // Docker pages diff --git a/nora-registry/src/ui/templates.rs b/nora-registry/src/ui/templates.rs index f280518..b3ec1e0 100644 --- a/nora-registry/src/ui/templates.rs +++ b/nora-registry/src/ui/templates.rs @@ -1,84 +1,128 @@ -use super::api::{DockerDetail, MavenDetail, PackageDetail, RegistryStats, RepoInfo}; +use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo}; use super::components::*; -/// Renders the main dashboard page -pub fn render_dashboard(stats: &RegistryStats) -> String { - let content = format!( - r##" -
-

Dashboard

-

Overview of all registries

-
- -
- {} - {} - {} - {} - {} -
- - - "##, - stat_card( - "Docker", - icons::DOCKER, - stats.docker, - "/ui/docker", - "images" - ), - stat_card("Maven", icons::MAVEN, stats.maven, "/ui/maven", "artifacts"), - stat_card("npm", icons::NPM, stats.npm, "/ui/npm", "packages"), - stat_card("Cargo", icons::CARGO, stats.cargo, "/ui/cargo", "crates"), - stat_card("PyPI", icons::PYPI, stats.pypi, "/ui/pypi", "packages"), - // Quick Links icons - icons::DOCKER, - icons::MAVEN, - icons::NPM, - icons::CARGO, - icons::PYPI, +/// Renders the main dashboard page with dark theme +pub fn render_dashboard(data: &DashboardResponse) -> String { + // Render global stats + let global_stats = render_global_stats( + data.global_stats.downloads, + data.global_stats.uploads, + data.global_stats.artifacts, + data.global_stats.cache_hit_percent, + data.global_stats.storage_bytes, ); - layout("Dashboard", &content, Some("dashboard")) + // Render registry cards + let registry_cards: String = data.registry_stats.iter().map(|r| { + let icon = match r.name.as_str() { + "docker" => icons::DOCKER, + "maven" => icons::MAVEN, + "npm" => icons::NPM, + "cargo" => icons::CARGO, + "pypi" => icons::PYPI, + _ => icons::DOCKER, + }; + let display_name = match r.name.as_str() { + "docker" => "Docker", + "maven" => "Maven", + "npm" => "npm", + "cargo" => "Cargo", + "pypi" => "PyPI", + _ => &r.name, + }; + render_registry_card( + display_name, + icon, + r.artifact_count, + r.downloads, + r.uploads, + r.size_bytes, + &format!("/ui/{}", r.name), + ) + }).collect(); + + // Render mount points + let mount_data: Vec<(String, String, Option)> = data.mount_points.iter() + .map(|m| (m.registry.clone(), m.mount_path.clone(), m.proxy_upstream.clone())) + .collect(); + let mount_points = render_mount_points_table(&mount_data); + + // Render activity log + let activity_rows: String = if data.activity.is_empty() { + r##"No recent activity"##.to_string() + } else { + data.activity.iter().map(|entry| { + let time_ago = format_relative_time(&entry.timestamp); + render_activity_row( + &time_ago, + &entry.action.to_string(), + &entry.artifact, + &entry.registry, + &entry.source, + ) + }).collect() + }; + let activity_log = render_activity_log(&activity_rows); + + // Format uptime + let hours = data.uptime_seconds / 3600; + let mins = (data.uptime_seconds % 3600) / 60; + let uptime_str = format!("{}h {}m", hours, mins); + + let content = format!( + r##" +
+
+
+

Dashboard

+

Overview of all registries

+
+
+
Uptime
+
{}
+
+
+
+ + {} + +
+ {} +
+ +
+ {} + {} +
+ "##, + uptime_str, + global_stats, + registry_cards, + mount_points, + activity_log, + ); + + let polling_script = render_polling_script(); + layout_dark("Dashboard", &content, Some("dashboard"), &polling_script) +} + +/// Format timestamp as relative time (e.g., "2 min ago") +fn format_relative_time(timestamp: &chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(*timestamp); + + if diff.num_seconds() < 60 { + "just now".to_string() + } else if diff.num_minutes() < 60 { + let mins = diff.num_minutes(); + format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" }) + } else if diff.num_hours() < 24 { + let hours = diff.num_hours(); + format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" }) + } else { + let days = diff.num_days(); + format!("{} day{} ago", days, if days == 1 { "" } else { "s" }) + } } /// Renders a registry list page (docker, maven, npm, cargo, pypi)