// Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail}; use super::components::*; use super::i18n::{get_translations, Lang}; use crate::repo_index::RepoInfo; /// Renders the main dashboard page with dark theme pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String { let t = get_translations(lang); // 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, lang, ); // Render registry cards let registry_cards: String = data .registry_stats .iter() .map(|r| { let icon = get_registry_icon(&r.name); let display_name = get_registry_title(&r.name); render_registry_card( display_name, icon, r.artifact_count, r.downloads, r.uploads, r.size_bytes, &format!("/ui/{}", r.name), t, ) }) .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, t); // Render activity log let activity_rows: String = if data.activity.is_empty() { format!( r##"{}"##, t.no_activity ) } else { // Group consecutive identical entries (same action+artifact+registry+source) struct GroupedActivity { time: String, action: String, artifact: String, registry: String, source: String, count: usize, } let mut grouped: Vec = Vec::new(); for entry in &data.activity { let action = entry.action.to_string(); let is_repeat = grouped.last().is_some_and(|last| { last.action == action && last.artifact == entry.artifact && last.registry == entry.registry && last.source == entry.source }); if is_repeat { if let Some(last) = grouped.last_mut() { last.count += 1; } } else { grouped.push(GroupedActivity { time: format_relative_time(&entry.timestamp), action, artifact: entry.artifact.clone(), registry: entry.registry.clone(), source: entry.source.clone(), count: 1, }); } } grouped .iter() .map(|g| { let display_artifact = if g.count > 1 { format!("{} (x{})", g.artifact, g.count) } else { g.artifact.clone() }; render_activity_row( &g.time, &g.action, &display_artifact, &g.registry, &g.source, ) }) .collect() }; let activity_log = render_activity_log(&activity_rows, t); // Format uptime let hours = data.uptime_seconds / 3600; let mins = (data.uptime_seconds % 3600) / 60; let uptime_str = format!("{}h {}m", hours, mins); // Render bragging footer let bragging_footer = render_bragging_footer(lang); let content = format!( r##"

{}

{}

{}
{}
{}
{}
{} {}
{} "##, t.dashboard_title, t.dashboard_subtitle, t.uptime, uptime_str, global_stats, registry_cards, mount_points, activity_log, bragging_footer, ); let polling_script = render_polling_script(); layout_dark( t.dashboard_title, &content, Some("dashboard"), &polling_script, lang, ) } /// 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) #[allow(dead_code)] pub fn render_registry_list( registry_type: &str, title: &str, repos: &[RepoInfo], lang: Lang, ) -> String { let t = get_translations(lang); let icon = get_registry_icon(registry_type); let table_rows = if repos.is_empty() { format!( r##"
📭
{}
{}
"##, t.no_repos_found, t.push_first_artifact ) } 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, }; let content = format!( r##"
{}

{}

{} {}

{}
{} {} {} {}
"##, icon, title, repos.len(), t.repositories, t.search_placeholder, registry_type, t.name, version_label, t.size, t.updated, table_rows ); 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.div_ceil(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); let tags_rows = if detail.tags.is_empty() { r##"No tags found"##.to_string() } else { detail .tags .iter() .map(|tag| { format!( r##" {} {} {} "##, html_escape(&tag.name), format_size(tag.size), &tag.created ) }) .collect::>() .join("") }; let pull_cmd = format!("docker pull 127.0.0.1:4000/{}", name); let content = format!( r##"
{}

{}

Pull Command

{}

Tags ({} total)

{}
Tag Size Created
"##, html_escape(name), icons::DOCKER, html_escape(name), pull_cmd, pull_cmd, detail.tags.len(), tags_rows ); layout_dark( &format!("{} - Docker", name), &content, Some("docker"), "", lang, ) } /// Renders package detail page (npm, cargo, pypi) pub fn render_package_detail( registry_type: &str, name: &str, detail: &PackageDetail, lang: Lang, ) -> String { let _t = get_translations(lang); let icon = get_registry_icon(registry_type); let registry_title = get_registry_title(registry_type); let versions_rows = if detail.versions.is_empty() { r##"No versions found"##.to_string() } else { detail .versions .iter() .map(|v| { format!( r##" {} {} {} "##, html_escape(&v.version), format_size(v.size), &v.published ) }) .collect::>() .join("") }; let install_cmd = match registry_type { "npm" => format!("npm install {} --registry http://127.0.0.1:4000/npm", name), "cargo" => format!("cargo add {}", name), "pypi" => format!( "pip install {} --index-url http://127.0.0.1:4000/simple", name ), "go" => format!("GOPROXY=http://127.0.0.1:4000/go go get {}", name), "raw" => format!("curl -O http://127.0.0.1:4000/raw/{}/", name), _ => String::new(), }; let content = format!( r##"
{} / {}
{}

{}

Install Command

{}

Versions ({} total)

{}
Version Size Published
"##, registry_type, registry_title, html_escape(name), icon, html_escape(name), install_cmd, install_cmd, detail.versions.len(), versions_rows ); layout_dark( &format!("{} - {}", name, registry_title), &content, Some(registry_type), "", lang, ) } /// Renders Maven artifact detail page pub fn render_maven_detail(path: &str, detail: &MavenDetail, lang: Lang) -> String { let _t = get_translations(lang); let artifact_rows = if detail.artifacts.is_empty() { r##"No artifacts found"##.to_string() } else { detail.artifacts.iter().map(|a| { let download_url = format!("/maven2/{}/{}", path, a.filename); format!(r##" {} {} "##, download_url, html_escape(&a.filename), format_size(a.size)) }).collect::>().join("") }; // Extract artifact name from path (last component before version) let parts: Vec<&str> = path.split('/').collect(); let artifact_name = if parts.len() >= 2 { parts[parts.len() - 2] } else { path }; let dep_cmd = format!( r#" {} {} {} "#, parts[..parts.len().saturating_sub(2)].join("."), artifact_name, parts.last().unwrap_or(&"") ); let content = format!( r##"
{}

{}

Maven Dependency

{}

Artifacts ({} files)

{}
Filename Size
"##, html_escape(path), icons::MAVEN, html_escape(path), html_escape(&dep_cmd), detail.artifacts.len(), artifact_rows ); layout_dark( &format!("{} - Maven", path), &content, Some("maven"), "", lang, ) } /// Returns SVG icon path for the registry type fn get_registry_icon(registry_type: &str) -> &'static str { match registry_type { "docker" => icons::DOCKER, "maven" => icons::MAVEN, "npm" => icons::NPM, "cargo" => icons::CARGO, "pypi" => icons::PYPI, "go" => icons::GO, "raw" => icons::RAW, _ => { r#""# } } } fn get_registry_title(registry_type: &str) -> &'static str { match registry_type { "docker" => "Docker Registry", "maven" => "Maven Repository", "npm" => "npm Registry", "cargo" => "Cargo Registry", "pypi" => "PyPI Repository", "go" => "Go Modules", "raw" => "Raw Storage", _ => "Registry", } } /// Simple URL encoding for path components pub fn encode_uri_component(s: &str) -> String { let mut result = String::new(); for c in s.chars() { match c { 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), _ => { for byte in c.to_string().as_bytes() { result.push_str(&format!("%{:02X}", byte)); } } } } result }