Commit

Author:

Hash:

Timestamp:

+326 -73 +/-9 browse

Kevin Schoon [me@kevinschoon.com]

792cc943f763302db3e45b66b1d2634e856363a1

Tue, 22 Apr 2025 11:31:54 +0000 (1 month ago)

implement more ui features, wip
1diff --git a/examples/server.rs b/examples/server.rs
2index 1299471..3696881 100644
3--- a/examples/server.rs
4+++ b/examples/server.rs
5 @@ -51,7 +51,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
6 }),
7 )
8 .layer(NormalizePathLayer::trim_trailing_slash())
9- .fallback_service(middleware.layer(web_interface));
10+ .fallback_service(
11+ tower::util::MapRequestLayer::new(papyri::axum::extract_namespace_optional)
12+ .layer(web_interface),
13+ );
14
15 axum::serve(listener, router).await?;
16 Ok(())
17 diff --git a/src/axum/mod.rs b/src/axum/mod.rs
18index f7f09d1..687136b 100644
19--- a/src/axum/mod.rs
20+++ b/src/axum/mod.rs
21 @@ -33,7 +33,7 @@ pub fn read_ns(uri: &str) -> Option<(Namespace, String)> {
22 let mut components: Vec<String> = uri.split("/").map(|cmp| cmp.to_string()).collect();
23 components.reverse();
24 let stop = components.iter().enumerate().find_map(|(i, entry)| {
25- if *entry == "blobs" || *entry == "manifests" || *entry == "tags" {
26+ if *entry == "blobs" || *entry == "manifests" || *entry == "tags" || *entry == "tag" || *entry == "index" {
27 Some(i + 1)
28 } else {
29 None
30 @@ -67,6 +67,19 @@ pub fn extract_namespace(mut req: Request<axum::body::Body>) -> Request<axum::bo
31 req
32 }
33
34+ pub fn extract_namespace_optional(mut req: Request<axum::body::Body>) -> Request<axum::body::Body> {
35+ let uri_str = req.uri().to_string();
36+ let extensions = req.extensions_mut();
37+ if let Some((namespace, route)) = read_ns(&uri_str) {
38+ extensions.insert(Some(namespace));
39+ let uri = req.uri_mut();
40+ *uri = Uri::from_str(&route).unwrap();
41+ } else {
42+ extensions.insert(None::<Namespace>);
43+ }
44+ req
45+ }
46+
47 // async fn propagate_header<B>(req: Request<B>, next: Next<B>) -> Response {
48 async fn global_headers(req: Request, next: Next) -> Response {
49 let mut res = next.run(req).await;
50 diff --git a/src/axum/web/link_tree.rs b/src/axum/web/link_tree.rs
51index d6f32c4..4a7089a 100644
52--- a/src/axum/web/link_tree.rs
53+++ b/src/axum/web/link_tree.rs
54 @@ -1,15 +1,27 @@
55 use crate::tree::Node;
56
57- pub fn generate<F>(root: &Node, link_builder: &F) -> String
58+ pub fn generate<F>(root: &Node, link_builder: &F, selected: Option<&String>) -> String
59 where
60- F: Fn(&str) -> String
61+ F: Fn(&str) -> String,
62 {
63 let mut start = format!("<ul id='{}'>", root.name);
64 if root.name != "/" {
65- start.push_str(&format!("<a href='{}'>{}</a>", link_builder(&root.name), root.name));
66+ let mut class = String::default();
67+ if selected.is_some_and(|selected| root.name == *selected) {
68+ class.push_str("active");
69+ }
70+ start.push_str(&format!(
71+ "<a class='{}' href='{}'>{}</a>",
72+ class,
73+ link_builder(&root.name),
74+ root.name
75+ ));
76 }
77 for child in root.children.iter() {
78- start.push_str(&format!("<li>{}</li>", generate(child, link_builder)));
79+ start.push_str(&format!(
80+ "<li>{}</li>",
81+ generate(child, link_builder, selected)
82+ ));
83 }
84 start.push_str("</ul>");
85 start
86 diff --git a/src/axum/web/router.rs b/src/axum/web/router.rs
87index 02b2c26..c9aae33 100644
88--- a/src/axum/web/router.rs
89+++ b/src/axum/web/router.rs
90 @@ -2,7 +2,7 @@ use std::sync::Arc;
91
92 use axum::{
93 Extension, Router,
94- extract::State,
95+ extract::{Path, State},
96 response::{Html, Response},
97 routing::get,
98 };
99 @@ -11,7 +11,10 @@ use http::header::CONTENT_TYPE;
100
101 use crate::{Namespace, axum::AppState, oci_interface::OciInterface, storage::Storage};
102
103- use super::{link_tree, template::{Repository, RepositoryIndex, LOGO, STYLESHEET}};
104+ use super::{
105+ link_tree,
106+ template::{self, LOGO, Repository, RepositoryIndex, STYLESHEET},
107+ };
108
109 pub async fn logo() -> Response {
110 let mut res = Response::new(Bytes::from_static(LOGO).into());
111 @@ -28,30 +31,74 @@ pub async fn stylesheet() -> Response {
112 }
113
114 pub async fn index(
115- // Extension(namespace): Extension<Option<Namespace>>,
116+ Extension(namespace): Extension<Option<Namespace>>,
117+ current_tag: Option<Path<String>>,
118 State(state): State<Arc<AppState>>,
119 ) -> Result<Html<String>, crate::axum::error::Error> {
120- let namespaces = state.oci.list_namespaces().await?;
121+ let namespaces = state.oci.list_namespaces(None).await?;
122 let ns_str: Vec<String> = namespaces.iter().map(|ns| ns.to_string()).collect(); // FIXME
123 let root = crate::tree::Builder::default().build(&ns_str);
124- let link_builder = |name: &str| {
125- name.to_string()
126- };
127- let tree = link_tree::generate(&root, &link_builder);
128- let template = RepositoryIndex {
129+ let link_builder = |name: &str| format!("/{}/index", name);
130+ let tree = link_tree::generate(
131+ &root,
132+ &link_builder,
133+ namespace.as_ref().map(|ns| ns.to_string()).as_ref(),
134+ );
135+ let mut template = RepositoryIndex {
136 title: "Repositories",
137- tree: &tree
138+ tree: &tree,
139+ namespace: None,
140+ manifest: None,
141+ tags: Vec::new(),
142 };
143
144- Ok(Html::from(template.to_string()))
145- }
146-
147- pub async fn repository(
148- Extension(namespace): Extension<Namespace>,
149- ) -> Result<Html<String>, crate::axum::error::Error> {
150- let template = Repository {
151- title: "REPOSITORY A",
152+ if let Some(ns) = namespace {
153+ let tags = state
154+ .oci
155+ .list_tags(&ns, None, None)
156+ .await
157+ .map_or(Vec::new(), |tags| {
158+ tags.tags()
159+ .iter()
160+ .map(|tag| template::Tag {
161+ name: tag.clone(),
162+ namespace: ns.to_string(),
163+ selected: current_tag
164+ .as_ref()
165+ .is_some_and(|current_tag| current_tag.as_str() == tag.as_str()),
166+ })
167+ .collect()
168+ });
169+ template.tags = tags;
170+ if let Some(current_tag) = current_tag {
171+ let manifest = state
172+ .oci
173+ .read_manifest(&ns, &crate::TagOrDigest::Tag(current_tag.to_string()))
174+ .await?;
175+ template.manifest = Some(template::Manifest {
176+ pretty: manifest.to_string_pretty().unwrap(),
177+ created_at: manifest.annotations().as_ref().and_then(|annotations| {
178+ annotations
179+ .get(oci_spec::image::ANNOTATION_CREATED)
180+ .map(|value| value.to_string())
181+ }),
182+ source: None,
183+ upstream_url: None,
184+ layers: manifest
185+ .layers()
186+ .iter()
187+ .map(|layer| template::Layer {
188+ digest: layer.digest().to_string(),
189+ size: layer.size(),
190+ })
191+ .collect(),
192+ });
193+ }
194+ template.namespace = Some(template::Namespace {
195+ name: ns.to_string(),
196+ });
197 };
198+
199 Ok(Html::from(template.to_string()))
200 }
201
202 @@ -59,35 +106,10 @@ pub fn router(storage: &Storage) -> Router {
203 let store = Arc::new(storage.inner());
204 Router::new()
205 .route("/", get(index))
206+ .route("/index", get(index))
207+ .route("/tag/{name}", get(index))
208 .route("/style.css", get(stylesheet))
209 .route("/logo.png", get(logo))
210- // // .route("/{name}/blobs/{digest}", head(crate::handlers::stat_blob))
211- // // .route(
212- // // "/{name}/manifests/{reference}",
213- // // get(crate::handlers::read_manifest),
214- // // )
215- // // .route(
216- // // "/{name}/manifests/{reference}",
217- // // head(crate::handlers::read_manifest),
218- // // )
219- // // .route("/{name}/blobs/uploads", post(crate::handlers::initiate_blob))
220- // // .route(
221- // // "/{name}/blobs/uploads/{reference}",
222- // // patch(crate::handlers::write_blob),
223- // // )
224- // // .route(
225- // // "/{name}/manifests/{reference}",
226- // // put(crate::handlers::write_manifest),
227- // // )
228- // // .route("/{name}/tags/list", get(crate::handlers::read_tags))
229- // // .route(
230- // // "/{name}/manifests/{reference}",
231- // // delete(crate::handlers::delete_manifest),
232- // // )
233- // // .route(
234- // // "/{name}/blobs/{digest}",
235- // // delete(crate::handlers::delete_blob),
236- // // )
237 .with_state(Arc::new(AppState {
238 oci: OciInterface { storage: store },
239 }))
240 diff --git a/src/axum/web/template.rs b/src/axum/web/template.rs
241index 4d02675..f6f4019 100644
242--- a/src/axum/web/template.rs
243+++ b/src/axum/web/template.rs
244 @@ -1,13 +1,70 @@
245+ use std::fmt::Display;
246+
247 use askama::Template;
248+ use serde::Serialize;
249
250 pub const STYLESHEET: &[u8] = include_bytes!("../../../templates/style.css");
251 pub const LOGO: &[u8] = include_bytes!("../../../assets/logo.png");
252
253+ #[derive(Debug, Serialize)]
254+ pub struct Tag {
255+ pub name: String,
256+ pub namespace: String,
257+ pub selected: bool,
258+ }
259+
260+ impl Display for Tag {
261+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262+ write!(f, "{}", self.name)
263+ }
264+ }
265+
266+ #[derive(Debug, Serialize)]
267+ pub struct Namespace {
268+ pub name: String,
269+ }
270+
271+ impl Display for Namespace {
272+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273+ write!(f, "{}", self.name)
274+ }
275+ }
276+
277+ #[derive(Debug, Serialize)]
278+ pub struct Layer {
279+ pub digest: String,
280+ pub size: u64,
281+ }
282+
283+ impl Display for Layer {
284+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285+ write!(f, "{}", self.digest)
286+ }
287+ }
288+
289+ #[derive(Debug, Serialize)]
290+ pub struct Manifest {
291+ pub pretty: String,
292+ pub layers: Vec<Layer>,
293+ pub created_at: Option<String>,
294+ pub source: Option<String>,
295+ pub upstream_url: Option<String>,
296+ }
297+
298+ impl Display for Manifest {
299+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300+ todo!()
301+ }
302+ }
303+
304 #[derive(Template)]
305 #[template(path = "index.html")]
306 pub struct RepositoryIndex<'a> {
307 pub title: &'a str,
308 pub tree: &'a str,
309+ pub namespace: Option<Namespace>,
310+ pub tags: Vec<Tag>,
311+ pub manifest: Option<Manifest>,
312 }
313
314 #[derive(Template)]
315 diff --git a/src/oci_interface.rs b/src/oci_interface.rs
316index 09df8e2..4c1538f 100644
317--- a/src/oci_interface.rs
318+++ b/src/oci_interface.rs
319 @@ -357,18 +357,26 @@ impl OciInterface {
320 }
321 }
322
323- pub async fn list_namespaces(&self) -> Result<Vec<Namespace>, Error> {
324+ pub async fn list_namespaces(&self, ns: Option<&Namespace>) -> Result<Vec<Namespace>, Error> {
325+ let addr = if let Some(namespace) = ns {
326+ &Address::Repository {
327+ namespace: namespace.clone(),
328+ }
329+ } else {
330+ &Address::RepositoriesRoot
331+ };
332 let results = self
333 .storage
334- .find(&Address::RepositoriesRoot, Some(Kind::Repository))
335+ .find(addr, Some(Kind::Repository))
336 .await
337 .map_err(Error::Storage)?;
338- let namespaces: Vec<Namespace> = results.iter().filter_map(|addr| {
339- match addr {
340+ let namespaces: Vec<Namespace> = results
341+ .iter()
342+ .filter_map(|addr| match addr {
343 Address::Repository { namespace } => Some(namespace.clone()),
344- _ => None
345- }
346- }).collect();
347+ _ => None,
348+ })
349+ .collect();
350 Ok(namespaces)
351 }
352 }
353 diff --git a/templates/base.html b/templates/base.html
354index ed2bdc2..9cb9954 100644
355--- a/templates/base.html
356+++ b/templates/base.html
357 @@ -7,16 +7,15 @@
358 </head>
359 <body>
360 <header>
361- <!--
362- <ul class="nav-links">
363- <li><a href="#home">Home</a></li>
364- </ul>
365- -->
366+ <nav class="navbar">
367+ <div class="logo-container">
368+ <a href="/"><img src="/logo.png" alt="Logo" class="logo"></a>
369+ </div>
370+ <ul class="nav-list"></ul>
371+ </nav>
372 </header>
373 <main>
374- <div class="content">
375 {% block content %}{% endblock %}
376- </div>
377 </main>
378 <footer class="sticky">
379 <p>Papyri</p>
380 diff --git a/templates/index.html b/templates/index.html
381index 10f4e59..7654879 100644
382--- a/templates/index.html
383+++ b/templates/index.html
384 @@ -1,9 +1,45 @@
385 {% extends "base.html" %}
386 {% block content %}
387- <div class="tree-display">
388+ {% if let Some(manifest) = manifest %}
389+ <section class="pane details">
390+ <span id="total-size"></span>
391+
392+ <p>Created At:</p>
393+ <span class="badge">hello</span>
394+
395+ <p>Revision:</p>
396+ <span id="revision"></span>
397+
398+ <p>Url:</p>
399+ <span id="url"></span>
400+ </section>
401+ {% endif %}
402+ <section class="flex">
403+ <section class="pane tree">
404 <ul class="tree">
405 {{ tree | safe }}
406 </ul>
407- </div>
408- <a href="/"><img class="logo" src="/logo.png" alt="Logo"></a>
409+ </section>
410+ <section class="pane tags">
411+ <ul>
412+ {% for tag in tags %}
413+ <li> <code class="tag"><a href="/{{tag.namespace}}/tag/{{tag}}">{{ tag }}</a></code> </li>
414+ {% endfor %}
415+ </ul>
416+ </section>
417+ <section class="pane manifest">
418+ {% if let Some(manifest) = manifest %}
419+ <h2>Manifest</h2>
420+ <section class="pane">
421+ <pre>{{ manifest.pretty | safe }}</pre>
422+ </section>
423+ <h2>Layers</h2>
424+ {% for layer in manifest.layers %}
425+ <section class="pane">
426+ {{ layer }}
427+ </section>
428+ {% endfor %}
429+ {% endif %}
430+ </section>
431+ </section>
432 {% endblock %}
433 diff --git a/templates/style.css b/templates/style.css
434index 8ad41b1..ff23674 100644
435--- a/templates/style.css
436+++ b/templates/style.css
437 @@ -4,6 +4,36 @@
438 box-sizing: border-box;
439 }
440
441+ a {
442+ text-decoration: none;
443+ }
444+
445+ a.active {
446+ text-decoration: underline;
447+ }
448+
449+ ul {
450+ list-style-type: none;
451+ }
452+
453+ code.tag {
454+ font-size: large;
455+ font-weight: bold
456+ }
457+
458+ section.pane.manifest >h2 {
459+ float:right;
460+ }
461+
462+ .badge {
463+ background-color: #f7dc6f; /* yellow */
464+ color: #333;
465+ padding: 10px 20px;
466+ border-radius: 50%;
467+ font-size: 16px;
468+ font-weight: bold;
469+ }
470+
471 .logo {
472 border-radius: 50%;
473 background-color: #333;
474 @@ -12,13 +42,42 @@
475 0 2px 4px rgba(0, 0, 0, 0.25);
476 }
477
478- .content {
479+ section.flex {
480 display: flex;
481- width: 80%;
482 padding: 2em;
483 margin: 3em auto;
484 }
485
486+ section.pane {
487+ margin-right: 10px;
488+ padding: 20px;
489+ border: 1px solid #ccc;
490+ border-radius: 5px;
491+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
492+ width: 100%;
493+ overflow: scroll;
494+ }
495+
496+ section.details {
497+ background-color: #f0f0f0;
498+ border-radius: 10px;
499+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
500+ font-size: 18px;
501+ color: #333;
502+ }
503+
504+ section.tree {
505+ width: 20%;
506+ }
507+
508+ section.tags {
509+ width: 10%;
510+ }
511+
512+ section.manifest {
513+ width: 70%;
514+ }
515+
516 footer {
517 position: absolute;
518 bottom: 0;
519 @@ -42,13 +101,57 @@ footer {
520 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
521 }
522
523+ .navbar {
524+ display: flex;
525+ justify-content: space-between;
526+ align-items: center;
527+ padding: 0.5rem;
528+ border-bottom: 1px solid #ccc;
529+ }
530+
531+ .logo-container {
532+ display: flex;
533+ align-items: center;
534+ margin-right: 1rem;
535+ }
536+
537+ .logo {
538+ height: 10em;
539+ width: auto;
540+ margin-right: 0.5rem;
541+ }
542+
543+ .nav-list {
544+ list-style: none;
545+ margin: 0;
546+ padding: 0;
547+ display: flex;
548+ align-items: center;
549+ }
550+
551+ .nav-list li {
552+ margin-right: 1rem;
553+ }
554+
555+ .nav-list a {
556+ color: #333;
557+ text-decoration: none;
558+ transition: color 0.2s ease-in-out;
559+ }
560+
561+ .nav-list a:hover {
562+ color: #555;
563+ }
564+
565+
566+
567 /*
568 Tree structure using CSS:
569 http://stackoverflow.com/questions/14922247/how-to-get-a-tree-in-html-using-pure-css
570 */
571
572 .tree, .tree ul {
573- font: 32px Helvetica, Arial, sans-serif;
574+ font: 16px monospace;
575 list-style-type: none;
576 margin-left: 0 0 0 10px;
577 padding: 0;
578 @@ -70,7 +173,7 @@ footer {
579
580 /* horizontal line on inner list items */
581 .tree li::before{
582- border-top: 3px solid #999;
583+ border-top: 1px solid #999;
584 top: 10px;
585 width: 10px;
586 height: 0;
587 @@ -78,7 +181,7 @@ footer {
588
589 /* vertical line on list items */
590 .tree li:after{
591- border-left: 3px solid #999;
592+ border-left: 1px solid #999;
593 height: 100%;
594 width: 0px;
595 top: -10px;