diff --git a/Cargo.lock b/Cargo.lock index 6df9c87..81427d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1201,7 +1201,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" [[package]] name = "nora-cli" -version = "0.2.12" +version = "0.2.18" dependencies = [ "clap", "flate2", @@ -1215,7 +1215,7 @@ dependencies = [ [[package]] name = "nora-registry" -version = "0.2.12" +version = "0.2.18" dependencies = [ "async-trait", "axum", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "nora-storage" -version = "0.2.12" +version = "0.2.18" dependencies = [ "axum", "base64", diff --git a/nora-registry/src/main.rs b/nora-registry/src/main.rs index da345cf..8dd9bb0 100644 --- a/nora-registry/src/main.rs +++ b/nora-registry/src/main.rs @@ -13,6 +13,7 @@ mod migrate; mod openapi; mod rate_limit; mod registry; +mod repo_index; mod request_id; mod secrets; mod storage; @@ -33,6 +34,7 @@ use activity_log::ActivityLog; use auth::HtpasswdAuth; use config::{Config, StorageMode}; use dashboard_metrics::DashboardMetrics; +use repo_index::RepoIndex; pub use storage::Storage; use tokens::TokenStore; @@ -82,6 +84,7 @@ pub struct AppState { pub metrics: DashboardMetrics, pub activity: ActivityLog, pub docker_auth: registry::DockerAuth, + pub repo_index: RepoIndex, } #[tokio::main] @@ -277,6 +280,7 @@ async fn run_server(config: Config, storage: Storage) { metrics: DashboardMetrics::new(), activity: ActivityLog::new(50), docker_auth, + repo_index: RepoIndex::new(), }); // Token routes with strict rate limiting (brute-force protection) diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index 4ea9cce..3c0de24 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -192,6 +192,8 @@ async fn download_blob( let _ = storage.put(&key_clone, &data_clone).await; }); + state.repo_index.invalidate("docker"); + return ( StatusCode::OK, [(header::CONTENT_TYPE, "application/octet-stream")], @@ -302,6 +304,7 @@ async fn upload_blob( "docker", "LOCAL", )); + state.repo_index.invalidate("docker"); let location = format!("/v2/{}/blobs/{}", name, digest); (StatusCode::CREATED, [(header::LOCATION, location)]).into_response() } @@ -413,6 +416,8 @@ async fn get_manifest( } }); + state.repo_index.invalidate("docker"); + return ( StatusCode::OK, [ @@ -474,6 +479,7 @@ async fn put_manifest( "docker", "LOCAL", )); + state.repo_index.invalidate("docker"); let location = format!("/v2/{}/manifests/{}", name, reference); ( diff --git a/nora-registry/src/registry/maven.rs b/nora-registry/src/registry/maven.rs index 3ed6812..b630017 100644 --- a/nora-registry/src/registry/maven.rs +++ b/nora-registry/src/registry/maven.rs @@ -70,6 +70,8 @@ async fn download(State(state): State>, Path(path): Path) let _ = storage.put(&key_clone, &data_clone).await; }); + state.repo_index.invalidate("maven"); + return with_content_type(&path, data.into()).into_response(); } Err(_) => continue, @@ -106,6 +108,7 @@ async fn upload( "maven", "LOCAL", )); + state.repo_index.invalidate("maven"); StatusCode::CREATED } Err(_) => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/nora-registry/src/registry/npm.rs b/nora-registry/src/registry/npm.rs index c0a7b02..e431027 100644 --- a/nora-registry/src/registry/npm.rs +++ b/nora-registry/src/registry/npm.rs @@ -85,6 +85,11 @@ async fn handle_request(State(state): State>, Path(path): Path for zero-cost reads +//! - Single rebuild at a time per registry (rebuild_lock) + +use crate::storage::Storage; +use crate::ui::components::format_timestamp; +use parking_lot::RwLock; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::Mutex as AsyncMutex; +use tracing::info; + +/// Repository info for UI display +#[derive(Debug, Clone, Serialize)] +pub struct RepoInfo { + pub name: String, + pub versions: usize, + pub size: u64, + pub updated: String, +} + +/// Index for a single registry type +pub struct RegistryIndex { + data: RwLock>>, + dirty: AtomicBool, + rebuild_lock: AsyncMutex<()>, +} + +impl RegistryIndex { + pub fn new() -> Self { + Self { + data: RwLock::new(Arc::new(Vec::new())), + dirty: AtomicBool::new(true), + rebuild_lock: AsyncMutex::new(()), + } + } + + /// Mark index as needing rebuild + pub fn invalidate(&self) { + self.dirty.store(true, Ordering::Release); + } + + fn is_dirty(&self) -> bool { + self.dirty.load(Ordering::Acquire) + } + + fn get_cached(&self) -> Arc> { + Arc::clone(&self.data.read()) + } + + fn set(&self, data: Vec) { + *self.data.write() = Arc::new(data); + self.dirty.store(false, Ordering::Release); + } + + pub fn count(&self) -> usize { + self.data.read().len() + } +} + +impl Default for RegistryIndex { + fn default() -> Self { + Self::new() + } +} + +/// Main repository index for all registries +pub struct RepoIndex { + pub docker: RegistryIndex, + pub maven: RegistryIndex, + pub npm: RegistryIndex, + pub cargo: RegistryIndex, + pub pypi: RegistryIndex, +} + +impl RepoIndex { + pub fn new() -> Self { + Self { + docker: RegistryIndex::new(), + maven: RegistryIndex::new(), + npm: RegistryIndex::new(), + cargo: RegistryIndex::new(), + pypi: RegistryIndex::new(), + } + } + + /// Invalidate a specific registry index + pub fn invalidate(&self, registry: &str) { + match registry { + "docker" => self.docker.invalidate(), + "maven" => self.maven.invalidate(), + "npm" => self.npm.invalidate(), + "cargo" => self.cargo.invalidate(), + "pypi" => self.pypi.invalidate(), + _ => {} + } + } + + /// Get index with double-checked locking (prevents race condition) + pub async fn get(&self, registry: &str, storage: &Storage) -> Arc> { + let index = match registry { + "docker" => &self.docker, + "maven" => &self.maven, + "npm" => &self.npm, + "cargo" => &self.cargo, + "pypi" => &self.pypi, + _ => return Arc::new(Vec::new()), + }; + + // Fast path: not dirty, return cached + if !index.is_dirty() { + return index.get_cached(); + } + + // Slow path: acquire rebuild lock (only one thread rebuilds) + let _guard = index.rebuild_lock.lock().await; + + // Double-check under lock (another thread may have rebuilt) + if index.is_dirty() { + let data = match registry { + "docker" => build_docker_index(storage).await, + "maven" => build_maven_index(storage).await, + "npm" => build_npm_index(storage).await, + "cargo" => build_cargo_index(storage).await, + "pypi" => build_pypi_index(storage).await, + _ => Vec::new(), + }; + info!(registry = registry, count = data.len(), "Index rebuilt"); + index.set(data); + } + + index.get_cached() + } + + /// Get counts for stats (no rebuild, just current state) + pub fn counts(&self) -> (usize, usize, usize, usize, usize) { + ( + self.docker.count(), + self.maven.count(), + self.npm.count(), + self.cargo.count(), + self.pypi.count(), + ) + } +} + +impl Default for RepoIndex { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Index builders +// ============================================================================ + +async fn build_docker_index(storage: &Storage) -> Vec { + let keys = storage.list("docker/").await; + let mut repos: HashMap = HashMap::new(); + + for key in &keys { + if key.ends_with(".meta.json") { + continue; + } + + if let Some(rest) = key.strip_prefix("docker/") { + let parts: Vec<_> = rest.split('/').collect(); + if parts.len() >= 3 && parts[1] == "manifests" && key.ends_with(".json") { + let name = parts[0].to_string(); + let entry = repos.entry(name).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Ok(data) = storage.get(key).await { + if let Ok(m) = serde_json::from_slice::(&data) { + let cfg = m.get("config").and_then(|c| c.get("size")).and_then(|s| s.as_u64()).unwrap_or(0); + let layers: u64 = m.get("layers").and_then(|l| l.as_array()) + .map(|arr| arr.iter().filter_map(|l| l.get("size").and_then(|s| s.as_u64())).sum()) + .unwrap_or(0); + entry.1 += cfg + layers; + } + } + + if let Some(meta) = storage.stat(key).await { + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + + to_sorted_vec(repos) +} + +async fn build_maven_index(storage: &Storage) -> Vec { + let keys = storage.list("maven/").await; + let mut repos: HashMap = HashMap::new(); + + for key in &keys { + if let Some(rest) = key.strip_prefix("maven/") { + let parts: Vec<_> = rest.split('/').collect(); + if parts.len() >= 2 { + let path = parts[..parts.len() - 1].join("/"); + let entry = repos.entry(path).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Some(meta) = storage.stat(key).await { + entry.1 += meta.size; + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + + to_sorted_vec(repos) +} + +async fn build_npm_index(storage: &Storage) -> Vec { + let keys = storage.list("npm/").await; + let mut packages: HashMap = HashMap::new(); + + // Count tarballs instead of parsing metadata.json (faster than parsing JSON) + for key in &keys { + if let Some(rest) = key.strip_prefix("npm/") { + // Pattern: npm/{package}/tarballs/{file}.tgz + if rest.contains("/tarballs/") && key.ends_with(".tgz") { + let parts: Vec<_> = rest.split('/').collect(); + if !parts.is_empty() { + let name = parts[0].to_string(); + let entry = packages.entry(name).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Some(meta) = storage.stat(key).await { + entry.1 += meta.size; + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + } + + to_sorted_vec(packages) +} + +async fn build_cargo_index(storage: &Storage) -> Vec { + let keys = storage.list("cargo/").await; + let mut crates: HashMap = HashMap::new(); + + for key in &keys { + if key.ends_with(".crate") { + if let Some(rest) = key.strip_prefix("cargo/") { + let parts: Vec<_> = rest.split('/').collect(); + if !parts.is_empty() { + let name = parts[0].to_string(); + let entry = crates.entry(name).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Some(meta) = storage.stat(key).await { + entry.1 += meta.size; + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + } + + to_sorted_vec(crates) +} + +async fn build_pypi_index(storage: &Storage) -> Vec { + let keys = storage.list("pypi/").await; + let mut packages: HashMap = HashMap::new(); + + for key in &keys { + if let Some(rest) = key.strip_prefix("pypi/") { + let parts: Vec<_> = rest.split('/').collect(); + if parts.len() >= 2 { + let name = parts[0].to_string(); + let entry = packages.entry(name).or_insert((0, 0, 0)); + entry.0 += 1; + + if let Some(meta) = storage.stat(key).await { + entry.1 += meta.size; + if meta.modified > entry.2 { + entry.2 = meta.modified; + } + } + } + } + } + + to_sorted_vec(packages) +} + +/// Convert HashMap to sorted Vec +fn to_sorted_vec(map: HashMap) -> Vec { + let mut result: Vec<_> = map + .into_iter() + .map(|(name, (versions, size, modified))| RepoInfo { + name, + versions, + size, + updated: if modified > 0 { + format_timestamp(modified) + } else { + "N/A".to_string() + }, + }) + .collect(); + + result.sort_by(|a, b| a.name.cmp(&b.name)); + result +} + +/// Pagination helper +pub fn paginate(data: &[T], page: usize, limit: usize) -> (Vec, usize) { + let total = data.len(); + let start = page.saturating_sub(1) * limit; + + if start >= total { + return (Vec::new(), total); + } + + let end = (start + limit).min(total); + (data[start..end].to_vec(), total) +} diff --git a/nora-registry/src/ui/api.rs b/nora-registry/src/ui/api.rs index 79cb4fe..b8590c2 100644 --- a/nora-registry/src/ui/api.rs +++ b/nora-registry/src/ui/api.rs @@ -4,6 +4,7 @@ use super::components::{format_size, format_timestamp, html_escape}; use super::templates::encode_uri_component; use crate::activity_log::ActivityEntry; +use crate::repo_index::RepoInfo; use crate::AppState; use crate::Storage; use axum::{ @@ -24,14 +25,6 @@ pub struct RegistryStats { pub pypi: usize, } -#[derive(Serialize, Clone)] -pub struct RepoInfo { - pub name: String, - pub versions: usize, - pub size: u64, - pub updated: String, -} - #[derive(Serialize)] pub struct TagInfo { pub name: String, @@ -115,44 +108,35 @@ pub struct MountPoint { // ============ API Handlers ============ pub async fn api_stats(State(state): State>) -> Json { - let stats = get_registry_stats(&state.storage).await; - Json(stats) + // Trigger index rebuild if needed, then get counts + let _ = state.repo_index.get("docker", &state.storage).await; + let _ = state.repo_index.get("maven", &state.storage).await; + let _ = state.repo_index.get("npm", &state.storage).await; + let _ = state.repo_index.get("cargo", &state.storage).await; + let _ = state.repo_index.get("pypi", &state.storage).await; + + let (docker, maven, npm, cargo, pypi) = state.repo_index.counts(); + Json(RegistryStats { docker, maven, npm, cargo, pypi }) } pub async fn api_dashboard(State(state): State>) -> Json { - let registry_stats = get_registry_stats(&state.storage).await; + // Get indexes (will rebuild if dirty) + let docker_repos = state.repo_index.get("docker", &state.storage).await; + let maven_repos = state.repo_index.get("maven", &state.storage).await; + let npm_repos = state.repo_index.get("npm", &state.storage).await; + let cargo_repos = state.repo_index.get("cargo", &state.storage).await; + let pypi_repos = state.repo_index.get("pypi", &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; + // Calculate sizes from cached index + let docker_size: u64 = docker_repos.iter().map(|r| r.size).sum(); + let maven_size: u64 = maven_repos.iter().map(|r| r.size).sum(); + let npm_size: u64 = npm_repos.iter().map(|r| r.size).sum(); + let cargo_size: u64 = cargo_repos.iter().map(|r| r.size).sum(); + let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum(); + let total_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size; - 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 total_artifacts = docker_repos.len() + maven_repos.len() + npm_repos.len() + + cargo_repos.len() + pypi_repos.len(); let global_stats = GlobalStats { downloads: state.metrics.downloads.load(Ordering::Relaxed), @@ -165,35 +149,35 @@ pub async fn api_dashboard(State(state): State>) -> Json>, Path(registry_type): Path, ) -> Json> { - let repos = match registry_type.as_str() { - "docker" => get_docker_repos(&state.storage).await, - "maven" => get_maven_repos(&state.storage).await, - "npm" => get_npm_packages(&state.storage).await, - "cargo" => get_cargo_crates(&state.storage).await, - "pypi" => get_pypi_packages(&state.storage).await, - _ => vec![], - }; - Json(repos) + let repos = state.repo_index.get(®istry_type, &state.storage).await; + Json((*repos).clone()) } pub async fn api_detail( @@ -283,20 +260,13 @@ pub async fn api_search( ) -> axum::response::Html { let query = params.q.unwrap_or_default().to_lowercase(); - let repos = match registry_type.as_str() { - "docker" => get_docker_repos(&state.storage).await, - "maven" => get_maven_repos(&state.storage).await, - "npm" => get_npm_packages(&state.storage).await, - "cargo" => get_cargo_crates(&state.storage).await, - "pypi" => get_pypi_packages(&state.storage).await, - _ => vec![], - }; + let repos = state.repo_index.get(®istry_type, &state.storage).await; - let filtered: Vec<_> = if query.is_empty() { - repos + let filtered: Vec<&RepoInfo> = if query.is_empty() { + repos.iter().collect() } else { repos - .into_iter() + .iter() .filter(|r| r.name.to_lowercase().contains(&query)) .collect() }; @@ -341,7 +311,9 @@ pub async fn api_search( } // ============ Data Fetching Functions ============ +// NOTE: Legacy functions below - kept for reference, will be removed in future cleanup +#[allow(dead_code)] pub async fn get_registry_stats(storage: &Storage) -> RegistryStats { let all_keys = storage.list("").await; @@ -393,6 +365,7 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats { } } +#[allow(dead_code)] pub async fn get_docker_repos(storage: &Storage) -> Vec { let keys = storage.list("docker/").await; @@ -571,6 +544,7 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail { DockerDetail { tags } } +#[allow(dead_code)] pub async fn get_maven_repos(storage: &Storage) -> Vec { let keys = storage.list("maven/").await; @@ -630,6 +604,7 @@ pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail { MavenDetail { artifacts } } +#[allow(dead_code)] pub async fn get_npm_packages(storage: &Storage) -> Vec { let keys = storage.list("npm/").await; @@ -747,6 +722,7 @@ pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail { PackageDetail { versions } } +#[allow(dead_code)] pub async fn get_cargo_crates(storage: &Storage) -> Vec { let keys = storage.list("cargo/").await; @@ -814,6 +790,7 @@ pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail { PackageDetail { versions } } +#[allow(dead_code)] pub async fn get_pypi_packages(storage: &Storage) -> Vec { let keys = storage.list("pypi/").await; diff --git a/nora-registry/src/ui/mod.rs b/nora-registry/src/ui/mod.rs index f691f35..e5b08de 100644 --- a/nora-registry/src/ui/mod.rs +++ b/nora-registry/src/ui/mod.rs @@ -2,11 +2,12 @@ // SPDX-License-Identifier: MIT mod api; -mod components; +pub mod components; pub mod i18n; mod logo; mod templates; +use crate::repo_index::paginate; use crate::AppState; use axum::{ extract::{Path, Query, State}, @@ -25,6 +26,15 @@ struct LangQuery { lang: Option, } +#[derive(Debug, serde::Deserialize)] +struct ListQuery { + lang: Option, + page: Option, + limit: Option, +} + +const DEFAULT_PAGE_SIZE: usize = 50; + fn extract_lang(query: &Query, cookie_header: Option<&str>) -> Lang { // Priority: query param > cookie > default if let Some(ref lang) = query.lang { @@ -44,6 +54,23 @@ fn extract_lang(query: &Query, cookie_header: Option<&str>) -> Lang { Lang::default() } +fn extract_lang_from_list(query: &ListQuery, cookie_header: Option<&str>) -> Lang { + if let Some(ref lang) = query.lang { + return Lang::from_str(lang); + } + + if let Some(cookies) = cookie_header { + for part in cookies.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("nora_lang=") { + return Lang::from_str(value); + } + } + } + + Lang::default() +} + pub fn routes() -> Router> { Router::new() // UI Pages @@ -85,18 +112,23 @@ async fn dashboard( // Docker pages async fn docker_list( State(state): State>, - Query(query): Query, + Query(query): Query, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let lang = extract_lang( - &Query(query), - headers.get("cookie").and_then(|v| v.to_str().ok()), - ); - let repos = get_docker_repos(&state.storage).await; - Html(render_registry_list( + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_repos = state.repo_index.get("docker", &state.storage).await; + let (repos, total) = paginate(&all_repos, page, limit); + + Html(render_registry_list_paginated( "docker", "Docker Registry", &repos, + page, + limit, + total, lang, )) } @@ -118,18 +150,23 @@ async fn docker_detail( // Maven pages async fn maven_list( State(state): State>, - Query(query): Query, + Query(query): Query, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let lang = extract_lang( - &Query(query), - headers.get("cookie").and_then(|v| v.to_str().ok()), - ); - let repos = get_maven_repos(&state.storage).await; - Html(render_registry_list( + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_repos = state.repo_index.get("maven", &state.storage).await; + let (repos, total) = paginate(&all_repos, page, limit); + + Html(render_registry_list_paginated( "maven", "Maven Repository", &repos, + page, + limit, + total, lang, )) } @@ -151,15 +188,25 @@ async fn maven_detail( // npm pages async fn npm_list( State(state): State>, - Query(query): Query, + Query(query): Query, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let lang = extract_lang( - &Query(query), - headers.get("cookie").and_then(|v| v.to_str().ok()), - ); - let packages = get_npm_packages(&state.storage).await; - Html(render_registry_list("npm", "npm Registry", &packages, lang)) + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_packages = state.repo_index.get("npm", &state.storage).await; + let (packages, total) = paginate(&all_packages, page, limit); + + Html(render_registry_list_paginated( + "npm", + "npm Registry", + &packages, + page, + limit, + total, + lang, + )) } async fn npm_detail( @@ -179,18 +226,23 @@ async fn npm_detail( // Cargo pages async fn cargo_list( State(state): State>, - Query(query): Query, + Query(query): Query, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let lang = extract_lang( - &Query(query), - headers.get("cookie").and_then(|v| v.to_str().ok()), - ); - let crates = get_cargo_crates(&state.storage).await; - Html(render_registry_list( + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_crates = state.repo_index.get("cargo", &state.storage).await; + let (crates, total) = paginate(&all_crates, page, limit); + + Html(render_registry_list_paginated( "cargo", "Cargo Registry", &crates, + page, + limit, + total, lang, )) } @@ -212,18 +264,23 @@ async fn cargo_detail( // PyPI pages async fn pypi_list( State(state): State>, - Query(query): Query, + Query(query): Query, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - let lang = extract_lang( - &Query(query), - headers.get("cookie").and_then(|v| v.to_str().ok()), - ); - let packages = get_pypi_packages(&state.storage).await; - Html(render_registry_list( + let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok())); + let page = query.page.unwrap_or(1).max(1); + let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100); + + let all_packages = state.repo_index.get("pypi", &state.storage).await; + let (packages, total) = paginate(&all_packages, page, limit); + + Html(render_registry_list_paginated( "pypi", "PyPI Repository", &packages, + page, + limit, + total, lang, )) } diff --git a/nora-registry/src/ui/templates.rs b/nora-registry/src/ui/templates.rs index 5188338..5e95f1f 100644 --- a/nora-registry/src/ui/templates.rs +++ b/nora-registry/src/ui/templates.rs @@ -1,7 +1,8 @@ // Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT -use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo}; +use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail}; +use crate::repo_index::RepoInfo; use super::components::*; use super::i18n::{get_translations, Lang}; @@ -166,6 +167,7 @@ fn format_relative_time(timestamp: &chrono::DateTime) -> String { } /// Renders a registry list page (docker, maven, npm, cargo, pypi) +#[allow(dead_code)] pub fn render_registry_list( registry_type: &str, title: &str, @@ -276,6 +278,215 @@ pub fn render_registry_list( layout_dark(title, &content, Some(registry_type), "", lang) } +/// Renders a registry list page with pagination +pub fn render_registry_list_paginated( + registry_type: &str, + title: &str, + repos: &[RepoInfo], + page: usize, + limit: usize, + total: usize, + lang: Lang, +) -> String { + let t = get_translations(lang); + let icon = get_registry_icon(registry_type); + + let table_rows = if repos.is_empty() && page == 1 { + format!( + r##" +
📭
+
{}
+
{}
+ "##, + t.no_repos_found, t.push_first_artifact + ) + } else if repos.is_empty() { + r##" +
📭
+
No more items on this page
+ "##.to_string() + } else { + repos + .iter() + .map(|repo| { + let detail_url = + format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name)); + format!( + r##" + + + {} + + {} + {} + {} + + "##, + detail_url, + detail_url, + html_escape(&repo.name), + repo.versions, + format_size(repo.size), + &repo.updated + ) + }) + .collect::>() + .join("") + }; + + let version_label = match registry_type { + "docker" => t.tags, + _ => t.versions, + }; + + // Pagination + let total_pages = (total + limit - 1) / limit; + let start_item = if total == 0 { 0 } else { (page - 1) * limit + 1 }; + let end_item = (start_item + repos.len()).saturating_sub(1); + + let pagination = if total_pages > 1 { + let mut pages_html = String::new(); + + // Previous button + if page > 1 { + pages_html.push_str(&format!( + r##""##, + registry_type, page - 1, limit + )); + } else { + pages_html.push_str(r##""##); + } + + // Page numbers (show max 7 pages around current) + let start_page = if page <= 4 { 1 } else { page - 3 }; + let end_page = (start_page + 6).min(total_pages); + + if start_page > 1 { + pages_html.push_str(&format!( + r##"1"##, + registry_type, limit + )); + if start_page > 2 { + pages_html.push_str(r##"..."##); + } + } + + for p in start_page..=end_page { + if p == page { + pages_html.push_str(&format!( + r##"{}"##, + p + )); + } else { + pages_html.push_str(&format!( + r##"{}"##, + registry_type, p, limit, p + )); + } + } + + if end_page < total_pages { + if end_page < total_pages - 1 { + pages_html.push_str(r##"..."##); + } + pages_html.push_str(&format!( + r##"{}"##, + registry_type, total_pages, limit, total_pages + )); + } + + // Next button + if page < total_pages { + pages_html.push_str(&format!( + r##""##, + registry_type, page + 1, limit + )); + } else { + pages_html.push_str(r##""##); + } + + format!( + r##" +
+
+ Showing {}-{} of {} items +
+
+ {} +
+
+ "##, + start_item, end_item, total, pages_html + ) + } else if total > 0 { + format!( + r##"
Showing all {} items
"##, + total + ) + } else { + String::new() + }; + + let content = format!( + r##" +
+
+ {} +
+

{}

+

{} {}

+
+
+
+
+ + + + +
+
+
+ +
+ + + + + + + + + + + {} + +
{}{}{}{}
+
+ {} + "##, + icon, + title, + total, + t.repositories, + t.search_placeholder, + registry_type, + t.name, + version_label, + t.size, + t.updated, + table_rows, + pagination + ); + + layout_dark(title, &content, Some(registry_type), "", lang) +} + /// Renders Docker image detail page pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String { let _t = get_translations(lang);