feat: initialize NORA artifact registry

Cloud-native multi-protocol artifact registry in Rust.

- Docker Registry v2
- Maven (+ proxy)
- npm (+ proxy)
- Cargo, PyPI
- Web UI, Swagger, Prometheus
- Local & S3 storage
- 32MB Docker image

Created by DevITWay
https://getnora.io
This commit is contained in:
2026-01-25 17:03:18 +00:00
commit 586420a476
36 changed files with 7613 additions and 0 deletions

580
nora-registry/src/ui/api.rs Normal file
View File

@@ -0,0 +1,580 @@
use super::components::{format_size, format_timestamp, html_escape};
use super::templates::encode_uri_component;
use crate::AppState;
use crate::Storage;
use axum::{
extract::{Path, Query, State},
response::Json,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[derive(Serialize)]
pub struct RegistryStats {
pub docker: usize,
pub maven: usize,
pub npm: usize,
pub cargo: usize,
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,
pub size: u64,
pub created: String,
}
#[derive(Serialize)]
pub struct DockerDetail {
pub tags: Vec<TagInfo>,
}
#[derive(Serialize)]
pub struct VersionInfo {
pub version: String,
pub size: u64,
pub published: String,
}
#[derive(Serialize)]
pub struct PackageDetail {
pub versions: Vec<VersionInfo>,
}
#[derive(Serialize)]
pub struct MavenArtifact {
pub filename: String,
pub size: u64,
}
#[derive(Serialize)]
pub struct MavenDetail {
pub artifacts: Vec<MavenArtifact>,
}
#[derive(Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
// ============ API Handlers ============
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
let stats = get_registry_stats(&state.storage).await;
Json(stats)
}
pub async fn api_list(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
) -> Json<Vec<RepoInfo>> {
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)
}
pub async fn api_detail(
State(state): State<Arc<AppState>>,
Path((registry_type, name)): Path<(String, String)>,
) -> Json<serde_json::Value> {
match registry_type.as_str() {
"docker" => {
let detail = get_docker_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
"npm" => {
let detail = get_npm_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
"cargo" => {
let detail = get_cargo_detail(&state.storage, &name).await;
Json(serde_json::to_value(detail).unwrap_or_default())
}
_ => Json(serde_json::json!({})),
}
}
pub async fn api_search(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
Query(params): Query<SearchQuery>,
) -> axum::response::Html<String> {
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 filtered: Vec<_> = if query.is_empty() {
repos
} else {
repos
.into_iter()
.filter(|r| r.name.to_lowercase().contains(&query))
.collect()
};
// Return HTML fragment for HTMX
let html = if filtered.is_empty() {
r#"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">🔍</div>
<div>No matching repositories found</div>
</td></tr>"#
.to_string()
} else {
filtered
.iter()
.map(|repo| {
let detail_url =
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
format!(
r#"
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"#,
detail_url,
detail_url,
html_escape(&repo.name),
repo.versions,
format_size(repo.size),
&repo.updated
)
})
.collect::<Vec<_>>()
.join("")
};
axum::response::Html(html)
}
// ============ Data Fetching Functions ============
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
let all_keys = storage.list("").await;
let docker = all_keys
.iter()
.filter(|k| k.starts_with("docker/") && k.contains("/manifests/"))
.filter_map(|k| k.split('/').nth(1))
.collect::<HashSet<_>>()
.len();
let maven = all_keys
.iter()
.filter(|k| k.starts_with("maven/"))
.filter_map(|k| {
// Extract groupId/artifactId from maven path
let parts: Vec<_> = k.strip_prefix("maven/")?.split('/').collect();
if parts.len() >= 2 {
Some(parts[..parts.len() - 1].join("/"))
} else {
None
}
})
.collect::<HashSet<_>>()
.len();
let npm = all_keys
.iter()
.filter(|k| k.starts_with("npm/") && k.ends_with("/metadata.json"))
.count();
let cargo = all_keys
.iter()
.filter(|k| k.starts_with("cargo/") && k.ends_with("/metadata.json"))
.count();
let pypi = all_keys
.iter()
.filter(|k| k.starts_with("pypi/"))
.filter_map(|k| k.strip_prefix("pypi/")?.split('/').next())
.collect::<HashSet<_>>()
.len();
RegistryStats {
docker,
maven,
npm,
cargo,
pypi,
}
}
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("docker/").await;
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new(); // (info, latest_modified)
for key in &keys {
if let Some(rest) = key.strip_prefix("docker/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 {
let name = parts[0].to_string();
let entry = repos.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts[1] == "manifests" {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = repos.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_docker_detail(storage: &Storage, name: &str) -> DockerDetail {
let prefix = format!("docker/{}/manifests/", name);
let keys = storage.list(&prefix).await;
let mut tags = Vec::new();
for key in &keys {
if let Some(tag_name) = key
.strip_prefix(&prefix)
.and_then(|s| s.strip_suffix(".json"))
{
let (size, created) = if let Some(meta) = storage.stat(key).await {
(meta.size, format_timestamp(meta.modified))
} else {
(0, "N/A".to_string())
};
tags.push(TagInfo {
name: tag_name.to_string(),
size,
created,
});
}
}
DockerDetail { tags }
}
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
let mut repos: HashMap<String, (RepoInfo, u64)> = 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 artifact_path = parts[..parts.len() - 1].join("/");
let entry = repos.entry(artifact_path.clone()).or_insert_with(|| {
(
RepoInfo {
name: artifact_path,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
let mut result: Vec<_> = repos.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
let prefix = format!("maven/{}/", path);
let keys = storage.list(&prefix).await;
let mut artifacts = Vec::new();
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if filename.contains('/') {
continue;
}
let size = storage.stat(key).await.map(|m| m.size).unwrap_or(0);
artifacts.push(MavenArtifact {
filename: filename.to_string(),
size,
});
}
}
MavenDetail { artifacts }
}
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("npm/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 3 && parts[1] == "tarballs" {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("npm/{}/tarballs/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in &keys {
if let Some(tarball) = key.strip_prefix(&prefix) {
if let Some(version) = tarball
.strip_prefix(&format!("{}-", name))
.and_then(|s| s.strip_suffix(".tgz"))
{
let (size, published) = if let Some(meta) = storage.stat(key).await {
(meta.size, format_timestamp(meta.modified))
} else {
(0, "N/A".to_string())
};
versions.push(VersionInfo {
version: version.to_string(),
size,
published,
});
}
}
}
PackageDetail { versions }
}
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
let mut crates: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
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.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 3 && key.ends_with(".crate") {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = crates.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("cargo/{}/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in keys.iter().filter(|k| k.ends_with(".crate")) {
if let Some(rest) = key.strip_prefix(&prefix) {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let (size, published) = if let Some(meta) = storage.stat(key).await {
(meta.size, format_timestamp(meta.modified))
} else {
(0, "N/A".to_string())
};
versions.push(VersionInfo {
version: parts[0].to_string(),
size,
published,
});
}
}
}
PackageDetail { versions }
}
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("pypi/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name.clone()).or_insert_with(|| {
(
RepoInfo {
name,
versions: 0,
size: 0,
updated: "N/A".to_string(),
},
0,
)
});
if parts.len() >= 2 {
entry.0.versions += 1;
if let Some(meta) = storage.stat(key).await {
entry.0.size += meta.size;
if meta.modified > entry.1 {
entry.1 = meta.modified;
entry.0.updated = format_timestamp(meta.modified);
}
}
}
}
}
}
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
pub async fn get_pypi_detail(storage: &Storage, name: &str) -> PackageDetail {
let prefix = format!("pypi/{}/", name);
let keys = storage.list(&prefix).await;
let mut versions = Vec::new();
for key in &keys {
if let Some(filename) = key.strip_prefix(&prefix) {
if let Some(version) = extract_pypi_version(name, filename) {
let (size, published) = if let Some(meta) = storage.stat(key).await {
(meta.size, format_timestamp(meta.modified))
} else {
(0, "N/A".to_string())
};
versions.push(VersionInfo {
version,
size,
published,
});
}
}
}
PackageDetail { versions }
}
fn extract_pypi_version(name: &str, filename: &str) -> Option<String> {
// Handle both .tar.gz and .whl files
let clean_name = name.replace('-', "_");
if filename.ends_with(".tar.gz") {
// package-1.0.0.tar.gz
let base = filename.strip_suffix(".tar.gz")?;
let version = base
.strip_prefix(&format!("{}-", name))
.or_else(|| base.strip_prefix(&format!("{}-", clean_name)))?;
Some(version.to_string())
} else if filename.ends_with(".whl") {
// package-1.0.0-py3-none-any.whl
let parts: Vec<_> = filename.split('-').collect();
if parts.len() >= 2 {
Some(parts[1].to_string())
} else {
None
}
} else {
None
}
}

