Skip to content

Commit 4856c41

Browse files
authored
feat(defi): dashboard for blob store canister (#9286)
Follow-up on #8937 to add an `http_request` endpoint serving an HTML dashboard at `/dashboard`, showing blob store metadata (total blobs, total size) and a table of all stored blobs with hash, uploader, size, insertion time, and tags. Example of rendered dashboard: <img width="1049" height="338" alt="Screenshot 2026-03-10 at 11 54 51" src="https://github.com/user-attachments/assets/4bdbf2b7-0669-4d50-9aef-b7c84911aaf1" />
1 parent dbae8fe commit 4856c41

File tree

10 files changed

+339
-0
lines changed

10 files changed

+339
-0
lines changed

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs/cross-chain/blob_store/BUILD.bazel

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ rust_library(
1212
"src/main.rs",
1313
],
1414
),
15+
compile_data = ["templates/dashboard.html"],
1516
crate_name = "blob_store_lib",
1617
deps = [
1718
# Keep sorted.
19+
"@crate_index//:askama",
1820
"@crate_index//:candid",
1921
"@crate_index//:ciborium",
2022
"@crate_index//:hex",
2123
"@crate_index//:ic-cdk",
2224
"@crate_index//:ic-stable-structures",
2325
"@crate_index//:serde",
2426
"@crate_index//:sha2",
27+
"@crate_index//:time",
2528
],
2629
)
2730

@@ -31,6 +34,7 @@ rust_test(
3134
deps = [
3235
# Keep sorted.
3336
":lib",
37+
"@crate_index//:scraper",
3438
],
3539
)
3640

@@ -41,8 +45,11 @@ rust_canister(
4145
deps = [
4246
# Keep sorted.
4347
":lib",
48+
"//packages/ic-http-types",
49+
"@crate_index//:askama",
4450
"@crate_index//:candid",
4551
"@crate_index//:ic-cdk",
52+
"@crate_index//:serde_bytes",
4653
],
4754
)
4855

rs/cross-chain/blob_store/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,20 @@ name = "blob_store_lib"
1515
path = "src/lib.rs"
1616

1717
[dependencies]
18+
askama = { workspace = true }
1819
candid = { workspace = true }
1920
ciborium = { workspace = true }
2021
hex = { workspace = true }
2122
ic-cdk = { workspace = true }
23+
ic-http-types = { path = "../../../packages/ic-http-types" }
2224
ic-stable-structures = { workspace = true }
2325
serde = { workspace = true }
26+
serde_bytes = { workspace = true }
2427
sha2 = { workspace = true }
28+
time = { workspace = true }
2529

2630
[dev-dependencies]
2731
assert_matches = { workspace = true }
2832
candid_parser = { workspace = true }
2933
pocket-ic = { path = "../../../packages/pocket-ic" }
34+
scraper = "0.17.1"

