mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 10:20:32 +00:00
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
This commit is contained in:
103
nora-registry/src/activity_log.rs
Normal file
103
nora-registry/src/activity_log.rs
Normal file
@@ -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<Utc>,
|
||||||
|
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<VecDeque<ActivityEntry>>,
|
||||||
|
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<ActivityEntry> {
|
||||||
|
let entries = self.entries.read();
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(count)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all entries (newest first)
|
||||||
|
pub fn all(&self) -> Vec<ActivityEntry> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
nora-registry/src/dashboard_metrics.rs
Normal file
114
nora-registry/src/dashboard_metrics.rs
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
mod activity_log;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod dashboard_metrics;
|
||||||
mod error;
|
mod error;
|
||||||
mod health;
|
mod health;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
@@ -23,8 +25,10 @@ use tokio::signal;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||||
|
|
||||||
|
use activity_log::ActivityLog;
|
||||||
use auth::HtpasswdAuth;
|
use auth::HtpasswdAuth;
|
||||||
use config::{Config, StorageMode};
|
use config::{Config, StorageMode};
|
||||||
|
use dashboard_metrics::DashboardMetrics;
|
||||||
pub use storage::Storage;
|
pub use storage::Storage;
|
||||||
use tokens::TokenStore;
|
use tokens::TokenStore;
|
||||||
|
|
||||||
@@ -71,6 +75,8 @@ pub struct AppState {
|
|||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
pub auth: Option<HtpasswdAuth>,
|
pub auth: Option<HtpasswdAuth>,
|
||||||
pub tokens: Option<TokenStore>,
|
pub tokens: Option<TokenStore>,
|
||||||
|
pub metrics: DashboardMetrics,
|
||||||
|
pub activity: ActivityLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -205,6 +211,8 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
start_time,
|
start_time,
|
||||||
auth,
|
auth,
|
||||||
tokens,
|
tokens,
|
||||||
|
metrics: DashboardMetrics::new(),
|
||||||
|
activity: ActivityLog::new(50),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token routes with strict rate limiting (brute-force protection)
|
// Token routes with strict rate limiting (brute-force protection)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
@@ -37,7 +38,17 @@ async fn download(
|
|||||||
crate_name, version, crate_name, version
|
crate_name, version, crate_name, version
|
||||||
);
|
);
|
||||||
match state.storage.get(&key).await {
|
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(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -75,12 +76,22 @@ async fn download_blob(
|
|||||||
|
|
||||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||||
match state.storage.get(&key).await {
|
match state.storage.get(&key).await {
|
||||||
Ok(data) => (
|
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,
|
StatusCode::OK,
|
||||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response()
|
||||||
|
}
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +187,13 @@ async fn upload_blob(
|
|||||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||||
match state.storage.put(&key, &data).await {
|
match state.storage.put(&key, &data).await {
|
||||||
Ok(()) => {
|
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);
|
let location = format!("/v2/{}/blobs/{}", name, digest);
|
||||||
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
|
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
|
||||||
}
|
}
|
||||||
@@ -197,6 +215,15 @@ async fn get_manifest(
|
|||||||
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
||||||
match state.storage.get(&key).await {
|
match state.storage.get(&key).await {
|
||||||
Ok(data) => {
|
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
|
// Calculate digest for Docker-Content-Digest header
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
||||||
@@ -245,6 +272,14 @@ async fn put_manifest(
|
|||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
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);
|
let location = format!("/v2/{}/manifests/{}", name, reference);
|
||||||
(
|
(
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -19,8 +20,19 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||||
let key = format!("maven/{}", path);
|
let key = format!("maven/{}", path);
|
||||||
|
|
||||||
|
// Extract artifact name for logging (last 2-3 path components)
|
||||||
|
let artifact_name = path.split('/').rev().take(3).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("/");
|
||||||
|
|
||||||
// Try local storage first
|
// Try local storage first
|
||||||
if let Ok(data) = state.storage.get(&key).await {
|
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();
|
return with_content_type(&path, data).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +42,15 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
|||||||
|
|
||||||
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
|
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
|
||||||
Ok(data) => {
|
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)
|
// Cache in local storage (fire and forget)
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
@@ -53,8 +74,21 @@ async fn upload(
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> StatusCode {
|
) -> StatusCode {
|
||||||
let key = format!("maven/{}", path);
|
let key = format!("maven/{}", path);
|
||||||
|
|
||||||
|
// Extract artifact name for logging
|
||||||
|
let artifact_name = path.split('/').rev().take(3).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("/");
|
||||||
|
|
||||||
match state.storage.put(&key, &body).await {
|
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,
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::activity_log::{ActionType, ActivityEntry};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
@@ -29,8 +30,25 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
format!("npm/{}/metadata.json", path)
|
format!("npm/{}/metadata.json", path)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract package name for logging
|
||||||
|
let package_name = if is_tarball {
|
||||||
|
path.split("/-/").next().unwrap_or(&path).to_string()
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// Try local storage first
|
// Try local storage first
|
||||||
if let Ok(data) = state.storage.get(&key).await {
|
if let Ok(data) = state.storage.get(&key).await {
|
||||||
|
if is_tarball {
|
||||||
|
state.metrics.record_download("npm");
|
||||||
|
state.metrics.record_cache_hit();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::CacheHit,
|
||||||
|
package_name,
|
||||||
|
"npm",
|
||||||
|
"CACHE",
|
||||||
|
));
|
||||||
|
}
|
||||||
return with_content_type(is_tarball, data).into_response();
|
return with_content_type(is_tarball, data).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +63,17 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
||||||
|
if is_tarball {
|
||||||
|
state.metrics.record_download("npm");
|
||||||
|
state.metrics.record_cache_miss();
|
||||||
|
state.activity.push(ActivityEntry::new(
|
||||||
|
ActionType::ProxyFetch,
|
||||||
|
package_name,
|
||||||
|
"npm",
|
||||||
|
"PROXY",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Cache in local storage (fire and forget)
|
// Cache in local storage (fire and forget)
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
let key_clone = key.clone();
|
let key_clone = key.clone();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::components::{format_size, format_timestamp, html_escape};
|
use super::components::{format_size, format_timestamp, html_escape};
|
||||||
use super::templates::encode_uri_component;
|
use super::templates::encode_uri_component;
|
||||||
|
use crate::activity_log::ActivityEntry;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::Storage;
|
use crate::Storage;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -8,6 +9,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -67,6 +69,40 @@ pub struct SearchQuery {
|
|||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DashboardResponse {
|
||||||
|
pub global_stats: GlobalStats,
|
||||||
|
pub registry_stats: Vec<RegistryCardStats>,
|
||||||
|
pub mount_points: Vec<MountPoint>,
|
||||||
|
pub activity: Vec<ActivityEntry>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============ API Handlers ============
|
// ============ API Handlers ============
|
||||||
|
|
||||||
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
|
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
|
||||||
@@ -74,6 +110,124 @@ pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats
|
|||||||
Json(stats)
|
Json(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<DashboardResponse> {
|
||||||
|
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(
|
pub async fn api_list(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(registry_type): Path<String>,
|
Path(registry_type): Path<String>,
|
||||||
|
|||||||
@@ -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##"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{} - Nora</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<style>
|
||||||
|
[x-cloak] {{ display: none !important; }}
|
||||||
|
.sidebar-open {{ overflow: hidden; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-[#0f172a] min-h-screen">
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Mobile sidebar overlay -->
|
||||||
|
<div id="sidebar-overlay" class="fixed inset-0 bg-black/50 z-40 hidden md:hidden" onclick="toggleSidebar()"></div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
{}
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
|
<!-- Header -->
|
||||||
|
{}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||||
|
{}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleSidebar() {{
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const overlay = document.getElementById('sidebar-overlay');
|
||||||
|
const isOpen = !sidebar.classList.contains('-translate-x-full');
|
||||||
|
|
||||||
|
if (isOpen) {{
|
||||||
|
sidebar.classList.add('-translate-x-full');
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
document.body.classList.remove('sidebar-open');
|
||||||
|
}} else {{
|
||||||
|
sidebar.classList.remove('-translate-x-full');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
document.body.classList.add('sidebar-open');
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
{}
|
||||||
|
</body>
|
||||||
|
</html>"##,
|
||||||
|
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#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||||
|
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||||
|
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||||
|
let cargo_icon = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
||||||
|
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||||
|
|
||||||
|
let nav_items = [
|
||||||
|
(
|
||||||
|
"dashboard",
|
||||||
|
"/ui/",
|
||||||
|
"Dashboard",
|
||||||
|
r#"<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>"#,
|
||||||
|
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##"
|
||||||
|
<a href="{}" class="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors {}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="{}"{} viewBox="0 0 24 24">
|
||||||
|
{}
|
||||||
|
</svg>
|
||||||
|
{}
|
||||||
|
</a>
|
||||||
|
"##, href, active_class, fill_attr, stroke_attr, icon_path, label)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
<div id="sidebar" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-slate-800 text-white flex flex-col transform -translate-x-full md:translate-x-0 transition-transform duration-200 ease-in-out">
|
||||||
|
<div class="h-16 flex items-center justify-between px-6 border-b border-slate-700">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img src="{}" alt="NORA" class="h-8" />
|
||||||
|
</div>
|
||||||
|
<button onclick="toggleSidebar()" class="md:hidden p-1 rounded-lg hover:bg-slate-700">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||||
|
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mb-3">
|
||||||
|
Navigation
|
||||||
|
</div>
|
||||||
|
{}
|
||||||
|
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mt-8 mb-3">
|
||||||
|
Registries
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="px-4 py-4 border-t border-slate-700">
|
||||||
|
<div class="text-xs text-slate-400">
|
||||||
|
Nora v0.2.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"#,
|
||||||
|
super::logo::LOGO_BASE64,
|
||||||
|
nav_html
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dark theme header
|
||||||
|
fn header_dark() -> String {
|
||||||
|
r##"
|
||||||
|
<header class="h-16 bg-[#1e293b] border-b border-slate-700 flex items-center justify-between px-4 md:px-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button onclick="toggleSidebar()" class="md:hidden p-2 -ml-2 mr-2 rounded-lg hover:bg-slate-700">
|
||||||
|
<svg class="w-6 h-6 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="md:hidden flex items-center">
|
||||||
|
<span class="font-bold text-slate-200 tracking-tight">N<span class="inline-block w-4 h-4 rounded-full border-2 border-current align-middle mx-px"></span>RA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 md:space-x-4">
|
||||||
|
<a href="https://github.com/getnora-io/nora" target="_blank" class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded-lg">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="/api-docs" class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded-lg" title="API Docs">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
"##.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##"
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">Downloads</div>
|
||||||
|
<div id="stat-downloads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">Uploads</div>
|
||||||
|
<div id="stat-uploads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">Artifacts</div>
|
||||||
|
<div id="stat-artifacts" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">Cache Hit</div>
|
||||||
|
<div id="stat-cache-hit" class="text-2xl font-bold text-slate-200">{:.1}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||||
|
<div class="text-slate-400 text-sm mb-1">Storage</div>
|
||||||
|
<div id="stat-storage" class="text-2xl font-bold text-slate-200">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
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##"
|
||||||
|
<a href="{}" id="registry-{}" class="block bg-[#1e293b] rounded-lg border border-slate-700 p-4 md:p-6 hover:border-blue-400 transition-all">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<svg class="w-8 h-8 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
{}
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-medium text-green-400 bg-green-400/10 px-2 py-1 rounded-full">ACTIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-semibold text-slate-200 mb-2">{}</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">Artifacts</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">Size</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">Downloads</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-500">Uploads</span>
|
||||||
|
<div class="text-slate-300 font-medium">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
"##,
|
||||||
|
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>)]) -> String {
|
||||||
|
let rows: String = mount_points
|
||||||
|
.iter()
|
||||||
|
.map(|(registry, mount_path, proxy)| {
|
||||||
|
let proxy_display = proxy.as_deref().unwrap_or("-");
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<tr class="border-b border-slate-700">
|
||||||
|
<td class="py-3 text-slate-300">{}</td>
|
||||||
|
<td class="py-3 font-mono text-blue-400">{}</td>
|
||||||
|
<td class="py-3 text-slate-400">{}</td>
|
||||||
|
</tr>
|
||||||
|
"##,
|
||||||
|
registry, mount_path, proxy_display
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700">
|
||||||
|
<h3 class="text-slate-200 font-semibold">Mount Points</h3>
|
||||||
|
</div>
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||||
|
<th class="px-4 py-2">Registry</th>
|
||||||
|
<th class="px-4 py-2">Mount Path</th>
|
||||||
|
<th class="px-4 py-2">Proxy Upstream</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="px-4">
|
||||||
|
{}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
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##"
|
||||||
|
<tr class="border-b border-slate-700/50 text-sm">
|
||||||
|
<td class="py-2 text-slate-500">{}</td>
|
||||||
|
<td class="py-2 font-medium {}"><span class="px-2 py-0.5 bg-slate-700 rounded">{}</span></td>
|
||||||
|
<td class="py-2 text-slate-300 font-mono text-xs">{}</td>
|
||||||
|
<td class="py-2 text-slate-400">{}</td>
|
||||||
|
<td class="py-2 text-slate-500">{}</td>
|
||||||
|
</tr>
|
||||||
|
"##,
|
||||||
|
timestamp, action_color, action, html_escape(artifact), registry, source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the activity log container
|
||||||
|
pub fn render_activity_log(rows: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r##"
|
||||||
|
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-slate-700">
|
||||||
|
<h3 class="text-slate-200 font-semibold">Recent Activity</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full" id="activity-log">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||||
|
<th class="px-4 py-2">Time</th>
|
||||||
|
<th class="px-4 py-2">Action</th>
|
||||||
|
<th class="px-4 py-2">Artifact</th>
|
||||||
|
<th class="px-4 py-2">Registry</th>
|
||||||
|
<th class="px-4 py-2">Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="px-4">
|
||||||
|
{}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the polling script for auto-refresh
|
||||||
|
pub fn render_polling_script() -> String {
|
||||||
|
r##"
|
||||||
|
<script>
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetch('/api/ui/dashboard').then(r => r.json());
|
||||||
|
|
||||||
|
// Update global stats
|
||||||
|
document.getElementById('stat-downloads').textContent = data.global_stats.downloads;
|
||||||
|
document.getElementById('stat-uploads').textContent = data.global_stats.uploads;
|
||||||
|
document.getElementById('stat-artifacts').textContent = data.global_stats.artifacts;
|
||||||
|
document.getElementById('stat-cache-hit').textContent = data.global_stats.cache_hit_percent.toFixed(1) + '%';
|
||||||
|
|
||||||
|
// Format storage size
|
||||||
|
const bytes = data.global_stats.storage_bytes;
|
||||||
|
let sizeStr;
|
||||||
|
if (bytes >= 1073741824) sizeStr = (bytes / 1073741824).toFixed(1) + ' GB';
|
||||||
|
else if (bytes >= 1048576) sizeStr = (bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
else if (bytes >= 1024) sizeStr = (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
else sizeStr = bytes + ' B';
|
||||||
|
document.getElementById('stat-storage').textContent = sizeStr;
|
||||||
|
|
||||||
|
// Update uptime
|
||||||
|
const uptime = document.getElementById('uptime');
|
||||||
|
if (uptime) {
|
||||||
|
const secs = data.uptime_seconds;
|
||||||
|
const hours = Math.floor(secs / 3600);
|
||||||
|
const mins = Math.floor((secs % 3600) / 60);
|
||||||
|
uptime.textContent = hours + 'h ' + mins + 'm';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard poll failed:', e);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
</script>
|
||||||
|
"##.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Sidebar navigation component
|
/// Sidebar navigation component
|
||||||
fn sidebar(active_page: Option<&str>) -> String {
|
fn sidebar(active_page: Option<&str>) -> String {
|
||||||
let active = active_page.unwrap_or("");
|
let active = active_page.unwrap_or("");
|
||||||
@@ -193,7 +584,8 @@ pub mod icons {
|
|||||||
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
pub fn stat_card(name: &str, icon_path: &str, count: usize, href: &str, unit: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r##"
|
r##"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/ui/pypi/{name}", get(pypi_detail))
|
.route("/ui/pypi/{name}", get(pypi_detail))
|
||||||
// API endpoints for HTMX
|
// API endpoints for HTMX
|
||||||
.route("/api/ui/stats", get(api_stats))
|
.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}/list", get(api_list))
|
||||||
.route("/api/ui/{registry_type}/{name}", get(api_detail))
|
.route("/api/ui/{registry_type}/{name}", get(api_detail))
|
||||||
.route("/api/ui/{registry_type}/search", get(api_search))
|
.route("/api/ui/{registry_type}/search", get(api_search))
|
||||||
@@ -40,8 +41,8 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
|
|
||||||
// Dashboard page
|
// Dashboard page
|
||||||
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
let stats = get_registry_stats(&state.storage).await;
|
let response = api_dashboard(State(state)).await.0;
|
||||||
Html(render_dashboard(&stats))
|
Html(render_dashboard(&response))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker pages
|
// Docker pages
|
||||||
|
|||||||
@@ -1,84 +1,128 @@
|
|||||||
use super::api::{DockerDetail, MavenDetail, PackageDetail, RegistryStats, RepoInfo};
|
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
|
||||||
use super::components::*;
|
use super::components::*;
|
||||||
|
|
||||||
/// Renders the main dashboard page
|
/// Renders the main dashboard page with dark theme
|
||||||
pub fn render_dashboard(stats: &RegistryStats) -> String {
|
pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||||
let content = format!(
|
// Render global stats
|
||||||
r##"
|
let global_stats = render_global_stats(
|
||||||
<div class="mb-8">
|
data.global_stats.downloads,
|
||||||
<h1 class="text-2xl font-bold text-slate-800 mb-2">Dashboard</h1>
|
data.global_stats.uploads,
|
||||||
<p class="text-slate-500">Overview of all registries</p>
|
data.global_stats.artifacts,
|
||||||
</div>
|
data.global_stats.cache_hit_percent,
|
||||||
|
data.global_stats.storage_bytes,
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
{}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-slate-800 mb-4">Quick Links</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<a href="/ui/docker" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Docker Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /v2/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/maven" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Maven Repository</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /maven2/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/npm" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">npm Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /npm/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/cargo" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">Cargo Registry</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /cargo/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="/ui/pypi" class="flex items-center p-3 rounded-lg border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-colors">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-slate-600" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium text-slate-700">PyPI Repository</div>
|
|
||||||
<div class="text-sm text-slate-500">API: /simple/</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"##,
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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<String>)> = 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##"<tr><td colspan="5" class="py-8 text-center text-slate-500">No recent activity</td></tr>"##.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##"
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-200 mb-1">Dashboard</h1>
|
||||||
|
<p class="text-slate-400">Overview of all registries</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-sm text-slate-500">Uptime</div>
|
||||||
|
<div id="uptime" class="text-lg font-semibold text-slate-300">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{}
|
||||||
|
{}
|
||||||
|
</div>
|
||||||
|
"##,
|
||||||
|
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<chrono::Utc>) -> 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)
|
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
||||||
|
|||||||
Reference in New Issue
Block a user