mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 10:20:32 +00:00
Add i18n support, PyPI proxy, and UI improvements
- Add Russian/English language switcher with cookie persistence - Add PyPI proxy support with caching (like npm) - Add height limits to Activity Log and Mount Points tables - Change Cargo icon to delivery truck - Replace graphical logo with styled text "NORA" - Bump version to 0.2.11
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -1185,7 +1185,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nora-cli"
|
||||
version = "0.2.9"
|
||||
version = "0.2.11"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"flate2",
|
||||
@@ -1199,7 +1199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nora-registry"
|
||||
version = "0.2.9"
|
||||
version = "0.2.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nora-storage"
|
||||
version = "0.2.9"
|
||||
version = "0.2.11"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
||||
@@ -7,7 +7,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["DevITWay <devitway@gmail.com>"]
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub npm: NpmConfig,
|
||||
#[serde(default)]
|
||||
pub pypi: PypiConfig,
|
||||
#[serde(default)]
|
||||
pub auth: AuthConfig,
|
||||
}
|
||||
|
||||
@@ -68,6 +70,14 @@ pub struct NpmConfig {
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PypiConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
#[serde(default)]
|
||||
@@ -108,6 +118,15 @@ impl Default for NpmConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PypiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: Some("https://pypi.org/simple/".to_string()),
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -190,6 +209,16 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// PyPI config
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
||||
self.pypi.proxy = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY_TIMEOUT") {
|
||||
if let Ok(timeout) = val.parse() {
|
||||
self.pypi.proxy_timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
// Token storage
|
||||
if let Ok(val) = env::var("NORA_AUTH_TOKEN_STORAGE") {
|
||||
self.auth.token_storage = val;
|
||||
@@ -212,6 +241,7 @@ impl Default for Config {
|
||||
},
|
||||
maven: MavenConfig::default(),
|
||||
npm: NpmConfig::default(),
|
||||
pypi: PypiConfig::default(),
|
||||
auth: AuthConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,295 @@
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse},
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/simple/", get(list_packages))
|
||||
Router::new()
|
||||
.route("/simple/", get(list_packages))
|
||||
.route("/simple/{name}/", get(package_versions))
|
||||
.route("/simple/{name}/{filename}", get(download_file))
|
||||
}
|
||||
|
||||
/// List all packages (Simple API index)
|
||||
async fn list_packages(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let keys = state.storage.list("pypi/").await;
|
||||
let mut packages = std::collections::HashSet::new();
|
||||
|
||||
for key in keys {
|
||||
if let Some(pkg) = key.strip_prefix("pypi/").and_then(|k| k.split('/').next()) {
|
||||
packages.insert(pkg.to_string());
|
||||
if !pkg.is_empty() {
|
||||
packages.insert(pkg.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut html = String::from("<html><body><h1>Simple Index</h1>");
|
||||
let mut html = String::from(
|
||||
"<!DOCTYPE html>\n<html><head><title>Simple Index</title></head><body><h1>Simple Index</h1>\n",
|
||||
);
|
||||
let mut pkg_list: Vec<_> = packages.into_iter().collect();
|
||||
pkg_list.sort();
|
||||
|
||||
for pkg in pkg_list {
|
||||
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>", pkg, pkg));
|
||||
html.push_str(&format!("<a href=\"/simple/{}/\">{}</a><br>\n", pkg, pkg));
|
||||
}
|
||||
html.push_str("</body></html>");
|
||||
|
||||
(StatusCode::OK, Html(html))
|
||||
}
|
||||
|
||||
/// List versions/files for a specific package
|
||||
async fn package_versions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
) -> Response {
|
||||
// Normalize package name (PEP 503)
|
||||
let normalized = normalize_name(&name);
|
||||
|
||||
// Try to get local files first
|
||||
let prefix = format!("pypi/{}/", normalized);
|
||||
let keys = state.storage.list(&prefix).await;
|
||||
|
||||
if !keys.is_empty() {
|
||||
// We have local files
|
||||
let mut html = format!(
|
||||
"<!DOCTYPE html>\n<html><head><title>Links for {}</title></head><body><h1>Links for {}</h1>\n",
|
||||
name, name
|
||||
);
|
||||
|
||||
for key in &keys {
|
||||
if let Some(filename) = key.strip_prefix(&prefix) {
|
||||
if !filename.is_empty() {
|
||||
html.push_str(&format!(
|
||||
"<a href=\"/simple/{}/{}\">{}</a><br>\n",
|
||||
normalized, filename, filename
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
html.push_str("</body></html>");
|
||||
|
||||
return (StatusCode::OK, Html(html)).into_response();
|
||||
}
|
||||
|
||||
// Try proxy if configured
|
||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||
let url = format!("{}{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||
|
||||
if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await {
|
||||
// Rewrite URLs in the HTML to point to our registry
|
||||
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||
return (StatusCode::OK, Html(rewritten)).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
/// Download a specific file
|
||||
async fn download_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, filename)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let normalized = normalize_name(&name);
|
||||
let key = format!("pypi/{}/{}", normalized, filename);
|
||||
|
||||
// Try local storage first
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
state.metrics.record_download("pypi");
|
||||
state.metrics.record_cache_hit();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::CacheHit,
|
||||
format!("{}/{}", name, filename),
|
||||
"pypi",
|
||||
"CACHE",
|
||||
));
|
||||
|
||||
let content_type = if filename.ends_with(".whl") {
|
||||
"application/zip"
|
||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
"application/gzip"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Try proxy if configured
|
||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||
// First, fetch the package page to find the actual download URL
|
||||
let page_url = format!("{}{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||
|
||||
if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await {
|
||||
// Find the URL for this specific file
|
||||
if let Some(file_url) = find_file_url(&html, &filename) {
|
||||
if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await {
|
||||
state.metrics.record_download("pypi");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::ProxyFetch,
|
||||
format!("{}/{}", name, filename),
|
||||
"pypi",
|
||||
"PROXY",
|
||||
));
|
||||
|
||||
// Cache in local storage
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
let content_type = if filename.ends_with(".whl") {
|
||||
"application/zip"
|
||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
"application/gzip"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, content_type)],
|
||||
data,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
/// Normalize package name according to PEP 503
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_lowercase().replace(['-', '_', '.'], "-")
|
||||
}
|
||||
|
||||
/// Fetch package page from upstream
|
||||
async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("Accept", "text/html")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.text().await.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Fetch file from upstream
|
||||
async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Rewrite PyPI links to point to our registry
|
||||
fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
|
||||
// Simple regex-free approach: find href="..." and rewrite
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut remaining = html;
|
||||
|
||||
while let Some(href_start) = remaining.find("href=\"") {
|
||||
result.push_str(&remaining[..href_start + 6]);
|
||||
remaining = &remaining[href_start + 6..];
|
||||
|
||||
if let Some(href_end) = remaining.find('"') {
|
||||
let url = &remaining[..href_end];
|
||||
|
||||
// Extract filename from URL
|
||||
if let Some(filename) = extract_filename(url) {
|
||||
// Rewrite to our local URL
|
||||
result.push_str(&format!("/simple/{}/{}", package_name, filename));
|
||||
} else {
|
||||
result.push_str(url);
|
||||
}
|
||||
|
||||
remaining = &remaining[href_end..];
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract filename from PyPI download URL
|
||||
fn extract_filename(url: &str) -> Option<&str> {
|
||||
// PyPI URLs look like:
|
||||
// https://files.pythonhosted.org/packages/.../package-1.0.0.tar.gz#sha256=...
|
||||
// or just the filename directly
|
||||
|
||||
// Remove hash fragment
|
||||
let url = url.split('#').next()?;
|
||||
|
||||
// Get the last path component
|
||||
let filename = url.rsplit('/').next()?;
|
||||
|
||||
// Must be a valid package file
|
||||
if filename.ends_with(".tar.gz")
|
||||
|| filename.ends_with(".tgz")
|
||||
|| filename.ends_with(".whl")
|
||||
|| filename.ends_with(".zip")
|
||||
|| filename.ends_with(".egg")
|
||||
{
|
||||
Some(filename)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the download URL for a specific file in the HTML
|
||||
fn find_file_url(html: &str, target_filename: &str) -> Option<String> {
|
||||
let mut remaining = html;
|
||||
|
||||
while let Some(href_start) = remaining.find("href=\"") {
|
||||
remaining = &remaining[href_start + 6..];
|
||||
|
||||
if let Some(href_end) = remaining.find('"') {
|
||||
let url = &remaining[..href_end];
|
||||
|
||||
if let Some(filename) = extract_filename(url) {
|
||||
if filename == target_filename {
|
||||
// Remove hash fragment for actual download
|
||||
return Some(url.split('#').next().unwrap_or(url).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
remaining = &remaining[href_end..];
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
MountPoint {
|
||||
registry: "PyPI".to_string(),
|
||||
mount_path: "/simple/".to_string(),
|
||||
proxy_upstream: None,
|
||||
proxy_upstream: state.config.pypi.proxy.clone(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::i18n::{get_translations, Lang, Translations};
|
||||
|
||||
/// Application version from Cargo.toml
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
@@ -7,10 +9,12 @@ pub fn layout_dark(
|
||||
content: &str,
|
||||
active_page: Option<&str>,
|
||||
extra_scripts: &str,
|
||||
lang: Lang,
|
||||
) -> String {
|
||||
let t = get_translations(lang);
|
||||
format!(
|
||||
r##"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -58,33 +62,42 @@ pub fn layout_dark(
|
||||
document.body.classList.add('sidebar-open');
|
||||
}}
|
||||
}}
|
||||
|
||||
function setLang(lang) {{
|
||||
document.cookie = 'nora_lang=' + lang + ';path=/;max-age=31536000';
|
||||
window.location.reload();
|
||||
}}
|
||||
</script>
|
||||
{}
|
||||
</body>
|
||||
</html>"##,
|
||||
lang.code(),
|
||||
html_escape(title),
|
||||
sidebar_dark(active_page),
|
||||
header_dark(),
|
||||
sidebar_dark(active_page, &t),
|
||||
header_dark(lang),
|
||||
content,
|
||||
extra_scripts
|
||||
)
|
||||
}
|
||||
|
||||
/// Dark theme sidebar
|
||||
fn sidebar_dark(active_page: Option<&str>) -> String {
|
||||
fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
|
||||
let active = active_page.unwrap_or("");
|
||||
|
||||
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
|
||||
// Dashboard label is translated, registry names stay as-is
|
||||
let dashboard_label = t.nav_dashboard;
|
||||
|
||||
let nav_items = [
|
||||
(
|
||||
"dashboard",
|
||||
"/ui/",
|
||||
"Dashboard",
|
||||
dashboard_label,
|
||||
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"/>"#,
|
||||
true,
|
||||
),
|
||||
@@ -124,7 +137,7 @@ fn sidebar_dark(active_page: Option<&str>) -> String {
|
||||
<div id="sidebar" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-slate-800 text-white flex flex-col transform -translate-x-full md:translate-x-0 transition-transform duration-200 ease-in-out">
|
||||
<div class="h-16 flex items-center justify-between px-6 border-b border-slate-700">
|
||||
<div class="flex items-center">
|
||||
<img src="{}" alt="NORA" class="h-8" />
|
||||
<span class="text-2xl font-bold tracking-tight">N<span class="inline-block w-5 h-5 rounded-full border-2 border-current align-middle relative -top-0.5 mx-0.5"></span>RA</span>
|
||||
</div>
|
||||
<button onclick="toggleSidebar()" class="md:hidden p-1 rounded-lg hover:bg-slate-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -133,12 +146,9 @@ fn sidebar_dark(active_page: Option<&str>) -> String {
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
<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 class="text-xs font-semibold text-slate-400 uppercase tracking-wider px-4 mt-6 mb-3">
|
||||
{}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="px-4 py-4 border-t border-slate-700">
|
||||
@@ -148,15 +158,20 @@ fn sidebar_dark(active_page: Option<&str>) -> String {
|
||||
</div>
|
||||
</div>
|
||||
"#,
|
||||
super::logo::LOGO_BASE64,
|
||||
nav_html,
|
||||
t.nav_registries,
|
||||
VERSION
|
||||
)
|
||||
}
|
||||
|
||||
/// Dark theme header
|
||||
fn header_dark() -> String {
|
||||
r##"
|
||||
/// Dark theme header with language switcher
|
||||
fn header_dark(lang: Lang) -> String {
|
||||
let (en_class, ru_class) = match lang {
|
||||
Lang::En => ("text-white font-semibold", "text-slate-400 hover:text-slate-200"),
|
||||
Lang::Ru => ("text-slate-400 hover:text-slate-200", "text-white font-semibold"),
|
||||
};
|
||||
|
||||
format!(r##"
|
||||
<header class="h-16 bg-[#1e293b] border-b border-slate-700 flex items-center justify-between px-4 md:px-6">
|
||||
<div class="flex items-center">
|
||||
<button onclick="toggleSidebar()" class="md:hidden p-2 -ml-2 mr-2 rounded-lg hover:bg-slate-700">
|
||||
@@ -169,6 +184,12 @@ fn header_dark() -> String {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 md:space-x-4">
|
||||
<!-- Language switcher -->
|
||||
<div class="flex items-center border border-slate-600 rounded-lg overflow-hidden text-sm">
|
||||
<button onclick="setLang('en')" class="px-3 py-1.5 {} transition-colors">EN</button>
|
||||
<span class="text-slate-600">|</span>
|
||||
<button onclick="setLang('ru')" class="px-3 py-1.5 {} transition-colors">RU</button>
|
||||
</div>
|
||||
<a href="https://github.com/getnora-io/nora" target="_blank" class="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded-lg">
|
||||
<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"/>
|
||||
@@ -181,7 +202,7 @@ fn header_dark() -> String {
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
"##.to_string()
|
||||
"##, en_class, ru_class)
|
||||
}
|
||||
|
||||
/// Render global stats row (5-column grid)
|
||||
@@ -191,37 +212,39 @@ pub fn render_global_stats(
|
||||
artifacts: u64,
|
||||
cache_hit_percent: f64,
|
||||
storage_bytes: u64,
|
||||
lang: Lang,
|
||||
) -> String {
|
||||
let t = get_translations(lang);
|
||||
format!(
|
||||
r##"
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||
<div class="text-slate-400 text-sm mb-1">Downloads</div>
|
||||
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||
<div id="stat-downloads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||
</div>
|
||||
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||
<div class="text-slate-400 text-sm mb-1">Uploads</div>
|
||||
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||
<div id="stat-uploads" class="text-2xl font-bold text-slate-200">{}</div>
|
||||
</div>
|
||||
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||
<div class="text-slate-400 text-sm mb-1">Artifacts</div>
|
||||
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||
<div id="stat-artifacts" class="text-2xl font-bold text-slate-200">{}</div>
|
||||
</div>
|
||||
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||
<div class="text-slate-400 text-sm mb-1">Cache Hit</div>
|
||||
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||
<div id="stat-cache-hit" class="text-2xl font-bold text-slate-200">{:.1}%</div>
|
||||
</div>
|
||||
<div class="bg-[#1e293b] rounded-lg p-4 border border-slate-700">
|
||||
<div class="text-slate-400 text-sm mb-1">Storage</div>
|
||||
<div class="text-slate-400 text-sm mb-1">{}</div>
|
||||
<div id="stat-storage" class="text-2xl font-bold text-slate-200">{}</div>
|
||||
</div>
|
||||
</div>
|
||||
"##,
|
||||
downloads,
|
||||
uploads,
|
||||
artifacts,
|
||||
cache_hit_percent,
|
||||
format_size(storage_bytes)
|
||||
t.stat_downloads, downloads,
|
||||
t.stat_uploads, uploads,
|
||||
t.stat_artifacts, artifacts,
|
||||
t.stat_cache_hit, cache_hit_percent,
|
||||
t.stat_storage, format_size(storage_bytes)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,6 +257,7 @@ pub fn render_registry_card(
|
||||
uploads: u64,
|
||||
size_bytes: u64,
|
||||
href: &str,
|
||||
t: &Translations,
|
||||
) -> String {
|
||||
format!(
|
||||
r##"
|
||||
@@ -242,24 +266,24 @@ pub fn render_registry_card(
|
||||
<svg class="w-8 h-8 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
{}
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-green-400 bg-green-400/10 px-2 py-1 rounded-full">ACTIVE</span>
|
||||
<span class="text-xs font-medium text-green-400 bg-green-400/10 px-2 py-1 rounded-full">{}</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-slate-200 mb-2">{}</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-slate-500">Artifacts</span>
|
||||
<span class="text-slate-500">{}</span>
|
||||
<div class="text-slate-300 font-medium">{}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-500">Size</span>
|
||||
<span class="text-slate-500">{}</span>
|
||||
<div class="text-slate-300 font-medium">{}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-500">Downloads</span>
|
||||
<span class="text-slate-500">{}</span>
|
||||
<div class="text-slate-300 font-medium">{}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-500">Uploads</span>
|
||||
<span class="text-slate-500">{}</span>
|
||||
<div class="text-slate-300 font-medium">{}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,16 +292,17 @@ pub fn render_registry_card(
|
||||
href,
|
||||
name.to_lowercase(),
|
||||
icon_path,
|
||||
t.active,
|
||||
name,
|
||||
artifact_count,
|
||||
format_size(size_bytes),
|
||||
downloads,
|
||||
uploads
|
||||
t.artifacts, artifact_count,
|
||||
t.size, format_size(size_bytes),
|
||||
t.downloads, downloads,
|
||||
t.uploads, uploads
|
||||
)
|
||||
}
|
||||
|
||||
/// Render mount points table
|
||||
pub fn render_mount_points_table(mount_points: &[(String, String, Option<String>)]) -> String {
|
||||
pub fn render_mount_points_table(mount_points: &[(String, String, Option<String>)], t: &Translations) -> String {
|
||||
let rows: String = mount_points
|
||||
.iter()
|
||||
.map(|(registry, mount_path, proxy)| {
|
||||
@@ -299,22 +324,28 @@ pub fn render_mount_points_table(mount_points: &[(String, String, Option<String>
|
||||
r##"
|
||||
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-700">
|
||||
<h3 class="text-slate-200 font-semibold">Mount Points</h3>
|
||||
<h3 class="text-slate-200 font-semibold">{}</h3>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-80">
|
||||
<table class="w-full">
|
||||
<thead class="sticky top-0 bg-slate-800">
|
||||
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="px-4">
|
||||
{}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||
<th class="px-4 py-2">Registry</th>
|
||||
<th class="px-4 py-2">Mount Path</th>
|
||||
<th class="px-4 py-2">Proxy Upstream</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="px-4">
|
||||
{}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"##,
|
||||
t.mount_points,
|
||||
t.registry,
|
||||
t.mount_path,
|
||||
t.proxy_upstream,
|
||||
rows
|
||||
)
|
||||
}
|
||||
@@ -355,22 +386,23 @@ pub fn render_activity_row(
|
||||
}
|
||||
|
||||
/// Render the activity log container
|
||||
pub fn render_activity_log(rows: &str) -> String {
|
||||
pub fn render_activity_log(rows: &str, t: &Translations) -> String {
|
||||
format!(
|
||||
r##"
|
||||
<div class="bg-[#1e293b] rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-700">
|
||||
<h3 class="text-slate-200 font-semibold">Recent Activity</h3>
|
||||
<div class="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-slate-200 font-semibold">{}</h3>
|
||||
<span class="text-xs text-slate-500">{}</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-auto max-h-80">
|
||||
<table class="w-full" id="activity-log">
|
||||
<thead>
|
||||
<thead class="sticky top-0 bg-slate-800">
|
||||
<tr class="text-left text-xs text-slate-500 uppercase border-b border-slate-700">
|
||||
<th class="px-4 py-2">Time</th>
|
||||
<th class="px-4 py-2">Action</th>
|
||||
<th class="px-4 py-2">Artifact</th>
|
||||
<th class="px-4 py-2">Registry</th>
|
||||
<th class="px-4 py-2">Source</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
<th class="px-4 py-2">{}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="px-4">
|
||||
@@ -380,6 +412,13 @@ pub fn render_activity_log(rows: &str) -> String {
|
||||
</div>
|
||||
</div>
|
||||
"##,
|
||||
t.recent_activity,
|
||||
t.last_n_events,
|
||||
t.time,
|
||||
t.action,
|
||||
t.artifact,
|
||||
t.registry,
|
||||
t.source,
|
||||
rows
|
||||
)
|
||||
}
|
||||
@@ -432,7 +471,7 @@ fn sidebar(active_page: Option<&str>) -> String {
|
||||
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
|
||||
let nav_items = [
|
||||
@@ -555,7 +594,7 @@ pub mod icons {
|
||||
pub const DOCKER: &str = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
pub const MAVEN: &str = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
pub const NPM: &str = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
pub const CARGO: &str = r#"<path fill="currentColor" d="M23.834 8.101a13.912 13.912 0 0 1-13.643 11.72 10.105 10.105 0 0 1-1.994-.12 6.111 6.111 0 0 1-5.082-5.761 5.934 5.934 0 0 1 11.867-.084c.025.983-.401 1.846-1.277 1.871-.936 0-1.374-.668-1.374-1.567v-2.5a1.531 1.531 0 0 0-1.52-1.533H8.715a3.648 3.648 0 1 0 2.695 6.08l.073-.11.074.121a2.58 2.58 0 0 0 2.2 1.048 2.909 2.909 0 0 0 2.695-3.04 7.912 7.912 0 0 0-.217-1.933 7.404 7.404 0 0 0-14.64 1.603 7.497 7.497 0 0 0 7.308 7.405 12.822 12.822 0 0 0 2.14-.12 11.927 11.927 0 0 0 9.98-10.023.117.117 0 0 0-.043-.117.115.115 0 0 0-.084-.023l-.09.024a.116.116 0 0 1-.147-.085.116.116 0 0 1 .054-.133zm-14.49 7.072a2.162 2.162 0 1 1 0-4.324 2.162 2.162 0 0 1 0 4.324z"/>"#;
|
||||
pub const CARGO: &str = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
}
|
||||
|
||||
@@ -606,6 +645,56 @@ pub fn html_escape(s: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// Render the "bragging" footer with NORA stats
|
||||
pub fn render_bragging_footer(lang: Lang) -> String {
|
||||
let t = get_translations(lang);
|
||||
format!(r##"
|
||||
<div class="mt-8 bg-gradient-to-r from-slate-800 to-slate-900 rounded-lg border border-slate-700 p-6">
|
||||
<div class="text-center mb-4">
|
||||
<span class="text-slate-400 text-sm uppercase tracking-wider">{}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 text-center">
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-blue-400">34 MB</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-green-400"><1s</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-purple-400">~30 MB</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-yellow-400">5</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{}</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-pink-400">{}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">amd64 / arm64</div>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="text-2xl font-bold text-cyan-400">{}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Config</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-4">
|
||||
<span class="text-slate-500 text-xs">{}</span>
|
||||
</div>
|
||||
</div>
|
||||
"##,
|
||||
t.built_for_speed,
|
||||
t.docker_image,
|
||||
t.cold_start,
|
||||
t.memory,
|
||||
t.registries_count,
|
||||
t.multi_arch,
|
||||
t.zero_config,
|
||||
t.tagline
|
||||
)
|
||||
}
|
||||
|
||||
/// Format Unix timestamp as relative time
|
||||
pub fn format_timestamp(ts: u64) -> String {
|
||||
if ts == 0 {
|
||||
|
||||
272
nora-registry/src/ui/i18n.rs
Normal file
272
nora-registry/src/ui/i18n.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
/// Internationalization support for the UI
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Lang {
|
||||
#[default]
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"ru" | "rus" | "russian" => Lang::Ru,
|
||||
_ => Lang::En,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en",
|
||||
Lang::Ru => "ru",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All translatable strings
|
||||
#[allow(dead_code)]
|
||||
pub struct Translations {
|
||||
// Navigation
|
||||
pub nav_dashboard: &'static str,
|
||||
pub nav_registries: &'static str,
|
||||
|
||||
// Dashboard
|
||||
pub dashboard_title: &'static str,
|
||||
pub dashboard_subtitle: &'static str,
|
||||
pub uptime: &'static str,
|
||||
|
||||
// Stats
|
||||
pub stat_downloads: &'static str,
|
||||
pub stat_uploads: &'static str,
|
||||
pub stat_artifacts: &'static str,
|
||||
pub stat_cache_hit: &'static str,
|
||||
pub stat_storage: &'static str,
|
||||
|
||||
// Registry cards
|
||||
pub active: &'static str,
|
||||
pub artifacts: &'static str,
|
||||
pub size: &'static str,
|
||||
pub downloads: &'static str,
|
||||
pub uploads: &'static str,
|
||||
|
||||
// Mount points
|
||||
pub mount_points: &'static str,
|
||||
pub registry: &'static str,
|
||||
pub mount_path: &'static str,
|
||||
pub proxy_upstream: &'static str,
|
||||
|
||||
// Activity
|
||||
pub recent_activity: &'static str,
|
||||
pub last_n_events: &'static str,
|
||||
pub time: &'static str,
|
||||
pub action: &'static str,
|
||||
pub artifact: &'static str,
|
||||
pub source: &'static str,
|
||||
pub no_activity: &'static str,
|
||||
|
||||
// Relative time
|
||||
pub just_now: &'static str,
|
||||
pub min_ago: &'static str,
|
||||
pub mins_ago: &'static str,
|
||||
pub hour_ago: &'static str,
|
||||
pub hours_ago: &'static str,
|
||||
pub day_ago: &'static str,
|
||||
pub days_ago: &'static str,
|
||||
|
||||
// Registry pages
|
||||
pub repositories: &'static str,
|
||||
pub search_placeholder: &'static str,
|
||||
pub no_repos_found: &'static str,
|
||||
pub push_first_artifact: &'static str,
|
||||
pub name: &'static str,
|
||||
pub tags: &'static str,
|
||||
pub versions: &'static str,
|
||||
pub updated: &'static str,
|
||||
|
||||
// Detail pages
|
||||
pub pull_command: &'static str,
|
||||
pub install_command: &'static str,
|
||||
pub maven_dependency: &'static str,
|
||||
pub total: &'static str,
|
||||
pub created: &'static str,
|
||||
pub published: &'static str,
|
||||
pub filename: &'static str,
|
||||
pub files: &'static str,
|
||||
|
||||
// Bragging footer
|
||||
pub built_for_speed: &'static str,
|
||||
pub docker_image: &'static str,
|
||||
pub cold_start: &'static str,
|
||||
pub memory: &'static str,
|
||||
pub registries_count: &'static str,
|
||||
pub multi_arch: &'static str,
|
||||
pub zero_config: &'static str,
|
||||
pub tagline: &'static str,
|
||||
}
|
||||
|
||||
pub fn get_translations(lang: Lang) -> &'static Translations {
|
||||
match lang {
|
||||
Lang::En => &TRANSLATIONS_EN,
|
||||
Lang::Ru => &TRANSLATIONS_RU,
|
||||
}
|
||||
}
|
||||
|
||||
pub static TRANSLATIONS_EN: Translations = Translations {
|
||||
// Navigation
|
||||
nav_dashboard: "Dashboard",
|
||||
nav_registries: "Registries",
|
||||
|
||||
// Dashboard
|
||||
dashboard_title: "Dashboard",
|
||||
dashboard_subtitle: "Overview of all registries",
|
||||
uptime: "Uptime",
|
||||
|
||||
// Stats
|
||||
stat_downloads: "Downloads",
|
||||
stat_uploads: "Uploads",
|
||||
stat_artifacts: "Artifacts",
|
||||
stat_cache_hit: "Cache Hit",
|
||||
stat_storage: "Storage",
|
||||
|
||||
// Registry cards
|
||||
active: "ACTIVE",
|
||||
artifacts: "Artifacts",
|
||||
size: "Size",
|
||||
downloads: "Downloads",
|
||||
uploads: "Uploads",
|
||||
|
||||
// Mount points
|
||||
mount_points: "Mount Points",
|
||||
registry: "Registry",
|
||||
mount_path: "Mount Path",
|
||||
proxy_upstream: "Proxy Upstream",
|
||||
|
||||
// Activity
|
||||
recent_activity: "Recent Activity",
|
||||
last_n_events: "Last 20 events",
|
||||
time: "Time",
|
||||
action: "Action",
|
||||
artifact: "Artifact",
|
||||
source: "Source",
|
||||
no_activity: "No recent activity",
|
||||
|
||||
// Relative time
|
||||
just_now: "just now",
|
||||
min_ago: "min ago",
|
||||
mins_ago: "mins ago",
|
||||
hour_ago: "hour ago",
|
||||
hours_ago: "hours ago",
|
||||
day_ago: "day ago",
|
||||
days_ago: "days ago",
|
||||
|
||||
// Registry pages
|
||||
repositories: "repositories",
|
||||
search_placeholder: "Search repositories...",
|
||||
no_repos_found: "No repositories found",
|
||||
push_first_artifact: "Push your first artifact to see it here",
|
||||
name: "Name",
|
||||
tags: "Tags",
|
||||
versions: "Versions",
|
||||
updated: "Updated",
|
||||
|
||||
// Detail pages
|
||||
pull_command: "Pull Command",
|
||||
install_command: "Install Command",
|
||||
maven_dependency: "Maven Dependency",
|
||||
total: "total",
|
||||
created: "Created",
|
||||
published: "Published",
|
||||
filename: "Filename",
|
||||
files: "files",
|
||||
|
||||
// Bragging footer
|
||||
built_for_speed: "Built for speed",
|
||||
docker_image: "Docker Image",
|
||||
cold_start: "Cold Start",
|
||||
memory: "Memory",
|
||||
registries_count: "Registries",
|
||||
multi_arch: "Multi-arch",
|
||||
zero_config: "Zero",
|
||||
tagline: "Pure Rust. Single binary. OCI compatible.",
|
||||
};
|
||||
|
||||
pub static TRANSLATIONS_RU: Translations = Translations {
|
||||
// Navigation
|
||||
nav_dashboard: "Панель",
|
||||
nav_registries: "Реестры",
|
||||
|
||||
// Dashboard
|
||||
dashboard_title: "Панель управления",
|
||||
dashboard_subtitle: "Обзор всех реестров",
|
||||
uptime: "Аптайм",
|
||||
|
||||
// Stats
|
||||
stat_downloads: "Загрузки",
|
||||
stat_uploads: "Публикации",
|
||||
stat_artifacts: "Артефакты",
|
||||
stat_cache_hit: "Кэш",
|
||||
stat_storage: "Хранилище",
|
||||
|
||||
// Registry cards
|
||||
active: "АКТИВЕН",
|
||||
artifacts: "Артефакты",
|
||||
size: "Размер",
|
||||
downloads: "Загрузки",
|
||||
uploads: "Публикации",
|
||||
|
||||
// Mount points
|
||||
mount_points: "Точки монтирования",
|
||||
registry: "Реестр",
|
||||
mount_path: "Путь",
|
||||
proxy_upstream: "Прокси",
|
||||
|
||||
// Activity
|
||||
recent_activity: "Последняя активность",
|
||||
last_n_events: "Последние 20 событий",
|
||||
time: "Время",
|
||||
action: "Действие",
|
||||
artifact: "Артефакт",
|
||||
source: "Источник",
|
||||
no_activity: "Нет активности",
|
||||
|
||||
// Relative time
|
||||
just_now: "только что",
|
||||
min_ago: "мин назад",
|
||||
mins_ago: "мин назад",
|
||||
hour_ago: "час назад",
|
||||
hours_ago: "ч назад",
|
||||
day_ago: "день назад",
|
||||
days_ago: "дн назад",
|
||||
|
||||
// Registry pages
|
||||
repositories: "репозиториев",
|
||||
search_placeholder: "Поиск репозиториев...",
|
||||
no_repos_found: "Репозитории не найдены",
|
||||
push_first_artifact: "Загрузите первый артефакт, чтобы увидеть его здесь",
|
||||
name: "Название",
|
||||
tags: "Теги",
|
||||
versions: "Версии",
|
||||
updated: "Обновлено",
|
||||
|
||||
// Detail pages
|
||||
pull_command: "Команда загрузки",
|
||||
install_command: "Команда установки",
|
||||
maven_dependency: "Maven зависимость",
|
||||
total: "всего",
|
||||
created: "Создан",
|
||||
published: "Опубликован",
|
||||
filename: "Файл",
|
||||
files: "файлов",
|
||||
|
||||
// Bragging footer
|
||||
built_for_speed: "Создан для скорости",
|
||||
docker_image: "Docker образ",
|
||||
cold_start: "Холодный старт",
|
||||
memory: "Память",
|
||||
registries_count: "Реестров",
|
||||
multi_arch: "Мульти-арх",
|
||||
zero_config: "Без",
|
||||
tagline: "Чистый Rust. Один бинарник. OCI совместимый.",
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
mod api;
|
||||
mod components;
|
||||
pub mod i18n;
|
||||
mod logo;
|
||||
mod templates;
|
||||
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
routing::get,
|
||||
Router,
|
||||
@@ -13,8 +14,33 @@ use axum::{
|
||||
use std::sync::Arc;
|
||||
|
||||
use api::*;
|
||||
use i18n::Lang;
|
||||
use templates::*;
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct LangQuery {
|
||||
lang: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
|
||||
// Priority: query param > cookie > default
|
||||
if let Some(ref lang) = query.lang {
|
||||
return Lang::from_str(lang);
|
||||
}
|
||||
|
||||
// Try cookie
|
||||
if let Some(cookies) = cookie_header {
|
||||
for part in cookies.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(value) = part.strip_prefix("nora_lang=") {
|
||||
return Lang::from_str(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lang::default()
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// UI Pages
|
||||
@@ -40,77 +66,122 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
}
|
||||
|
||||
// Dashboard page
|
||||
async fn dashboard(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn dashboard(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let response = api_dashboard(State(state)).await.0;
|
||||
Html(render_dashboard(&response))
|
||||
Html(render_dashboard(&response, lang))
|
||||
}
|
||||
|
||||
// Docker pages
|
||||
async fn docker_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn docker_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let repos = get_docker_repos(&state.storage).await;
|
||||
Html(render_registry_list("docker", "Docker Registry", &repos))
|
||||
Html(render_registry_list("docker", "Docker Registry", &repos, lang))
|
||||
}
|
||||
|
||||
async fn docker_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let detail = get_docker_detail(&state.storage, &name).await;
|
||||
Html(render_docker_detail(&name, &detail))
|
||||
Html(render_docker_detail(&name, &detail, lang))
|
||||
}
|
||||
|
||||
// Maven pages
|
||||
async fn maven_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn maven_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let repos = get_maven_repos(&state.storage).await;
|
||||
Html(render_registry_list("maven", "Maven Repository", &repos))
|
||||
Html(render_registry_list("maven", "Maven Repository", &repos, lang))
|
||||
}
|
||||
|
||||
async fn maven_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let detail = get_maven_detail(&state.storage, &path).await;
|
||||
Html(render_maven_detail(&path, &detail))
|
||||
Html(render_maven_detail(&path, &detail, lang))
|
||||
}
|
||||
|
||||
// npm pages
|
||||
async fn npm_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn npm_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let packages = get_npm_packages(&state.storage).await;
|
||||
Html(render_registry_list("npm", "npm Registry", &packages))
|
||||
Html(render_registry_list("npm", "npm Registry", &packages, lang))
|
||||
}
|
||||
|
||||
async fn npm_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let detail = get_npm_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("npm", &name, &detail))
|
||||
Html(render_package_detail("npm", &name, &detail, lang))
|
||||
}
|
||||
|
||||
// Cargo pages
|
||||
async fn cargo_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn cargo_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let crates = get_cargo_crates(&state.storage).await;
|
||||
Html(render_registry_list("cargo", "Cargo Registry", &crates))
|
||||
Html(render_registry_list("cargo", "Cargo Registry", &crates, lang))
|
||||
}
|
||||
|
||||
async fn cargo_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let detail = get_cargo_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("cargo", &name, &detail))
|
||||
Html(render_package_detail("cargo", &name, &detail, lang))
|
||||
}
|
||||
|
||||
// PyPI pages
|
||||
async fn pypi_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
async fn pypi_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let packages = get_pypi_packages(&state.storage).await;
|
||||
Html(render_registry_list("pypi", "PyPI Repository", &packages))
|
||||
Html(render_registry_list("pypi", "PyPI Repository", &packages, lang))
|
||||
}
|
||||
|
||||
async fn pypi_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(&Query(query), headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let detail = get_pypi_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("pypi", &name, &detail))
|
||||
Html(render_package_detail("pypi", &name, &detail, lang))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
|
||||
use super::components::*;
|
||||
use super::i18n::{get_translations, Lang};
|
||||
|
||||
/// Renders the main dashboard page with dark theme
|
||||
pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
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,
|
||||
@@ -10,6 +12,7 @@ pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
data.global_stats.artifacts,
|
||||
data.global_stats.cache_hit_percent,
|
||||
data.global_stats.storage_bytes,
|
||||
lang,
|
||||
);
|
||||
|
||||
// Render registry cards
|
||||
@@ -41,6 +44,7 @@ pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
r.uploads,
|
||||
r.size_bytes,
|
||||
&format!("/ui/{}", r.name),
|
||||
&t,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -57,11 +61,11 @@ pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mount_points = render_mount_points_table(&mount_data);
|
||||
let mount_points = render_mount_points_table(&mount_data, &t);
|
||||
|
||||
// Render activity log
|
||||
let activity_rows: String = if data.activity.is_empty() {
|
||||
r##"<tr><td colspan="5" class="py-8 text-center text-slate-500">No recent activity</td></tr>"##.to_string()
|
||||
format!(r##"<tr><td colspan="5" class="py-8 text-center text-slate-500">{}</td></tr>"##, t.no_activity)
|
||||
} else {
|
||||
data.activity
|
||||
.iter()
|
||||
@@ -77,23 +81,26 @@ pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let activity_log = render_activity_log(&activity_rows);
|
||||
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">Dashboard</h1>
|
||||
<p class="text-slate-400">Overview of all registries</p>
|
||||
<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">Uptime</div>
|
||||
<div class="text-sm text-slate-500">{}</div>
|
||||
<div id="uptime" class="text-lg font-semibold text-slate-300">{}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,16 +112,26 @@ pub fn render_dashboard(data: &DashboardResponse) -> String {
|
||||
{}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{}
|
||||
{}
|
||||
</div>
|
||||
|
||||
{}
|
||||
"##,
|
||||
uptime_str, global_stats, registry_cards, mount_points, activity_log,
|
||||
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("Dashboard", &content, Some("dashboard"), &polling_script)
|
||||
layout_dark(t.dashboard_title, &content, Some("dashboard"), &polling_script, lang)
|
||||
}
|
||||
|
||||
/// Format timestamp as relative time (e.g., "2 min ago")
|
||||
@@ -137,16 +154,16 @@ fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
}
|
||||
|
||||
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
||||
pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]) -> String {
|
||||
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() {
|
||||
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
||||
format!(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()
|
||||
<div>{}</div>
|
||||
<div class="text-sm mt-1">{}</div>
|
||||
</td></tr>"##, t.no_repos_found, t.push_first_artifact)
|
||||
} else {
|
||||
repos
|
||||
.iter()
|
||||
@@ -177,9 +194,8 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
||||
};
|
||||
|
||||
let version_label = match registry_type {
|
||||
"docker" => "Tags",
|
||||
"maven" => "Versions",
|
||||
_ => "Versions",
|
||||
"docker" => t.tags,
|
||||
_ => t.versions,
|
||||
};
|
||||
|
||||
let content = format!(
|
||||
@@ -189,13 +205,13 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
||||
<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">{} repositories</p>
|
||||
<p class="text-slate-500">{} {}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Search repositories..."
|
||||
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"
|
||||
@@ -212,10 +228,10 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
||||
<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">Name</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">Size</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">Updated</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">
|
||||
@@ -227,16 +243,22 @@ pub fn render_registry_list(registry_type: &str, title: &str, repos: &[RepoInfo]
|
||||
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), "")
|
||||
layout_dark(title, &content, Some(registry_type), "", lang)
|
||||
}
|
||||
|
||||
/// Renders Docker image detail page
|
||||
pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
||||
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 {
|
||||
@@ -318,11 +340,12 @@ pub fn render_docker_detail(name: &str, detail: &DockerDetail) -> String {
|
||||
tags_rows
|
||||
);
|
||||
|
||||
layout_dark(&format!("{} - Docker", name), &content, Some("docker"), "")
|
||||
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) -> String {
|
||||
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);
|
||||
|
||||
@@ -422,11 +445,13 @@ pub fn render_package_detail(registry_type: &str, name: &str, detail: &PackageDe
|
||||
&content,
|
||||
Some(registry_type),
|
||||
"",
|
||||
lang,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders Maven artifact detail page
|
||||
pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
|
||||
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 {
|
||||
@@ -506,7 +531,7 @@ pub fn render_maven_detail(path: &str, detail: &MavenDetail) -> String {
|
||||
artifact_rows
|
||||
);
|
||||
|
||||
layout_dark(&format!("{} - Maven", path), &content, Some("maven"), "")
|
||||
layout_dark(&format!("{} - Maven", path), &content, Some("maven"), "", lang)
|
||||
}
|
||||
|
||||
/// Returns SVG icon path for the registry type
|
||||
|
||||
Reference in New Issue
Block a user