mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
feat: Add web UI for browsing packages in pkg6depotd
Add a human-facing web interface at /ui/ for browsing IPS package repositories. Uses Askama templates, HTMX for interactivity, and Pico.css for styling. Routes: - /ui/ - Publisher list with package counts - /ui/packages/:publisher - Paginated package list - /ui/search - Search with HTMX search-as-you-type - /ui/package/:publisher/*fmri - Package detail with lazy manifest - /ui/p5i - P5I file generation for installing package sets
This commit is contained in:
parent
6f5040978b
commit
d49bb3c306
16 changed files with 1047 additions and 7 deletions
78
Cargo.lock
generated
78
Cargo.lock
generated
|
|
@ -131,6 +131,58 @@ version = "1.7.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57"
|
||||||
|
dependencies = [
|
||||||
|
"askama_macros",
|
||||||
|
"itoa",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_derive"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37"
|
||||||
|
dependencies = [
|
||||||
|
"askama_parser",
|
||||||
|
"basic-toml",
|
||||||
|
"memchr",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_macros"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b"
|
||||||
|
dependencies = [
|
||||||
|
"askama_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_parser"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c"
|
||||||
|
dependencies = [
|
||||||
|
"rustc-hash",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"unicode-ident",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "assert_cmd"
|
name = "assert_cmd"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
|
|
@ -326,6 +378,15 @@ version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basic-toml"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
|
|
@ -2124,6 +2185,7 @@ dependencies = [
|
||||||
name = "pkg6depotd"
|
name = "pkg6depotd"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"askama",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
|
|
@ -2160,6 +2222,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3532,6 +3595,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "userland"
|
name = "userland"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -3965,6 +4034,15 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winsafe"
|
name = "winsafe"
|
||||||
version = "0.0.19"
|
version = "0.0.19"
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ tracing-opentelemetry = "0.32"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
miette = { version = "7.6.0", features = ["fancy"] }
|
miette = { version = "7.6.0", features = ["fancy"] }
|
||||||
|
|
||||||
|
# Templating & Web UI
|
||||||
|
askama = "0.15"
|
||||||
|
urlencoding = "2"
|
||||||
|
|
||||||
# Project Dependencies
|
# Project Dependencies
|
||||||
libips = { path = "../libips" }
|
libips = { path = "../libips" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ pub async fn get_info(
|
||||||
// - Prefer publisher-scoped file path; fallback to global location.
|
// - Prefer publisher-scoped file path; fallback to global location.
|
||||||
// - If content appears to be gzip-compressed (magic 1f 8b), decompress.
|
// - If content appears to be gzip-compressed (magic 1f 8b), decompress.
|
||||||
// - Decode as UTF-8 (lossy) and enforce a maximum output size to avoid huge responses.
|
// - Decode as UTF-8 (lossy) and enforce a maximum output size to avoid huge responses.
|
||||||
fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Option<String> {
|
pub(crate) fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Option<String> {
|
||||||
let path = repo.get_file_path(publisher, digest)?;
|
let path = repo.get_file_path(publisher, digest)?;
|
||||||
let bytes = fs::read(&path).ok()?;
|
let bytes = fs::read(&path).ok()?;
|
||||||
|
|
||||||
|
|
@ -181,7 +181,7 @@ fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Opti
|
||||||
Some(text)
|
Some(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
|
pub(crate) fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
|
||||||
for attr in &manifest.attributes {
|
for attr in &manifest.attributes {
|
||||||
if attr.key == key {
|
if attr.key == key {
|
||||||
return attr.values.first().cloned();
|
return attr.values.first().cloned();
|
||||||
|
|
@ -208,7 +208,7 @@ fn month_name(month: u32) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_packaging_date(ts: &str) -> Option<String> {
|
pub(crate) fn format_packaging_date(ts: &str) -> Option<String> {
|
||||||
// Expect formats like YYYYMMDDThhmmssZ or with fractional seconds before Z
|
// Expect formats like YYYYMMDDThhmmssZ or with fractional seconds before Z
|
||||||
let clean_ts = if let Some((base, _frac)) = ts.split_once('.') {
|
let clean_ts = if let Some((base, _frac)) = ts.split_once('.') {
|
||||||
format!("{}Z", base)
|
format!("{}Z", base)
|
||||||
|
|
@ -239,7 +239,7 @@ fn format_packaging_date(ts: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum pkg.size (uncompressed) and pkg.csize (compressed) over all file actions
|
// Sum pkg.size (uncompressed) and pkg.csize (compressed) over all file actions
|
||||||
fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
|
pub(crate) fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
|
||||||
let mut size: u128 = 0;
|
let mut size: u128 = 0;
|
||||||
let mut csize: u128 = 0;
|
let mut csize: u128 = 0;
|
||||||
|
|
||||||
|
|
@ -260,7 +260,7 @@ fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
|
||||||
(size, csize)
|
(size, csize)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn human_bytes(bytes: u128) -> String {
|
pub(crate) fn human_bytes(bytes: u128) -> String {
|
||||||
// Use binary (IEC-like) units for familiarity; format with two decimals for KB and above
|
// Use binary (IEC-like) units for familiarity; format with two decimals for KB and above
|
||||||
const KIB: u128 = 1024;
|
const KIB: u128 = 1024;
|
||||||
const MIB: u128 = 1024 * 1024;
|
const MIB: u128 = 1024 * 1024;
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ pub mod manifest;
|
||||||
pub mod publisher;
|
pub mod publisher;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod shard;
|
pub mod shard;
|
||||||
|
pub mod ui;
|
||||||
pub mod versions;
|
pub mod versions;
|
||||||
|
|
|
||||||
502
pkg6depotd/src/http/handlers/ui.rs
Normal file
502
pkg6depotd/src/http/handlers/ui.rs
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
use crate::errors::DepotError;
|
||||||
|
use crate::http::handlers::info::{compute_sizes, find_attr, format_packaging_date, human_bytes};
|
||||||
|
use crate::repo::DepotRepo;
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{header, HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use libips::actions::Manifest;
|
||||||
|
use libips::fmri::Fmri;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: render an Askama template into an HTML response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn render_template(tmpl: &impl Template) -> Result<Response, DepotError> {
|
||||||
|
let html = tmpl
|
||||||
|
.render()
|
||||||
|
.map_err(|e| DepotError::Server(e.to_string()))?;
|
||||||
|
Ok(([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: URL-encode an FMRI for use in URL path segments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn encode_fmri(fmri_str: &str) -> String {
|
||||||
|
// Encode characters that are problematic in URLs but preserve / for readability
|
||||||
|
let mut out = String::with_capacity(fmri_str.len());
|
||||||
|
for ch in fmri_str.chars() {
|
||||||
|
match ch {
|
||||||
|
'@' => out.push_str("%40"),
|
||||||
|
',' => out.push_str("%2C"),
|
||||||
|
':' => out.push_str("%3A"),
|
||||||
|
_ => out.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: parse a manifest from text (JSON or legacy)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn parse_manifest(content: &str) -> Result<Manifest, DepotError> {
|
||||||
|
match serde_json::from_str::<Manifest>(content) {
|
||||||
|
Ok(m) => Ok(m),
|
||||||
|
Err(_) => Manifest::parse_string(content.to_string())
|
||||||
|
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: parse version components from an FMRI version string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct VersionParts {
|
||||||
|
version: String,
|
||||||
|
build_release: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
timestamp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version_parts(version_full: &str) -> VersionParts {
|
||||||
|
let mut version = version_full.to_string();
|
||||||
|
let mut build_release = None;
|
||||||
|
let mut branch = None;
|
||||||
|
let mut timestamp = None;
|
||||||
|
|
||||||
|
if let Some((core, rest)) = version_full.split_once(',') {
|
||||||
|
version = core.to_string();
|
||||||
|
if let Some((rel_branch, ts)) = rest.split_once(':') {
|
||||||
|
timestamp = Some(ts.to_string());
|
||||||
|
if let Some((rel, br)) = rel_branch.split_once('-') {
|
||||||
|
if !rel.is_empty() {
|
||||||
|
build_release = Some(rel.to_string());
|
||||||
|
}
|
||||||
|
if !br.is_empty() {
|
||||||
|
branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
} else if !rel_branch.is_empty() {
|
||||||
|
build_release = Some(rel_branch.to_string());
|
||||||
|
}
|
||||||
|
} else if let Some((rel, br)) = rest.split_once('-') {
|
||||||
|
if !rel.is_empty() {
|
||||||
|
build_release = Some(rel.to_string());
|
||||||
|
}
|
||||||
|
if !br.is_empty() {
|
||||||
|
branch = Some(br.to_string());
|
||||||
|
}
|
||||||
|
} else if !rest.is_empty() {
|
||||||
|
build_release = Some(rest.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VersionParts {
|
||||||
|
version,
|
||||||
|
build_release,
|
||||||
|
branch,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Templates
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate {
|
||||||
|
publishers: Vec<PublisherDisplay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PublisherDisplay {
|
||||||
|
name: String,
|
||||||
|
package_count: usize,
|
||||||
|
updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "packages.html")]
|
||||||
|
struct PackagesTemplate {
|
||||||
|
publisher: String,
|
||||||
|
packages: Vec<PackageRow>,
|
||||||
|
page: usize,
|
||||||
|
per_page: usize,
|
||||||
|
total_pages: usize,
|
||||||
|
total_packages: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PackageRow {
|
||||||
|
name: String,
|
||||||
|
version: String,
|
||||||
|
fmri_str: String,
|
||||||
|
fmri_encoded: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "search.html")]
|
||||||
|
struct SearchTemplate {
|
||||||
|
publishers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "partials/search_results.html")]
|
||||||
|
struct SearchResultsTemplate {
|
||||||
|
results: Vec<SearchResultRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchResultRow {
|
||||||
|
name: String,
|
||||||
|
publisher: String,
|
||||||
|
fmri: String,
|
||||||
|
fmri_encoded: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "package_detail.html")]
|
||||||
|
struct PackageDetailTemplate {
|
||||||
|
publisher: String,
|
||||||
|
name: String,
|
||||||
|
summary: Option<String>,
|
||||||
|
version: String,
|
||||||
|
build_release: Option<String>,
|
||||||
|
branch: Option<String>,
|
||||||
|
packaging_date: Option<String>,
|
||||||
|
size: String,
|
||||||
|
csize: String,
|
||||||
|
fmri_str: String,
|
||||||
|
fmri_encoded: String,
|
||||||
|
dependencies: Vec<DepDisplay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DepDisplay {
|
||||||
|
dep_type: String,
|
||||||
|
fmri: String,
|
||||||
|
link: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "partials/manifest.html")]
|
||||||
|
struct ManifestTemplate {
|
||||||
|
manifest_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Query parameter structs
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct PaginationParams {
|
||||||
|
page: Option<usize>,
|
||||||
|
per_page: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct SearchParams {
|
||||||
|
q: Option<String>,
|
||||||
|
publisher: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct P5iParams {
|
||||||
|
publisher: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pkg: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Handlers
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/// GET /ui/ - Home page: publisher list with package counts
|
||||||
|
pub async fn ui_index(
|
||||||
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
let info = repo.get_info()?;
|
||||||
|
let publishers = info
|
||||||
|
.publishers
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| PublisherDisplay {
|
||||||
|
name: p.name,
|
||||||
|
package_count: p.package_count,
|
||||||
|
updated: p.updated,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
render_template(&IndexTemplate { publishers })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ui/packages/:publisher - Paginated package list
|
||||||
|
pub async fn ui_packages(
|
||||||
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
Path(publisher): Path<String>,
|
||||||
|
Query(params): Query<PaginationParams>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
let page = params.page.unwrap_or(1).max(1);
|
||||||
|
let per_page = params.per_page.unwrap_or(50).clamp(10, 200);
|
||||||
|
|
||||||
|
let mut pkg_list = repo.list_packages(Some(&publisher), None)?;
|
||||||
|
pkg_list.sort_by(|a, b| a.fmri.name.cmp(&b.fmri.name));
|
||||||
|
|
||||||
|
let total_packages = pkg_list.len();
|
||||||
|
let total_pages = (total_packages + per_page - 1) / per_page.max(1);
|
||||||
|
|
||||||
|
let start = (page - 1) * per_page;
|
||||||
|
let packages: Vec<PackageRow> = pkg_list
|
||||||
|
.into_iter()
|
||||||
|
.skip(start)
|
||||||
|
.take(per_page)
|
||||||
|
.map(|pi| {
|
||||||
|
let name = pi.fmri.name.clone();
|
||||||
|
let version = pi.fmri.version();
|
||||||
|
let fmri_str = if version.is_empty() {
|
||||||
|
format!("pkg://{}/{}", publisher, name)
|
||||||
|
} else {
|
||||||
|
format!("pkg://{}/{}@{}", publisher, name, version)
|
||||||
|
};
|
||||||
|
let fmri_encoded = encode_fmri(&fmri_str);
|
||||||
|
PackageRow {
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
fmri_str,
|
||||||
|
fmri_encoded,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
render_template(&PackagesTemplate {
|
||||||
|
publisher,
|
||||||
|
packages,
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
total_pages,
|
||||||
|
total_packages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ui/search - Search page (full page)
|
||||||
|
pub async fn ui_search(
|
||||||
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
let info = repo.get_info()?;
|
||||||
|
let publishers = info.publishers.into_iter().map(|p| p.name).collect();
|
||||||
|
render_template(&SearchTemplate { publishers })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ui/search/results - HTMX partial: search result rows
|
||||||
|
pub async fn ui_search_results(
|
||||||
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
Query(params): Query<SearchParams>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
let query = match params.q {
|
||||||
|
Some(ref q) if !q.trim().is_empty() => q.trim().to_string(),
|
||||||
|
_ => {
|
||||||
|
return render_template(&SearchResultsTemplate {
|
||||||
|
results: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let publisher = params
|
||||||
|
.publisher
|
||||||
|
.as_deref()
|
||||||
|
.filter(|p| !p.is_empty());
|
||||||
|
|
||||||
|
let entries = repo.search(publisher, &query, false)?;
|
||||||
|
|
||||||
|
// Deduplicate by FMRI
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for entry in entries {
|
||||||
|
if seen.insert(entry.fmri.clone()) {
|
||||||
|
// Extract publisher and name from the FMRI string
|
||||||
|
let (pub_name, pkg_name) = if let Ok(fmri) = Fmri::from_str(&entry.fmri) {
|
||||||
|
(
|
||||||
|
fmri.publisher.unwrap_or_default(),
|
||||||
|
fmri.name.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Fallback: try to parse manually
|
||||||
|
(String::new(), entry.fmri.clone())
|
||||||
|
};
|
||||||
|
let fmri_encoded = encode_fmri(&entry.fmri);
|
||||||
|
results.push(SearchResultRow {
|
||||||
|
name: pkg_name,
|
||||||
|
publisher: pub_name,
|
||||||
|
fmri: entry.fmri,
|
||||||
|
fmri_encoded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_template(&SearchResultsTemplate { results })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ui/package/:publisher/*fmri - Package detail or manifest partial
|
||||||
|
pub async fn ui_package_detail(
|
||||||
|
State(repo): State<Arc<DepotRepo>>,
|
||||||
|
Path((publisher, fmri_str)): Path<(String, String)>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
// If the path ends with /manifest, serve the manifest partial instead
|
||||||
|
if fmri_str.ends_with("/manifest") {
|
||||||
|
return ui_package_manifest_inner(&repo, &publisher, &fmri_str).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded_fmri = urlencoding::decode(&fmri_str)
|
||||||
|
.map(|c| c.into_owned())
|
||||||
|
.unwrap_or(fmri_str);
|
||||||
|
let fmri = Fmri::from_str(&decoded_fmri)
|
||||||
|
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
|
||||||
|
|
||||||
|
let content = repo.get_manifest_text(&publisher, &fmri)?;
|
||||||
|
let manifest = parse_manifest(&content)?;
|
||||||
|
|
||||||
|
let name = fmri.stem().to_string();
|
||||||
|
let version_full = fmri.version();
|
||||||
|
let vp = parse_version_parts(&version_full);
|
||||||
|
|
||||||
|
let packaging_date = vp
|
||||||
|
.timestamp
|
||||||
|
.as_deref()
|
||||||
|
.and_then(format_packaging_date);
|
||||||
|
|
||||||
|
let (total_size, total_csize) = compute_sizes(&manifest);
|
||||||
|
|
||||||
|
let fmri_display = if version_full.is_empty() {
|
||||||
|
format!("pkg://{}/{}", publisher, name)
|
||||||
|
} else {
|
||||||
|
format!("pkg://{}/{}@{}", publisher, name, version_full)
|
||||||
|
};
|
||||||
|
let fmri_encoded = encode_fmri(&fmri_display);
|
||||||
|
|
||||||
|
let summary = find_attr(&manifest, "pkg.summary");
|
||||||
|
|
||||||
|
let dependencies: Vec<DepDisplay> = manifest
|
||||||
|
.dependencies
|
||||||
|
.iter()
|
||||||
|
.filter_map(|dep| {
|
||||||
|
let dep_fmri = dep.fmri.as_ref()?;
|
||||||
|
let dep_fmri_str = format!("{}", dep_fmri);
|
||||||
|
// Try to link to detail page if same publisher
|
||||||
|
let link = if dep_fmri.publisher.is_none()
|
||||||
|
|| dep_fmri.publisher.as_deref() == Some(&publisher)
|
||||||
|
{
|
||||||
|
let full = if dep_fmri.version().is_empty() {
|
||||||
|
format!("pkg://{}/{}", publisher, dep_fmri.stem())
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"pkg://{}/{}@{}",
|
||||||
|
publisher,
|
||||||
|
dep_fmri.stem(),
|
||||||
|
dep_fmri.version()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Some(format!("/ui/package/{}/{}", publisher, encode_fmri(&full)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Some(DepDisplay {
|
||||||
|
dep_type: dep.dependency_type.clone(),
|
||||||
|
fmri: dep_fmri_str,
|
||||||
|
link,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
render_template(&PackageDetailTemplate {
|
||||||
|
publisher,
|
||||||
|
name,
|
||||||
|
summary,
|
||||||
|
version: vp.version,
|
||||||
|
build_release: vp.build_release,
|
||||||
|
branch: vp.branch,
|
||||||
|
packaging_date,
|
||||||
|
size: human_bytes(total_size),
|
||||||
|
csize: human_bytes(total_csize),
|
||||||
|
fmri_str: fmri_display,
|
||||||
|
fmri_encoded,
|
||||||
|
dependencies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve manifest partial for HTMX lazy loading
|
||||||
|
async fn ui_package_manifest_inner(
|
||||||
|
repo: &DepotRepo,
|
||||||
|
publisher: &str,
|
||||||
|
fmri_str: &str,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
// Strip trailing "/manifest" from the catch-all path
|
||||||
|
let fmri_path = fmri_str
|
||||||
|
.strip_suffix("/manifest")
|
||||||
|
.unwrap_or(fmri_str)
|
||||||
|
.to_string();
|
||||||
|
let decoded = urlencoding::decode(&fmri_path)
|
||||||
|
.map(|c| c.into_owned())
|
||||||
|
.unwrap_or(fmri_path);
|
||||||
|
let fmri = Fmri::from_str(&decoded)
|
||||||
|
.map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
|
||||||
|
|
||||||
|
let manifest_text = repo.get_manifest_text(publisher, &fmri)?;
|
||||||
|
render_template(&ManifestTemplate { manifest_text })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /ui/p5i - Generate P5I file from query params
|
||||||
|
pub async fn ui_p5i_generate(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(params): Query<P5iParams>,
|
||||||
|
) -> Result<Response, DepotError> {
|
||||||
|
if params.pkg.is_empty() {
|
||||||
|
return Err(DepotError::Server(
|
||||||
|
"No packages selected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = headers
|
||||||
|
.get(header::HOST)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("localhost");
|
||||||
|
let origin = format!("http://{}", host);
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct P5iPublisherInfo {
|
||||||
|
alias: Option<String>,
|
||||||
|
name: String,
|
||||||
|
packages: Vec<String>,
|
||||||
|
repositories: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct P5iFile {
|
||||||
|
packages: Vec<String>,
|
||||||
|
publishers: Vec<P5iPublisherInfo>,
|
||||||
|
version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let p5i = P5iFile {
|
||||||
|
packages: Vec::new(),
|
||||||
|
publishers: vec![P5iPublisherInfo {
|
||||||
|
alias: None,
|
||||||
|
name: params.publisher,
|
||||||
|
packages: params.pkg,
|
||||||
|
repositories: vec![origin],
|
||||||
|
}],
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&p5i)
|
||||||
|
.map_err(|e| DepotError::Server(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/vnd.pkg5.info"),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"packages.p5i\"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
json,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
use crate::http::admin;
|
use crate::http::admin;
|
||||||
use crate::http::handlers::{catalog, file, info, manifest, publisher, search, shard, versions};
|
use crate::http::handlers::{catalog, file, info, manifest, publisher, search, shard, ui, versions};
|
||||||
use crate::repo::DepotRepo;
|
use crate::repo::DepotRepo;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
pub fn app_router(state: Arc<DepotRepo>) -> Router {
|
pub fn app_router(state: Arc<DepotRepo>) -> Router {
|
||||||
|
// Static file serving for the web UI
|
||||||
|
let static_dir = ServeDir::new(
|
||||||
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("static"),
|
||||||
|
);
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/versions/0", get(versions::get_versions))
|
.route("/versions/0", get(versions::get_versions))
|
||||||
.route("/versions/0/", get(versions::get_versions))
|
.route("/versions/0/", get(versions::get_versions))
|
||||||
|
|
@ -64,6 +70,14 @@ pub fn app_router(state: Arc<DepotRepo>) -> Router {
|
||||||
// Admin API over HTTP
|
// Admin API over HTTP
|
||||||
.route("/admin/health", get(admin::health))
|
.route("/admin/health", get(admin::health))
|
||||||
.route("/admin/auth/check", post(admin::auth_check))
|
.route("/admin/auth/check", post(admin::auth_check))
|
||||||
|
// Web UI routes
|
||||||
|
.route("/ui/", get(ui::ui_index))
|
||||||
|
.route("/ui/packages/{publisher}", get(ui::ui_packages))
|
||||||
|
.route("/ui/search", get(ui::ui_search))
|
||||||
|
.route("/ui/search/results", get(ui::ui_search_results))
|
||||||
|
.route("/ui/package/{publisher}/{*fmri}", get(ui::ui_package_detail))
|
||||||
|
.route("/ui/p5i", get(ui::ui_p5i_generate))
|
||||||
|
.nest_service("/ui/static", static_dir)
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::errors::{DepotError, Result};
|
use crate::errors::{DepotError, Result};
|
||||||
use libips::fmri::Fmri;
|
use libips::fmri::Fmri;
|
||||||
use libips::repository::{FileBackend, IndexEntry, ReadableRepository};
|
use libips::repository::{FileBackend, IndexEntry, PackageInfo, ReadableRepository};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
|
@ -118,6 +118,18 @@ impl DepotRepo {
|
||||||
.map_err(DepotError::Repo)
|
.map_err(DepotError::Repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_packages(
|
||||||
|
&self,
|
||||||
|
publisher: Option<&str>,
|
||||||
|
pattern: Option<&str>,
|
||||||
|
) -> Result<Vec<PackageInfo>> {
|
||||||
|
let backend = self
|
||||||
|
.backend
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| DepotError::Server(format!("Lock poisoned: {}", e)))?;
|
||||||
|
backend.list_packages(publisher, pattern).map_err(DepotError::Repo)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_info(&self) -> Result<libips::repository::RepositoryInfo> {
|
pub fn get_info(&self) -> Result<libips::repository::RepositoryInfo> {
|
||||||
let backend = self
|
let backend = self
|
||||||
.backend
|
.backend
|
||||||
|
|
|
||||||
180
pkg6depotd/static/css/style.css
Normal file
180
pkg6depotd/static/css/style.css
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
/* pkg6depotd Web UI */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--depot-accent: #0969da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
nav.depot-nav {
|
||||||
|
background: var(--pico-card-background-color, #fff);
|
||||||
|
border-bottom: 1px solid var(--pico-muted-border-color, #dee2e6);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.depot-nav .nav-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.depot-nav .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--pico-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.depot-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.depot-nav a:hover {
|
||||||
|
color: var(--pico-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publisher cards */
|
||||||
|
.publisher-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-card {
|
||||||
|
border: 1px solid var(--pico-muted-border-color, #dee2e6);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-card h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publisher-card .meta {
|
||||||
|
color: var(--pico-muted-color, #666);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Package table */
|
||||||
|
table.packages td.pkg-name {
|
||||||
|
font-family: var(--pico-font-family-monospace, monospace);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Manifest code block */
|
||||||
|
.manifest-block {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--pico-card-background-color, #f8f9fa);
|
||||||
|
border: 1px solid var(--pico-muted-border-color, #dee2e6);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifest-block pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* P5I cart */
|
||||||
|
.p5i-cart {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
background: var(--depot-accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
display: none;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p5i-cart a {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-input {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a, .pagination span {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .current {
|
||||||
|
background: var(--depot-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail info table */
|
||||||
|
.info-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-table th {
|
||||||
|
width: 180px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTMX loading indicator */
|
||||||
|
.htmx-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator,
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dependencies list */
|
||||||
|
.dep-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-list li {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-family: var(--pico-font-family-monospace, monospace);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-type {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background: var(--pico-muted-border-color, #dee2e6);
|
||||||
|
}
|
||||||
1
pkg6depotd/static/js/htmx.min.js
vendored
Normal file
1
pkg6depotd/static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
59
pkg6depotd/templates/base.html
Normal file
59
pkg6depotd/templates/base.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="light">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Package Repository{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="/ui/static/css/style.css">
|
||||||
|
<script src="/ui/static/js/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="depot-nav">
|
||||||
|
<div class="nav-inner">
|
||||||
|
<a href="/ui/" class="brand">pkg depot</a>
|
||||||
|
<a href="/ui/">Publishers</a>
|
||||||
|
<a href="/ui/search">Search</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<div id="p5i-cart" class="p5i-cart">
|
||||||
|
<span id="p5i-count">0</span> selected —
|
||||||
|
<a href="#" id="p5i-download" onclick="downloadP5i(); return false;">Download P5I</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const p5iSelection = new Set();
|
||||||
|
function toggleP5i(checkbox, publisher, fmri) {
|
||||||
|
const key = publisher + '|' + fmri;
|
||||||
|
if (checkbox.checked) {
|
||||||
|
p5iSelection.add(key);
|
||||||
|
} else {
|
||||||
|
p5iSelection.delete(key);
|
||||||
|
}
|
||||||
|
const cart = document.getElementById('p5i-cart');
|
||||||
|
const count = document.getElementById('p5i-count');
|
||||||
|
count.textContent = p5iSelection.size;
|
||||||
|
cart.style.display = p5iSelection.size > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
function downloadP5i() {
|
||||||
|
const byPub = {};
|
||||||
|
for (const key of p5iSelection) {
|
||||||
|
const [pub, fmri] = key.split('|', 2);
|
||||||
|
if (!byPub[pub]) byPub[pub] = [];
|
||||||
|
byPub[pub].push(fmri);
|
||||||
|
}
|
||||||
|
const publishers = Object.keys(byPub);
|
||||||
|
if (publishers.length === 0) return;
|
||||||
|
const pub = publishers[0];
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('publisher', pub);
|
||||||
|
for (const fmri of byPub[pub]) {
|
||||||
|
params.append('pkg', fmri);
|
||||||
|
}
|
||||||
|
window.location.href = '/ui/p5i?' + params.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
pkg6depotd/templates/index.html
Normal file
20
pkg6depotd/templates/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Package Repository{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Package Repository</h1>
|
||||||
|
<div class="publisher-grid">
|
||||||
|
{% for pub_info in publishers %}
|
||||||
|
<a href="/ui/packages/{{ pub_info.name }}" class="publisher-card">
|
||||||
|
<h3>{{ pub_info.name }}</h3>
|
||||||
|
<div class="meta">
|
||||||
|
{{ pub_info.package_count }} packages
|
||||||
|
{% if !pub_info.updated.is_empty() %}
|
||||||
|
· Updated {{ pub_info.updated }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
56
pkg6depotd/templates/package_detail.html
Normal file
56
pkg6depotd/templates/package_detail.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ name }} - Package Detail{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ name }}</h1>
|
||||||
|
{% if let Some(summary) = summary %}
|
||||||
|
<p>{{ summary }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<table class="info-table">
|
||||||
|
<tr><th>Publisher</th><td>{{ publisher }}</td></tr>
|
||||||
|
<tr><th>Version</th><td>{{ version }}</td></tr>
|
||||||
|
{% if let Some(br) = build_release %}
|
||||||
|
<tr><th>Build Release</th><td>{{ br }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if let Some(b) = branch %}
|
||||||
|
<tr><th>Branch</th><td>{{ b }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if let Some(date) = packaging_date %}
|
||||||
|
<tr><th>Packaging Date</th><td>{{ date }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr><th>Size</th><td>{{ size }}</td></tr>
|
||||||
|
<tr><th>Compressed Size</th><td>{{ csize }}</td></tr>
|
||||||
|
<tr><th>FMRI</th><td><code>{{ fmri_str }}</code></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Install</h2>
|
||||||
|
<pre><code>pkg install {{ fmri_str }}</code></pre>
|
||||||
|
|
||||||
|
{% if !dependencies.is_empty() %}
|
||||||
|
<h2>Dependencies</h2>
|
||||||
|
<ul class="dep-list">
|
||||||
|
{% for dep in &dependencies %}
|
||||||
|
<li>
|
||||||
|
<span class="dep-type">{{ dep.dep_type }}</span>
|
||||||
|
{% if let Some(link) = dep.link %}
|
||||||
|
<a href="{{ link }}">{{ dep.fmri }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ dep.fmri }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Manifest</h2>
|
||||||
|
<button hx-get="/ui/package/{{ publisher }}/{{ fmri_encoded }}/manifest"
|
||||||
|
hx-target="#manifest-content"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#manifest-spinner">
|
||||||
|
Show Manifest
|
||||||
|
</button>
|
||||||
|
<span id="manifest-spinner" class="htmx-indicator" aria-busy="true">Loading...</span>
|
||||||
|
<div id="manifest-content"></div>
|
||||||
|
{% endblock %}
|
||||||
45
pkg6depotd/templates/packages.html
Normal file
45
pkg6depotd/templates/packages.html
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ publisher }} - Packages{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ publisher }}</h1>
|
||||||
|
<p>{{ total_packages }} packages</p>
|
||||||
|
|
||||||
|
<table class="packages" role="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px;"></th>
|
||||||
|
<th>Package</th>
|
||||||
|
<th>Version</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pkg in packages %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox"
|
||||||
|
onchange="toggleP5i(this, '{{ publisher }}', '{{ pkg.fmri_str }}')"
|
||||||
|
aria-label="Select {{ pkg.name }}">
|
||||||
|
</td>
|
||||||
|
<td class="pkg-name">
|
||||||
|
<a href="/ui/package/{{ publisher }}/{{ pkg.fmri_encoded }}">{{ pkg.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ pkg.version }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="/ui/packages/{{ publisher }}?page={{ page - 1 }}&per_page={{ per_page }}">← Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page }} of {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="/ui/packages/{{ publisher }}?page={{ page + 1 }}&per_page={{ per_page }}">Next →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
3
pkg6depotd/templates/partials/manifest.html
Normal file
3
pkg6depotd/templates/partials/manifest.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div class="manifest-block">
|
||||||
|
<pre>{{ manifest_text }}</pre>
|
||||||
|
</div>
|
||||||
28
pkg6depotd/templates/partials/search_results.html
Normal file
28
pkg6depotd/templates/partials/search_results.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% if results.is_empty() %}
|
||||||
|
<p>No packages found.</p>
|
||||||
|
{% else %}
|
||||||
|
<table class="packages" role="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px;"></th>
|
||||||
|
<th>Package</th>
|
||||||
|
<th>Publisher</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in &results %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox"
|
||||||
|
onchange="toggleP5i(this, '{{ r.publisher }}', '{{ r.fmri }}')"
|
||||||
|
aria-label="Select {{ r.name }}">
|
||||||
|
</td>
|
||||||
|
<td class="pkg-name">
|
||||||
|
<a href="/ui/package/{{ r.publisher }}/{{ r.fmri_encoded }}">{{ r.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.publisher }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
37
pkg6depotd/templates/search.html
Normal file
37
pkg6depotd/templates/search.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Search Packages{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Search Packages</h1>
|
||||||
|
|
||||||
|
<div class="search-input">
|
||||||
|
<input type="search"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search packages..."
|
||||||
|
hx-get="/ui/search/results"
|
||||||
|
hx-trigger="input changed delay:300ms, search"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-indicator="#search-spinner"
|
||||||
|
hx-include="[name='publisher']"
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !publishers.is_empty() %}
|
||||||
|
<details>
|
||||||
|
<summary>Filter by publisher</summary>
|
||||||
|
<select name="publisher">
|
||||||
|
<option value="">All publishers</option>
|
||||||
|
{% for p in &publishers %}
|
||||||
|
<option value="{{ p }}">{{ p }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span id="search-spinner" class="htmx-indicator" aria-busy="true">Searching...</span>
|
||||||
|
|
||||||
|
<div id="search-results">
|
||||||
|
<p><em>Type to search packages by name, summary, or description.</em></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Reference in a new issue