mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 20:50:31 +00:00
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:
580
nora-registry/src/ui/api.rs
Normal file
580
nora-registry/src/ui/api.rs
Normal 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
|
||||
}
|
||||
}
|
||||
222
nora-registry/src/ui/components.rs
Normal file
222
nora-registry/src/ui/components.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 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
114
nora-registry/src/ui/mod.rs
Normal 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))
|
||||
}
|
||||
478
nora-registry/src/ui/templates.rs
Normal file
478
nora-registry/src/ui/templates.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user