rs/cross-chain/blob_store/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,12 @@ Query metadata (uploader, insertion time, size, tags) without downloading the bl
6767
```bash
6868
icp canister call blob_store get_metadata "(\"$HASH\")"
6969
```
70+
71+
### View the dashboard
72+
73+
The dashboard can be found at http://t63gs-up777-77776-aaaba-cai.raw.localhost:8000/dashboard:
74+
* Adapt the canister ID if needed. This should be visible in the output of `icp deploy`.
75+
```
76+
Created canister blob_store with ID t63gs-up777-77776-aaaba-cai
77+
```
78+
* Note the `raw` part of the URL to bypass certification.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use crate::storage::read_blob_store;
2+
use askama::Template;
3+
4+
#[cfg(test)]
5+
mod tests;
6+
7+
mod filters {
8+
#[askama::filter_fn]
9+
pub fn timestamp_to_datetime<T: std::fmt::Display>(
10+
timestamp: T,
11+
_env: &dyn askama::Values,
12+
) -> askama::Result<String> {
13+
let input = timestamp.to_string();
14+
let ts: i128 = input
15+
.parse()
16+
.map_err(|e| askama::Error::Custom(Box::new(e)))?;
17+
let dt_offset = time::OffsetDateTime::from_unix_timestamp_nanos(ts).unwrap();
18+
let format =
19+
time::format_description::parse("[year]-[month]-[day]T[hour]:[minute]:[second]+00:00")
20+
.unwrap();
21+
Ok(dt_offset.format(&format).unwrap())
22+
}
23+
}
24+
25+
#[derive(Template)]
26+
#[template(path = "dashboard.html", whitespace = "suppress")]
27+
pub struct DashboardTemplate {
28+
pub total_blobs: u64,
29+
pub total_size_bytes: u64,
30+
pub blobs: Vec<DashboardBlob>,
31+
}
32+
33+
pub struct DashboardBlob {
34+
pub hash: String,
35+
pub uploader: String,
36+
pub size: u64,
37+
pub inserted_at_ns: u64,
38+
pub tags: Vec<String>,
39+
}
40+
41+
pub fn dashboard() -> DashboardTemplate {
42+
read_blob_store(|store| {
43+
let mut total_size_bytes = 0u64;
44+
let blobs: Vec<DashboardBlob> = store
45+
.iter_metadata()
46+
.map(|(hash, metadata)| {
47+
total_size_bytes += metadata.size;
48+
DashboardBlob {
49+
hash: hash.to_string(),
50+
uploader: metadata.uploader.to_string(),
51+
size: metadata.size,
52+
inserted_at_ns: metadata.inserted_at_ns,
53+
tags: metadata.tags.into_iter().map(|t| t.to_string()).collect(),
54+
}
55+
})
56+
.collect();
57+
DashboardTemplate {
58+
total_blobs: store.len(),
59+
total_size_bytes,
60+
blobs,
61+
}
62+
})
63+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use crate::dashboard::{DashboardBlob, DashboardTemplate};
2+
use askama::Template;
3+
use scraper::{Html, Selector};
4+
5+
#[test]
6+
fn should_render_empty_dashboard() {
7+
let dashboard = DashboardTemplate {
8+
total_blobs: 0,
9+
total_size_bytes: 0,
10+
blobs: vec![],
11+
};
12+
let html = dashboard.render().unwrap();
13+
let parsed = Html::parse_document(&html);
14+
15+
assert_has_text(&parsed, "#total-blobs > td > code", "0");
16+
assert_has_text(&parsed, "#total-size > td > code", "0");
17+
assert!(
18+
parsed
19+
.select(&Selector::parse("#blobs").unwrap())
20+
.next()
21+
.is_none(),
22+
"expected no blobs section"
23+
);
24+
}
25+
26+
#[test]
27+
fn should_render_blobs() {
28+
let dashboard = DashboardTemplate {
29+
total_blobs: 2,
30+
total_size_bytes: 300,
31+
blobs: vec![
32+
DashboardBlob {
33+
hash: "98ac5cfe873a7b42b7c98a1b3fbeff2255e340deb9c80aa9eb0bd0ba3a0d2a99"
34+
.to_string(),
35+
uploader: "principal-1".to_string(),
36+
size: 100,
37+
inserted_at_ns: 1_700_000_000_000_000_000,
38+
tags: vec!["ledger".to_string(), "u256".to_string()],
39+
},
40+
DashboardBlob {
41+
hash: "3d33f9edeae50572a42378d1eaaa29f5149543ec16268797b058156b1b575a04"
42+
.to_string(),
43+
uploader: "principal-2".to_string(),
44+
size: 200,
45+
inserted_at_ns: 1_700_000_001_000_000_000,
46+
tags: vec![],
47+
},
48+
],
49+
};
50+
let html = dashboard.render().unwrap();
51+
let parsed = Html::parse_document(&html);
52+
53+
assert_has_text(&parsed, "#total-blobs > td > code", "2");
54+
assert_has_text(&parsed, "#total-size > td > code", "300");
55+
56+
assert_row_contains(
57+
&parsed,
58+
1,
59+
"98ac5cfe873a7b42b7c98a1b3fbeff2255e340deb9c80aa9eb0bd0ba3a0d2a99",
60+
"principal-1",
61+
"100",
62+
&["ledger", "u256"],
63+
);
64+
assert_row_contains(
65+
&parsed,
66+
2,
67+
"3d33f9edeae50572a42378d1eaaa29f5149543ec16268797b058156b1b575a04",
68+
"principal-2",
69+
"200",
70+
&[],
71+
);
72+
}
73+
74+
fn assert_has_text(html: &Html, selector: &str, expected: &str) {
75+
let sel = Selector::parse(selector).unwrap();
76+
let element = html
77+
.select(&sel)
78+
.next()
79+
.unwrap_or_else(|| panic!("selector '{selector}' not found"));
80+
let text: String = element.text().collect();
81+
assert_eq!(text, expected, "selector '{selector}'");
82+
}
83+
84+
fn assert_row_contains(
85+
html: &Html,
86+
row: usize,
87+
hash: &str,
88+
uploader: &str,
89+
size: &str,
90+
tags: &[&str],
91+
) {
92+
let row_sel =
93+
Selector::parse(&format!("#blobs + table > tbody > tr:nth-child({row})")).unwrap();
94+
let row_el = html
95+
.select(&row_sel)
96+
.next()
97+
.unwrap_or_else(|| panic!("row {row} not found"));
98+
let cells: Vec<String> = row_el
99+
.select(&Selector::parse("td").unwrap())
100+
.map(|td| td.text().collect::<String>())
101+
.collect();
102+
assert_eq!(cells[0], hash, "hash mismatch in row {row}");
103+
assert_eq!(cells[1], uploader, "uploader mismatch in row {row}");
104+
assert_eq!(cells[2], size, "size mismatch in row {row}");
105+
// cells[3] is the formatted timestamp, just check it's not empty
106+
assert!(!cells[3].is_empty(), "timestamp missing in row {row}");
107+
let tag_sel = Selector::parse(".tag").unwrap();
108+
let actual_tags: Vec<String> = row_el
109+
.select(&tag_sel)
110+
.map(|el| el.text().collect::<String>())
111+
.collect();
112+
assert_eq!(
113+
actual_tags,
114+
tags.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
115+
"tags mismatch in row {row}"
116+
);
117+
}

rs/cross-chain/blob_store/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod api;
2+
pub mod dashboard;
23
pub mod query;
34
pub mod storage;
45
pub mod update;

rs/cross-chain/blob_store/src/main.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use blob_store_lib::api::{BlobMetadata, GetError, InsertError, InsertRequest};
2+
use ic_http_types as http;
23

34
#[ic_cdk::init]
45
fn init() {}
@@ -27,6 +28,27 @@ fn insert(request: InsertRequest) -> Result<String, InsertError> {
2728
.map(|hash| hash.to_string())
2829
}
2930

31+
#[ic_cdk::query(hidden = true)]
32+
fn http_request(req: http::HttpRequest) -> http::HttpResponse {
33+
if ic_cdk::api::in_replicated_execution() {
34+
ic_cdk::trap("update call rejected");
35+
}
36+
37+
match req.path() {
38+
"/dashboard" => {
39+
use askama::Template;
40+
let dashboard = blob_store_lib::dashboard::dashboard().render().unwrap();
41+
http::HttpResponseBuilder::ok()
42+
.header("Content-Type", "text/html; charset=utf-8")
43+
.with_body_and_content_length(dashboard)
44+
.build()
45+
}
46+
_ => http::HttpResponseBuilder::not_found()
47+
.with_body_and_content_length("not found")
48+
.build(),
49+
}
50+
}
51+
3052
fn main() {}
3153

3254
#[test]

rs/cross-chain/blob_store/src/storage.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ impl<M: Memory> BlobStore<M> {
7373
self.metadata.get(&hash)
7474
}
7575

76+
pub fn len(&self) -> u64 {
77+
self.store.len()
78+
}
79+
80+
pub fn is_empty(&self) -> bool {
81+
self.store.is_empty()
82+
}
83+
84+
pub fn iter_metadata(&self) -> impl Iterator<Item = (Hash, Metadata)> + '_ {
85+
self.metadata.iter()
86+
}
87+
7688
pub fn insert<B: Into<Blob>>(&mut self, blob: B, metadata: Metadata) -> Option<Hash> {
7789
let Blob { data, hash } = blob.into();
7890
if self.store.contains_key(&hash) {

0 commit comments

Comments
 (0)