From 9175732f3e35f4ca2f1353451d68840c2297293a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 16 Mar 2026 14:42:12 -0700 Subject: [PATCH] Serve Go volume server UI assets --- seaweed-volume/src/main.rs | 2 + seaweed-volume/src/server/handlers.rs | 36 +- seaweed-volume/src/server/heartbeat.rs | 1 + seaweed-volume/src/server/mod.rs | 1 + seaweed-volume/src/server/ui.rs | 497 +++++++++++++++++++++ seaweed-volume/src/server/volume_server.rs | 2 + seaweed-volume/src/server/write_queue.rs | 1 + seaweed-volume/tests/http_integration.rs | 33 ++ 8 files changed, 551 insertions(+), 22 deletions(-) create mode 100644 seaweed-volume/src/server/ui.rs diff --git a/seaweed-volume/src/main.rs b/seaweed-volume/src/main.rs index 750f7b6b1..b9c766988 100644 --- a/seaweed-volume/src/main.rs +++ b/seaweed-volume/src/main.rs @@ -41,6 +41,7 @@ fn main() { .init(); let config = config::parse_cli(); + seaweed_volume::server::server_stats::init_process_start(); let cpu_profile = match CpuProfileSession::start(&config) { Ok(session) => session, Err(e) => { @@ -333,6 +334,7 @@ async fn run( ), read_mode: config.read_mode, master_url, + master_urls: config.masters.clone(), self_url, http_client, outgoing_http_scheme, diff --git a/seaweed-volume/src/server/handlers.rs b/seaweed-volume/src/server/handlers.rs index 42d4671d3..ebde2b0d2 100644 --- a/seaweed-volume/src/server/handlers.rs +++ b/seaweed-volume/src/server/handlers.rs @@ -9,7 +9,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use axum::body::Body; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::http::{header, HeaderMap, Method, Request, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; @@ -2404,26 +2404,20 @@ pub async fn stats_disk_handler( // ============================================================================ pub async fn favicon_handler() -> Response { - // Return a minimal valid ICO (1x1 transparent) - let ico = include_bytes!("favicon.ico"); - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "image/x-icon")], - ico.as_ref(), - ) - .into_response() + let asset = super::ui::favicon_asset(); + (StatusCode::OK, [(header::CONTENT_TYPE, asset.content_type)], asset.bytes).into_response() } -pub async fn static_asset_handler() -> Response { - // Return a minimal valid PNG (1x1 transparent) - let png: &[u8] = &[ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, - 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x62, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, - 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, - ]; - (StatusCode::OK, [(header::CONTENT_TYPE, "image/png")], png).into_response() +pub async fn static_asset_handler(Path(path): Path) -> Response { + match super::ui::lookup_static_asset(&path) { + Some(asset) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, asset.content_type)], + asset.bytes, + ) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } } pub async fn ui_handler( @@ -2440,9 +2434,7 @@ pub async fn ui_handler( } drop(guard); - let html = r#" -SeaweedFS Volume Server -

SeaweedFS Volume Server

Rust implementation

"#; + let html = super::ui::render_volume_server_html(&state); ( StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], diff --git a/seaweed-volume/src/server/heartbeat.rs b/seaweed-volume/src/server/heartbeat.rs index 29682e698..532aaf106 100644 --- a/seaweed-volume/src/server/heartbeat.rs +++ b/seaweed-volume/src/server/heartbeat.rs @@ -673,6 +673,7 @@ mod tests { s3_tier_registry: std::sync::RwLock::new(S3TierRegistry::new()), read_mode: ReadMode::Local, master_url: String::new(), + master_urls: Vec::new(), self_url: String::new(), http_client: reqwest::Client::new(), outgoing_http_scheme: "http".to_string(), diff --git a/seaweed-volume/src/server/mod.rs b/seaweed-volume/src/server/mod.rs index 637a4f426..6103b4980 100644 --- a/seaweed-volume/src/server/mod.rs +++ b/seaweed-volume/src/server/mod.rs @@ -7,5 +7,6 @@ pub mod memory_status; pub mod profiling; pub mod request_id; pub mod server_stats; +pub mod ui; pub mod volume_server; pub mod write_queue; diff --git a/seaweed-volume/src/server/ui.rs b/seaweed-volume/src/server/ui.rs new file mode 100644 index 000000000..4bc2dfa72 --- /dev/null +++ b/seaweed-volume/src/server/ui.rs @@ -0,0 +1,497 @@ +use std::fmt::Write as _; + +use crate::server::server_stats; +use crate::server::volume_server::VolumeServerState; +use crate::storage::store::Store; + +pub struct EmbeddedAsset { + pub content_type: &'static str, + pub bytes: &'static [u8], +} + +struct UiDiskRow { + dir: String, + disk_type: String, + all: u64, + free: u64, + used: u64, +} + +struct UiVolumeRow { + id: u32, + collection: String, + disk_type: String, + size: u64, + file_count: i64, + delete_count: i64, + deleted_byte_count: u64, + ttl: String, + read_only: bool, + version: u32, + remote_storage_name: String, + remote_storage_key: String, +} + +struct UiEcShardRow { + shard_id: u8, + size: u64, +} + +struct UiEcVolumeRow { + volume_id: u32, + collection: String, + size: u64, + shards: Vec, + created_at: String, +} + +pub fn favicon_asset() -> EmbeddedAsset { + EmbeddedAsset { + content_type: "image/x-icon", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/favicon.ico" + )), + } +} + +pub fn lookup_static_asset(path: &str) -> Option { + let path = path.trim_start_matches('/'); + let asset = match path { + "bootstrap/3.3.1/css/bootstrap.min.css" => EmbeddedAsset { + content_type: "text/css; charset=utf-8", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/bootstrap/3.3.1/css/bootstrap.min.css" + )), + }, + "bootstrap/3.3.1/fonts/glyphicons-halflings-regular.eot" => EmbeddedAsset { + content_type: "application/vnd.ms-fontobject", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.eot" + )), + }, + "bootstrap/3.3.1/fonts/glyphicons-halflings-regular.svg" => EmbeddedAsset { + content_type: "image/svg+xml", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.svg" + )), + }, + "bootstrap/3.3.1/fonts/glyphicons-halflings-regular.ttf" => EmbeddedAsset { + content_type: "font/ttf", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.ttf" + )), + }, + "bootstrap/3.3.1/fonts/glyphicons-halflings-regular.woff" => EmbeddedAsset { + content_type: "font/woff", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/bootstrap/3.3.1/fonts/glyphicons-halflings-regular.woff" + )), + }, + "images/folder.gif" => EmbeddedAsset { + content_type: "image/gif", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/images/folder.gif" + )), + }, + "javascript/jquery-3.6.0.min.js" => EmbeddedAsset { + content_type: "application/javascript; charset=utf-8", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/javascript/jquery-3.6.0.min.js" + )), + }, + "javascript/jquery-sparklines/2.1.2/jquery.sparkline.min.js" => EmbeddedAsset { + content_type: "application/javascript; charset=utf-8", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/javascript/jquery-sparklines/2.1.2/jquery.sparkline.min.js" + )), + }, + "seaweed50x50.png" => EmbeddedAsset { + content_type: "image/png", + bytes: include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../weed/static/seaweed50x50.png" + )), + }, + _ => return None, + }; + Some(asset) +} + +pub fn render_volume_server_html(state: &VolumeServerState) -> String { + let counters = server_stats::snapshot(); + let (disk_rows, volume_rows, remote_volume_rows, ec_volume_rows) = { + let store = state.store.read().unwrap(); + collect_ui_data(&store) + }; + + let masters = if state.master_urls.is_empty() { + "[]".to_string() + } else { + format!("[{}]", state.master_urls.join(" ")) + }; + let uptime = server_stats::uptime_string(); + let read_week = join_i64(&counters.read_requests.week_counter.to_list()); + let read_day = join_i64(&counters.read_requests.day_counter.to_list()); + let read_hour = join_i64(&counters.read_requests.hour_counter.to_list()); + let read_minute = join_i64(&counters.read_requests.minute_counter.to_list()); + + let mut disk_rows_html = String::new(); + for disk in &disk_rows { + let _ = write!( + disk_rows_html, + "{}{}{}{}{:.2}%", + escape_html(&disk.dir), + escape_html(&disk.disk_type), + bytes_to_human_readable(disk.all), + bytes_to_human_readable(disk.free), + percent_from(disk.all, disk.used), + ); + } + + let mut volume_rows_html = String::new(); + for volume in &volume_rows { + let _ = write!( + volume_rows_html, + "{}{}{}{}{}{} / {}{}{}{}", + volume.id, + escape_html(&volume.collection), + escape_html(&volume.disk_type), + bytes_to_human_readable(volume.size), + volume.file_count, + volume.delete_count, + bytes_to_human_readable(volume.deleted_byte_count), + escape_html(&volume.ttl), + volume.read_only, + volume.version, + ); + } + + let remote_section = if remote_volume_rows.is_empty() { + String::new() + } else { + let mut remote_rows_html = String::new(); + for volume in &remote_volume_rows { + let _ = write!( + remote_rows_html, + "{}{}{}{}{} / {}{}{}", + volume.id, + escape_html(&volume.collection), + bytes_to_human_readable(volume.size), + volume.file_count, + volume.delete_count, + bytes_to_human_readable(volume.deleted_byte_count), + escape_html(&volume.remote_storage_name), + escape_html(&volume.remote_storage_key), + ); + } + format!( + r#"
+

Remote Volumes

+ + + + + + + + + + + + + {} +
IdCollectionSizeFilesTrashRemoteKey
+
"#, + remote_rows_html + ) + }; + + let ec_section = if ec_volume_rows.is_empty() { + String::new() + } else { + let mut ec_rows_html = String::new(); + for ec in &ec_volume_rows { + let mut shard_labels = String::new(); + for shard in &ec.shards { + let _ = write!( + shard_labels, + "{}: {}", + shard.shard_id, + bytes_to_human_readable(shard.size) + ); + } + let _ = write!( + ec_rows_html, + "{}{}{}{}{}", + ec.volume_id, + escape_html(&ec.collection), + bytes_to_human_readable(ec.size), + shard_labels, + escape_html(&ec.created_at), + ); + } + format!( + r#"
+

Erasure Coding Shards

+ + + + + + + + + + + {} +
IdCollectionTotal SizeShard DetailsCreatedAt
+
"#, + ec_rows_html + ) + }; + + format!( + r#" + + + SeaweedFS {version} + + + + + + + +
+ + +
+
+

Disk Stats

+ + + + + + + + + + + {disk_rows_html} +
PathDiskTotalFreeUsage
+
+ +
+

System Stats

+ + + + + + + +
Masters{masters}
Weekly # ReadRequests{read_week}
Daily # ReadRequests{read_day}
Hourly # ReadRequests{read_hour}
Last Minute # ReadRequests{read_minute}
Up Time{uptime}
+
+
+ +
+

Volumes

+ + + + + + + + + + + + + + + {volume_rows_html} +
IdCollectionDiskData SizeFilesTrashTTLReadOnlyVersion
+
+ + {remote_section} + {ec_section} +
+ +"#, + version = escape_html(crate::version::version()), + disk_rows_html = disk_rows_html, + masters = escape_html(&masters), + read_week = read_week, + read_day = read_day, + read_hour = read_hour, + read_minute = read_minute, + uptime = escape_html(&uptime), + volume_rows_html = volume_rows_html, + remote_section = remote_section, + ec_section = ec_section, + ) +} + +fn collect_ui_data(store: &Store) -> (Vec, Vec, Vec, Vec) { + let mut disk_rows = Vec::new(); + let mut volumes = Vec::new(); + let mut remote_volumes = Vec::new(); + let mut ec_volumes = Vec::new(); + + for loc in &store.locations { + let dir = absolute_display_path(&loc.directory); + let (all, free) = crate::storage::disk_location::get_disk_stats(&dir); + disk_rows.push(UiDiskRow { + dir, + disk_type: loc.disk_type.to_string(), + all, + free, + used: all.saturating_sub(free), + }); + + for (_, volume) in loc.volumes() { + let (remote_storage_name, remote_storage_key) = volume.remote_storage_name_key(); + let row = UiVolumeRow { + id: volume.id.0, + collection: volume.collection.clone(), + disk_type: loc.disk_type.to_string(), + size: volume.content_size(), + file_count: volume.file_count(), + delete_count: volume.deleted_count(), + deleted_byte_count: volume.deleted_size(), + ttl: volume.super_block.ttl.to_string(), + read_only: volume.is_read_only(), + version: volume.version().0 as u32, + remote_storage_name, + remote_storage_key, + }; + if row.remote_storage_name.is_empty() { + volumes.push(row); + } else { + remote_volumes.push(row); + } + } + + for (_, ec_volume) in loc.ec_volumes() { + let mut shards = Vec::new(); + let mut total_size = 0u64; + let mut created_at = String::from("-"); + for shard in ec_volume.shards.iter().flatten() { + let shard_size = shard.file_size().max(0) as u64; + total_size = total_size.saturating_add(shard_size); + shards.push(UiEcShardRow { + shard_id: shard.shard_id, + size: shard_size, + }); + if created_at == "-" { + if let Ok(metadata) = std::fs::metadata(shard.file_name()) { + if let Ok(modified) = metadata.modified() { + let ts: chrono::DateTime = modified.into(); + created_at = ts.format("%Y-%m-%d %H:%M").to_string(); + } + } + } + } + let preferred_size = ec_volume.dat_file_size.max(0) as u64; + ec_volumes.push(UiEcVolumeRow { + volume_id: ec_volume.volume_id.0, + collection: ec_volume.collection.clone(), + size: preferred_size.max(total_size), + shards, + created_at, + }); + } + } + + disk_rows.sort_by(|left, right| left.dir.cmp(&right.dir)); + volumes.sort_by_key(|row| row.id); + remote_volumes.sort_by_key(|row| row.id); + ec_volumes.sort_by_key(|row| row.volume_id); + + (disk_rows, volumes, remote_volumes, ec_volumes) +} + +fn absolute_display_path(path: &str) -> String { + let p = std::path::Path::new(path); + if p.is_absolute() { + return path.to_string(); + } + std::env::current_dir() + .map(|cwd| cwd.join(p).to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string()) +} + +fn join_i64(values: &[i64]) -> String { + values + .iter() + .map(std::string::ToString::to_string) + .collect::>() + .join(",") +} + +fn percent_from(total: u64, part: u64) -> f64 { + if total == 0 { + return 0.0; + } + (part as f64 / total as f64) * 100.0 +} + +fn bytes_to_human_readable(bytes: u64) -> String { + const UNIT: u64 = 1024; + if bytes < UNIT { + return format!("{} B", bytes); + } + + let mut div = UNIT; + let mut exp = 0usize; + let mut n = bytes / UNIT; + while n >= UNIT { + div *= UNIT; + n /= UNIT; + exp += 1; + } + + format!("{:.2} {}iB", bytes as f64 / div as f64, ["K", "M", "G", "T", "P", "E"][exp]) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + diff --git a/seaweed-volume/src/server/volume_server.rs b/seaweed-volume/src/server/volume_server.rs index fc72ae79f..ca890a76d 100644 --- a/seaweed-volume/src/server/volume_server.rs +++ b/seaweed-volume/src/server/volume_server.rs @@ -78,6 +78,8 @@ pub struct VolumeServerState { pub read_mode: ReadMode, /// First master address for volume lookups (e.g., "localhost:9333"). pub master_url: String, + /// Seed master addresses for UI rendering. + pub master_urls: Vec, /// This server's own address (ip:port) for filtering self from lookup results. pub self_url: String, /// HTTP client for proxy requests and master lookups. diff --git a/seaweed-volume/src/server/write_queue.rs b/seaweed-volume/src/server/write_queue.rs index e0ecca8cd..6986f7ab9 100644 --- a/seaweed-volume/src/server/write_queue.rs +++ b/seaweed-volume/src/server/write_queue.rs @@ -197,6 +197,7 @@ mod tests { ), read_mode: crate::config::ReadMode::Local, master_url: String::new(), + master_urls: Vec::new(), self_url: String::new(), http_client: reqwest::Client::new(), outgoing_http_scheme: "http".to_string(), diff --git a/seaweed-volume/tests/http_integration.rs b/seaweed-volume/tests/http_integration.rs index 3ec2d73d7..478df3e76 100644 --- a/seaweed-volume/tests/http_integration.rs +++ b/seaweed-volume/tests/http_integration.rs @@ -82,6 +82,7 @@ fn test_state_with_signing_key(signing_key: Vec) -> (Arc, ), read_mode: seaweed_volume::config::ReadMode::Local, master_url: String::new(), + master_urls: Vec::new(), self_url: String::new(), http_client: reqwest::Client::new(), outgoing_http_scheme: "http".to_string(), @@ -532,4 +533,36 @@ async fn admin_router_can_expose_ui_with_explicit_override() { // UI handler does JWT check inside but read_signing_key is empty in this test, // so it returns 200 (auth is only enforced when read key is set) assert_eq!(response.status(), StatusCode::OK); + let body = body_bytes(response).await; + let html = String::from_utf8(body).unwrap(); + assert!(html.contains("Disk Stats")); + assert!(html.contains("System Stats")); + assert!(html.contains("Volumes")); +} + +#[tokio::test] +async fn admin_router_serves_volume_ui_static_assets() { + let (state, _tmp) = test_state(); + let app = build_admin_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/seaweedfsstatic/bootstrap/3.3.1/css/bootstrap.min.css") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/css; charset=utf-8") + ); + let body = body_bytes(response).await; + assert!(body.len() > 1000); }