mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 09:10:32 +00:00
* docs: add Go module proxy to README, update dashboard GIF - Add Go Modules to supported registries table - Add Go usage example (GOPROXY) - Add Go config.toml example - Add /go/ endpoint to endpoints table - Update dashboard GIF with 6 registry cards in one row - Fix registries count: 6 package registries * feat(ui): add Raw storage to dashboard, sidebar, and list pages - Raw Storage card on dashboard with file count and size - Raw in sidebar navigation with file icon - Raw list and detail pages (/ui/raw) - Raw mount point in mount points table - Grid updated to 7 columns for all registry cards - README: 7 registries, add Go module proxy docs * docs: add product badges (release, image size, downloads)
862 lines
31 KiB
Rust
862 lines
31 KiB
Rust
// 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<String>)> = data
|
|
.mount_points
|
|
.iter()
|
|
.map(|m| {
|
|
(
|
|
m.registry.clone(),
|
|
m.mount_path.clone(),
|
|
m.proxy_upstream.clone(),
|
|
)
|
|
})
|
|
.collect();
|
|
let mount_points = render_mount_points_table(&mount_data, t);
|
|
|
|
// Render activity log
|
|
let activity_rows: String = if data.activity.is_empty() {
|
|
format!(
|
|
r##"<tr><td colspan="5" class="py-8 text-center text-slate-500">{}</td></tr>"##,
|
|
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<GroupedActivity> = 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##"
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-200 mb-1">{}</h1>
|
|
<p class="text-slate-400">{}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-sm text-slate-500">{}</div>
|
|
<div id="uptime" class="text-lg font-semibold text-slate-300">{}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{}
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 mb-6">
|
|
{}
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
|
{}
|
|
{}
|
|
</div>
|
|
|
|
{}
|
|
"##,
|
|
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<chrono::Utc>) -> String {
|
|
let now = chrono::Utc::now();
|
|
let diff = now.signed_duration_since(*timestamp);
|
|
|
|
if diff.num_seconds() < 60 {
|
|
"just now".to_string()
|
|
} else if diff.num_minutes() < 60 {
|
|
let mins = diff.num_minutes();
|
|
format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
|
|
} else if diff.num_hours() < 24 {
|
|
let hours = diff.num_hours();
|
|
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
|
|
} else {
|
|
let days = diff.num_days();
|
|
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
|
|
}
|
|
}
|
|
|
|
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
|
#[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##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
|
<div class="text-4xl mb-2">📭</div>
|
|
<div>{}</div>
|
|
<div class="text-sm mt-1">{}</div>
|
|
</td></tr>"##,
|
|
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##"
|
|
<tr class="hover:bg-slate-700 cursor-pointer" onclick="window.location='{}'">
|
|
<td class="px-6 py-4">
|
|
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
|
|
</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</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" => t.tags,
|
|
_ => t.versions,
|
|
};
|
|
|
|
let content = format!(
|
|
r##"
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<div class="flex items-center">
|
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
|
<p class="text-slate-500">{} {}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<div class="relative">
|
|
<input type="text"
|
|
placeholder="{}"
|
|
class="pl-10 pr-4 py-2 bg-slate-800 border border-slate-600 text-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-500"
|
|
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-500" 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800 border-b border-slate-700">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="repo-table-body" class="divide-y divide-slate-700">
|
|
{}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"##,
|
|
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##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
|
<div class="text-4xl mb-2">📭</div>
|
|
<div>{}</div>
|
|
<div class="text-sm mt-1">{}</div>
|
|
</td></tr>"##,
|
|
t.no_repos_found, t.push_first_artifact
|
|
)
|
|
} else 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 more items on this page</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-700 cursor-pointer" onclick="window.location='{}'">
|
|
<td class="px-6 py-4">
|
|
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
|
|
</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</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" => 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##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">←</a>"##,
|
|
registry_type, page - 1, limit
|
|
));
|
|
} else {
|
|
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">←</span>"##);
|
|
}
|
|
|
|
// 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##"<a href="/ui/{}?page=1&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">1</a>"##,
|
|
registry_type, limit
|
|
));
|
|
if start_page > 2 {
|
|
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
|
|
}
|
|
}
|
|
|
|
for p in start_page..=end_page {
|
|
if p == page {
|
|
pages_html.push_str(&format!(
|
|
r##"<span class="px-3 py-1 rounded bg-blue-600 text-white font-medium">{}</span>"##,
|
|
p
|
|
));
|
|
} else {
|
|
pages_html.push_str(&format!(
|
|
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
|
|
registry_type, p, limit, p
|
|
));
|
|
}
|
|
}
|
|
|
|
if end_page < total_pages {
|
|
if end_page < total_pages - 1 {
|
|
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
|
|
}
|
|
pages_html.push_str(&format!(
|
|
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
|
|
registry_type, total_pages, limit, total_pages
|
|
));
|
|
}
|
|
|
|
// Next button
|
|
if page < total_pages {
|
|
pages_html.push_str(&format!(
|
|
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">→</a>"##,
|
|
registry_type, page + 1, limit
|
|
));
|
|
} else {
|
|
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">→</span>"##);
|
|
}
|
|
|
|
format!(
|
|
r##"
|
|
<div class="mt-4 flex items-center justify-between">
|
|
<div class="text-sm text-slate-500">
|
|
Showing {}-{} of {} items
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
{}
|
|
</div>
|
|
</div>
|
|
"##,
|
|
start_item, end_item, total, pages_html
|
|
)
|
|
} else if total > 0 {
|
|
format!(
|
|
r##"<div class="mt-4 text-sm text-slate-500">Showing all {} items</div>"##,
|
|
total
|
|
)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let content = format!(
|
|
r##"
|
|
<div class="mb-6 flex items-center justify-between">
|
|
<div class="flex items-center">
|
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
|
<p class="text-slate-500">{} {}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<div class="relative">
|
|
<input type="text"
|
|
placeholder="{}"
|
|
class="pl-10 pr-4 py-2 bg-slate-800 border border-slate-600 text-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-500"
|
|
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-500" 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800 border-b border-slate-700">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="repo-table-body" class="divide-y divide-slate-700">
|
|
{}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{}
|
|
"##,
|
|
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##"<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-700">
|
|
<td class="px-6 py-4">
|
|
<span class="font-mono text-sm bg-slate-700 text-slate-200 px-2 py-1 rounded">{}</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</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-400 hover:text-blue-300">Docker Registry</a>
|
|
<span class="mx-2 text-slate-500">/</span>
|
|
<span class="text-slate-200 font-medium">{}</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-slate-200 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-700">
|
|
<h2 class="text-lg font-semibold text-slate-200">Tags ({} total)</h2>
|
|
</div>
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800 border-b border-slate-700">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Tag</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Created</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-700">
|
|
{}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"##,
|
|
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##"<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-700">
|
|
<td class="px-6 py-4">
|
|
<span class="font-mono text-sm bg-slate-700 text-slate-200 px-2 py-1 rounded">{}</span>
|
|
</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</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
|
|
),
|
|
"go" => format!("GOPROXY=http://127.0.0.1:4000/go go get {}", name),
|
|
"raw" => format!("curl -O http://127.0.0.1:4000/raw/{}/<file>", name),
|
|
_ => String::new(),
|
|
};
|
|
|
|
let content = format!(
|
|
r##"
|
|
<div class="mb-6">
|
|
<div class="flex items-center mb-2">
|
|
<a href="/ui/{}" class="text-blue-400 hover:text-blue-300">{}</a>
|
|
<span class="mx-2 text-slate-500">/</span>
|
|
<span class="text-slate-200 font-medium">{}</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-slate-200 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-700">
|
|
<h2 class="text-lg font-semibold text-slate-200">Versions ({} total)</h2>
|
|
</div>
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800 border-b border-slate-700">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Version</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Published</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-700">
|
|
{}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"##,
|
|
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##"<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-700">
|
|
<td class="px-6 py-4">
|
|
<a href="{}" class="text-blue-400 hover:text-blue-300 font-mono text-sm">{}</a>
|
|
</td>
|
|
<td class="px-6 py-4 text-slate-400">{}</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-400 hover:text-blue-300">Maven Repository</a>
|
|
<span class="mx-2 text-slate-500">/</span>
|
|
<span class="text-slate-200 font-medium">{}</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
|
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 p-6 mb-6">
|
|
<h2 class="text-lg font-semibold text-slate-200 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-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
|
<div class="px-6 py-4 border-b border-slate-700">
|
|
<h2 class="text-lg font-semibold text-slate-200">Artifacts ({} files)</h2>
|
|
</div>
|
|
<table class="w-full">
|
|
<thead class="bg-slate-800 border-b border-slate-700">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Filename</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-700">
|
|
{}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
"##,
|
|
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#"<path fill="currentColor" d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>"#
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|