View File

@@ -0,0 +1,222 @@
/// Main layout wrapper with header and sidebar
pub fn layout(title: &str, content: &str, active_page: Option<&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; }}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
{}
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Header -->
{}
<!-- Content -->
<main class="flex-1 overflow-y-auto p-6">
{}
</main>
</div>
</div>
</body>
</html>"##,
html_escape(title),
sidebar(active_page),
header(),
content
)
}
/// Sidebar navigation component
fn sidebar(active_page: Option<&str>) -> String {
let active = active_page.unwrap_or("");
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"/>"#,
),
("docker", "/ui/docker", "🐳 Docker", ""),
("maven", "/ui/maven", "☕ Maven", ""),
("npm", "/ui/npm", "📦 npm", ""),
("cargo", "/ui/cargo", "🦀 Cargo", ""),
("pypi", "/ui/pypi", "🐍 PyPI", ""),
];
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path)| {
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"
};
if icon_path.is_empty() {
// Emoji-based item
format!(r#"
<a href="{}" class="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors {}">
<span class="mr-3 text-lg">{}</span>
</a>
"#, href, active_class, label)
} else {
// SVG icon item
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="none" stroke="currentColor" viewBox="0 0 24 24">
{}
</svg>
{}
</a>
"##, href, active_class, icon_path, label)
}
}).collect();
format!(
r#"
<div class="w-64 bg-slate-800 text-white flex flex-col">
<!-- Logo -->
<div class="h-16 flex items-center px-6 border-b border-slate-700">
<span class="text-2xl mr-2">⚓</span>
<span class="text-xl font-bold">Nora</span>
</div>
<!-- Navigation -->
<nav class="flex-1 px-4 py-6 space-y-1">
<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>
<!-- Footer -->
<div class="px-4 py-4 border-t border-slate-700">
<div class="text-xs text-slate-400">
Nora v0.1.0
</div>
</div>
</div>
"#,
nav_html
)
}
/// Header component
fn header() -> String {
r##"
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
<div class="flex-1">
<!-- Search removed for simplicity, HTMX search is on list pages -->
</div>
<div class="flex items-center space-x-4">
<a href="https://github.com" target="_blank" class="text-slate-500 hover:text-slate-700">
<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>
<button class="text-slate-500 hover:text-slate-700">
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</header>
"##.to_string()
}
/// Stat card for dashboard
pub fn stat_card(name: &str, icon: &str, count: usize, href: &str, unit: &str) -> String {
format!(
r##"
<a href="{}" class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 hover:shadow-md hover:border-blue-300 transition-all">
<div class="flex items-center justify-between mb-4">
<span class="text-3xl">{}</span>
<span class="text-xs font-medium text-green-600 bg-green-100 px-2 py-1 rounded-full">ACTIVE</span>
</div>
<div class="text-lg font-semibold text-slate-800 mb-1">{}</div>
<div class="text-2xl font-bold text-slate-800">{}</div>
<div class="text-sm text-slate-500">{}</div>
</a>
"##,
href, icon, name, count, unit
)
}
/// Format file size in human-readable format
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
/// Escape HTML special characters
pub fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Format Unix timestamp as relative time
pub fn format_timestamp(ts: u64) -> String {
if ts == 0 {
return "N/A".to_string();
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now < ts {
return "just now".to_string();
}
let diff = now - ts;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
} else if diff < 86400 {
let hours = diff / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if diff < 604800 {
let days = diff / 86400;
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else if diff < 2592000 {
let weeks = diff / 604800;
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
} else {
let months = diff / 2592000;
format!("{} month{} ago", months, if months == 1 { "" } else { "s" })
}
}

114
nora-registry/src/ui/mod.rs Normal file
View File

@@ -0,0 +1,114 @@
mod api;
mod components;
mod templates;
use crate::AppState;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
routing::get,
Router,
};
use std::sync::Arc;
use api::*;
use templates::*;
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
// UI Pages
.route("/", get(|| async { Redirect::to("/ui/") }))
.route("/ui", get(|| async { Redirect::to("/ui/") }))
.route("/ui/", get(dashboard))
.route("/ui/docker", get(docker_list))
.route("/ui/docker/{name}", get(docker_detail))
.route("/ui/maven", get(maven_list))
.route("/ui/maven/{*path}", get(maven_detail))
.route("/ui/npm", get(npm_list))
.route("/ui/npm/{name}", get(npm_detail))
.route("/ui/cargo", get(cargo_list))
.route("/ui/cargo/{name}", get(cargo_detail))
.route("/ui/pypi", get(pypi_list))
.route("/ui/pypi/{name}", get(pypi_detail))
// API endpoints for HTMX
.route("/api/ui/stats", get(api_stats))
.route("/api/ui/{registry_type}/list", get(api_list))
.route("/api/ui/{registry_type}/{name}", get(api_detail))
.route("/api/ui/{registry_type}/search", get(api_search))
}
// Dashboard page
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let stats = get_registry_stats(&state.storage).await;
Html(render_dashboard(&stats))
}
// Docker pages
async fn docker_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let repos = get_docker_repos(&state.storage).await;
Html(render_registry_list("docker", "Docker Registry", &repos))
}
async fn docker_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_docker_detail(&state.storage, &name).await;
Html(render_docker_detail(&name, &detail))
}
// Maven pages
async fn maven_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let repos = get_maven_repos(&state.storage).await;
Html(render_registry_list("maven", "Maven Repository", &repos))
}
async fn maven_detail(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
) -> impl IntoResponse {
let detail = get_maven_detail(&state.storage, &path).await;
Html(render_maven_detail(&path, &detail))
}
// npm pages
async fn npm_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let packages = get_npm_packages(&state.storage).await;
Html(render_registry_list("npm", "npm Registry", &packages))
}
async fn npm_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_npm_detail(&state.storage, &name).await;
Html(render_package_detail("npm", &name, &detail))
}
// Cargo pages
async fn cargo_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let crates = get_cargo_crates(&state.storage).await;
Html(render_registry_list("cargo", "Cargo Registry", &crates))
}
async fn cargo_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_cargo_detail(&state.storage, &name).await;
Html(render_package_detail("cargo", &name, &detail))
}
// PyPI pages
async fn pypi_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let packages = get_pypi_packages(&state.storage).await;
Html(render_registry_list("pypi", "PyPI Repository", &packages))
}
async fn pypi_detail(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> impl IntoResponse {
let detail = get_pypi_detail(&state.storage, &name).await;
Html(render_package_detail("pypi", &name, &detail))
}

View File

@@ -0,0 +1,478 @@
use super::api::{DockerDetail, MavenDetail, PackageDetail, RegistryStats, RepoInfo};
use super::components::*;
/// Renders the main dashboard page
pub fn render_dashboard(stats: &RegistryStats) -> String {
let content = format!(
r##"
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-800 mb-2">Dashboard</h1>
<p class="text-slate-500">Overview of all registries</p>
</div>
<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">
<span class="text-2xl mr-3">🐳</span>
<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">
<span class="text-2xl mr-3">☕</span>
<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">
<span class="text-2xl mr-3">📦</span>
<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">
<span class="text-2xl mr-3">🦀</span>
<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">
<span class="text-2xl mr-3">🐍</span>
<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", "🐳", stats.docker, "/ui/docker", "images"),
stat_card("Maven", "", stats.maven, "/ui/maven", "artifacts"),
stat_card("npm", "📦", stats.npm, "/ui/npm", "packages"),
stat_card("Cargo", "🦀", stats.cargo, "/ui/cargo", "crates"),
stat_card("PyPI", "🐍", stats.pypi, "/ui/pypi", "packages"),
);
layout("Dashboard", &content, Some("dashboard"))
}
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]) -> String {
let icon = get_registry_icon(registry_type);
let table_rows = if repos.is_empty() {
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">📭</div>
<div>No repositories found</div>
<div class="text-sm mt-1">Push your first artifact to see it here</div>
</td></tr>"##
.to_string()
} else {
repos
.iter()
.map(|repo| {
let detail_url =
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
format!(
r##"
<tr class="hover:bg-slate-50 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
detail_url,
detail_url,
html_escape(&repo.name),
repo.versions,
format_size(repo.size),
&repo.updated
)
})
.collect::<Vec<_>>()
.join("")
};
let version_label = match registry_type {
"docker" => "Tags",
"maven" => "Versions",
_ => "Versions",
};
let content = format!(
r##"
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center">
<span class="text-3xl mr-3">{}</span>
<div>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
<p class="text-slate-500">{} repositories</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="relative">
<input type="text"
placeholder="Search repositories..."
class="pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
hx-get="/api/ui/{}/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#repo-table-body"
name="q">
<svg class="absolute left-3 top-2.5 h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Updated</th>
</tr>
</thead>
<tbody id="repo-table-body" class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
icon,
title,
repos.len(),
registry_type,
version_label,
table_rows
);
layout(title, &content, Some(registry_type))
}
/// Renders Docker image detail page
pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
let tags_rows = if detail.tags.is_empty() {
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No tags found</td></tr>"##.to_string()
} else {
detail
.tags
.iter()
.map(|tag| {
format!(
r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
html_escape(&tag.name),
format_size(tag.size),
&tag.created
)
})
.collect::<Vec<_>>()
.join("")
};
let pull_cmd = format!("docker pull 127.0.0.1:4000/{}", name);
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/docker" class="text-blue-600 hover:text-blue-800">Docker Registry</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">🐳</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Pull Command</h2>
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
<code class="flex-1">{}</code>
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Tags ({} total)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Tag</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
html_escape(name),
html_escape(name),
pull_cmd,
pull_cmd,
detail.tags.len(),
tags_rows
);
layout(&format!("{} - Docker", name), &content, Some("docker"))
}
/// Renders package detail page (npm, cargo, pypi)
pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDetail) -> String {
let icon = get_registry_icon(registry_type);
let registry_title = get_registry_title(registry_type);
let versions_rows = if detail.versions.is_empty() {
r##"<tr><td colspan="3" class="px-6 py-8 text-center text-slate-500">No versions found</td></tr>"##.to_string()
} else {
detail
.versions
.iter()
.map(|v| {
format!(
r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<span class="font-mono text-sm bg-slate-100 px-2 py-1 rounded">{}</span>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
html_escape(&v.version),
format_size(v.size),
&v.published
)
})
.collect::<Vec<_>>()
.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
),
_ => String::new(),
};
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/{}" class="text-blue-600 hover:text-blue-800">{}</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">{}</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Install Command</h2>
<div class="flex items-center bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm">
<code class="flex-1">{}</code>
<button onclick="navigator.clipboard.writeText('{}')" class="ml-4 text-slate-400 hover:text-white transition-colors" title="Copy to clipboard">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Versions ({} total)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Version</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Published</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
registry_type,
registry_title,
html_escape(name),
icon,
html_escape(name),
install_cmd,
install_cmd,
detail.versions.len(),
versions_rows
);
layout(
&format!("{} - {}", name, registry_title),
&content,
Some(registry_type),
)
}
/// Renders Maven artifact detail page
pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
let artifact_rows = if detail.artifacts.is_empty() {
r##"<tr><td colspan="2" class="px-6 py-8 text-center text-slate-500">No artifacts found</td></tr>"##.to_string()
} else {
detail.artifacts.iter().map(|a| {
let download_url = format!("/maven2/{}/{}", path, a.filename);
format!(r##"
<tr class="hover:bg-slate-50">
<td class="px-6 py-4">
<a href="{}" class="text-blue-600 hover:text-blue-800 font-mono text-sm">{}</a>
</td>
<td class="px-6 py-4 text-slate-600">{}</td>
</tr>
"##, download_url, html_escape(&a.filename), format_size(a.size))
}).collect::<Vec<_>>().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#"<dependency>
<groupId>{}</groupId>
<artifactId>{}</artifactId>
<version>{}</version>
</dependency>"#,
parts[..parts.len().saturating_sub(2)].join("."),
artifact_name,
parts.last().unwrap_or(&"")
);
let content = format!(
r##"
<div class="mb-6">
<div class="flex items-center mb-2">
<a href="/ui/maven" class="text-blue-600 hover:text-blue-800">Maven Repository</a>
<span class="mx-2 text-slate-400">/</span>
<span class="text-slate-800 font-medium">{}</span>
</div>
<div class="flex items-center">
<span class="text-3xl mr-3">☕</span>
<h1 class="text-2xl font-bold text-slate-800">{}</h1>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 p-6 mb-6">
<h2 class="text-lg font-semibold text-slate-800 mb-3">Maven Dependency</h2>
<pre class="bg-slate-900 text-green-400 rounded-lg p-4 font-mono text-sm overflow-x-auto">{}</pre>
</div>
<div class="bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200">
<h2 class="text-lg font-semibold text-slate-800">Artifacts ({} files)</h2>
</div>
<table class="w-full">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">Size</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200">
{}
</tbody>
</table>
</div>
"##,
html_escape(path),
html_escape(path),
html_escape(&dep_cmd),
detail.artifacts.len(),
artifact_rows
);
layout(&format!("{} - Maven", path), &content, Some("maven"))
}
fn get_registry_icon(registry_type: &str) -> &'static str {
match registry_type {
"docker" => "🐳",
"maven" => "",
"npm" => "📦",
"cargo" => "🦀",
"pypi" => "🐍",
_ => "📁",
}
}
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",
_ => "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
}