Skip to content

Commit 95f2937

Browse files
authored
Bundle assets into binary & improve title bar (#246)
1 parent aeac155 commit 95f2937

9 files changed

Lines changed: 290 additions & 48 deletions

File tree

Cargo.lock

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

dial9-viewer/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ futures = "0.3"
2828
serde = { version = "1", features = ["derive"] }
2929
serde_json = "1"
3030
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
31+
rust-embed = { version = "8", features = ["mime-guess"] }
32+
mime_guess = "2"
3133
tower-http = { version = "0.6", features = ["fs"] }
3234
tracing = "0.1"
3335
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

dial9-viewer/build.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ fn main() {
1818

1919
println!("cargo::rerun-if-changed=toolkit");
2020
println!("cargo::rerun-if-changed=skills");
21+
println!("cargo::rerun-if-changed=ui");
2122

2223
generate_toolkit(&manifest_dir, &out_dir);
2324
generate_skills(&manifest_dir, &out_dir);

dial9-viewer/src/bin/dev_server.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ async fn main() -> anyhow::Result<()> {
8484
);
8585

8686
let ui_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("ui");
87-
let app = dial9_viewer::server::router(state, &ui_dir);
87+
let state = state.with_dev_ui_dir(ui_dir);
88+
let app = dial9_viewer::server::router(state);
8889

8990
let port: u16 = std::env::var("PORT")
9091
.ok()

dial9-viewer/src/main.rs

Lines changed: 89 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ pub struct Cli {
4040
#[arg(long, global = true, conflicts_with = "bucket")]
4141
local_dir: Option<PathBuf>,
4242

43-
/// Directory containing UI static files (when running without a subcommand)
44-
#[arg(long, default_value = "ui", global = true)]
45-
ui_dir: PathBuf,
43+
/// Dev mode: serve UI files from disk for faster iteration
44+
#[arg(long, global = true)]
45+
dev: bool,
4646
}
4747

4848
#[derive(Subcommand, Debug)]
@@ -99,18 +99,35 @@ async fn main() -> anyhow::Result<()> {
9999
},
100100
},
101101
Some(Commands::Serve {}) | None => {
102-
return serve(cli.port, cli.bucket, cli.prefix, cli.local_dir, cli.ui_dir).await;
102+
return serve(cli.port, cli.bucket, cli.prefix, cli.local_dir, cli.dev).await;
103103
}
104104
}
105105
Ok(())
106106
}
107107

108+
async fn detect_bucket_region(bucket: &str) -> Option<String> {
109+
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
110+
let client = aws_sdk_s3::Client::new(&config);
111+
match client.head_bucket().bucket(bucket).send().await {
112+
Ok(resp) => resp.bucket_region().map(|r| r.to_string()),
113+
Err(err) => {
114+
// HeadBucket errors include the x-amz-bucket-region header
115+
let raw = err.raw_response();
116+
raw.and_then(|r| {
117+
r.headers()
118+
.get("x-amz-bucket-region")
119+
.map(|v| v.to_string())
120+
})
121+
}
122+
}
123+
}
124+
108125
async fn serve(
109126
port: u16,
110127
bucket: Option<String>,
111128
prefix: Option<String>,
112129
local_dir: Option<PathBuf>,
113-
ui_dir: PathBuf,
130+
dev: bool,
114131
) -> anyhow::Result<()> {
115132
tracing_subscriber::fmt()
116133
.with_env_filter(
@@ -119,42 +136,89 @@ async fn serve(
119136
)
120137
.init();
121138

122-
let ui_dir = if ui_dir.exists() {
123-
ui_dir
124-
} else if let Ok(exe) = std::env::current_exe() {
125-
let candidate = exe.parent().unwrap_or(exe.as_ref()).join(&ui_dir);
126-
if candidate.exists() {
127-
candidate
128-
} else {
129-
ui_dir
139+
let dev_ui_dir = if dev {
140+
// In dev mode, find the ui/ directory relative to the manifest or CWD
141+
let candidates = [PathBuf::from("ui"), PathBuf::from("dial9-viewer/ui")];
142+
let dir = candidates.into_iter().find(|p| p.exists());
143+
match dir {
144+
Some(d) => {
145+
tracing::info!(path = %d.display(), "dev mode: serving UI from disk");
146+
Some(d)
147+
}
148+
None => {
149+
anyhow::bail!(
150+
"--dev: could not find ui/ directory. Run from the dial9-viewer/ or repo root directory."
151+
);
152+
}
130153
}
131154
} else {
132-
ui_dir
155+
None
133156
};
134157

135158
let app_state = if let Some(dir) = &local_dir {
136159
let dir = std::fs::canonicalize(dir)?;
137160
tracing::info!(path = %dir.display(), "serving traces from local directory");
138161
let backend = dial9_viewer::storage::LocalBackend::new(&dir);
139-
// Use a sentinel bucket so routes that require one don't fail.
140-
dial9_viewer::server::AppState::new(
162+
let mut state = dial9_viewer::server::AppState::new(
141163
std::sync::Arc::new(backend),
142164
Some("local".into()),
143165
prefix.clone(),
144-
)
166+
);
167+
if let Some(d) = dev_ui_dir {
168+
state = state.with_dev_ui_dir(d);
169+
}
170+
state
145171
} else {
146-
let backend = dial9_viewer::storage::S3Backend::from_env().await;
147-
dial9_viewer::server::AppState::new(
148-
std::sync::Arc::new(backend),
149-
bucket.clone(),
150-
prefix.clone(),
151-
)
172+
// Detect bucket region if a bucket is provided
173+
if let Some(bucket_name) = &bucket {
174+
if let Some(region) = detect_bucket_region(bucket_name).await {
175+
tracing::info!(%region, bucket = %bucket_name, "detected bucket region");
176+
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
177+
.region(aws_sdk_s3::config::Region::new(region))
178+
.load()
179+
.await;
180+
let client = aws_sdk_s3::Client::new(&config);
181+
let backend = dial9_viewer::storage::S3Backend::from_client(client);
182+
let mut state = dial9_viewer::server::AppState::new(
183+
std::sync::Arc::new(backend),
184+
bucket.clone(),
185+
prefix.clone(),
186+
);
187+
if let Some(d) = dev_ui_dir {
188+
state = state.with_dev_ui_dir(d);
189+
}
190+
state
191+
} else {
192+
tracing::warn!(bucket = %bucket_name, "could not detect bucket region, using default");
193+
let backend = dial9_viewer::storage::S3Backend::from_env().await;
194+
let mut state = dial9_viewer::server::AppState::new(
195+
std::sync::Arc::new(backend),
196+
bucket.clone(),
197+
prefix.clone(),
198+
);
199+
if let Some(d) = dev_ui_dir {
200+
state = state.with_dev_ui_dir(d);
201+
}
202+
state
203+
}
204+
} else {
205+
let backend = dial9_viewer::storage::S3Backend::from_env().await;
206+
let mut state = dial9_viewer::server::AppState::new(
207+
std::sync::Arc::new(backend),
208+
bucket.clone(),
209+
prefix.clone(),
210+
);
211+
if let Some(d) = dev_ui_dir {
212+
state = state.with_dev_ui_dir(d);
213+
}
214+
state
215+
}
152216
};
153217

154-
let app = dial9_viewer::server::router(app_state, &ui_dir);
218+
let app = dial9_viewer::server::router(app_state);
155219

156220
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
157-
tracing::info!(port, ui_dir = %ui_dir.display(), "dial9-viewer listening");
221+
tracing::info!(port, dev, "dial9-viewer listening");
158222
println!("\n → http://localhost:{}\n", port);
159223
if let Some(dir) = &local_dir {
160224
tracing::info!(path = %dir.display(), "local directory mode");

dial9-viewer/src/server/mod.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
use crate::storage::StorageBackend;
22
use axum::Router;
3-
use std::path::Path;
3+
use axum::body::Body;
4+
use axum::http::{HeaderValue, StatusCode, header};
5+
use axum::response::{IntoResponse, Response};
6+
use rust_embed::Embed;
7+
use std::path::PathBuf;
48
use std::sync::Arc;
5-
use tower_http::services::ServeDir;
69

710
mod config;
811
mod prefixes;
912
mod search;
1013
mod trace;
1114

15+
#[derive(Embed)]
16+
#[folder = "ui/"]
17+
struct UiAssets;
18+
1219
#[derive(Clone)]
1320
#[non_exhaustive]
1421
pub struct AppState {
1522
pub backend: Arc<dyn StorageBackend>,
1623
pub default_bucket: Option<String>,
1724
pub default_prefix: Option<String>,
25+
/// When set, serve UI files from disk instead of embedded assets.
26+
pub dev_ui_dir: Option<PathBuf>,
1827
}
1928

2029
impl AppState {
@@ -27,14 +36,47 @@ impl AppState {
2736
backend,
2837
default_bucket,
2938
default_prefix,
39+
dev_ui_dir: None,
3040
}
3141
}
42+
43+
pub fn with_dev_ui_dir(mut self, dir: PathBuf) -> Self {
44+
self.dev_ui_dir = Some(dir);
45+
self
46+
}
3247
}
3348

34-
pub fn router(state: AppState, ui_dir: &Path) -> Router {
35-
Router::new()
36-
.nest("/api", api_router(state))
37-
.fallback_service(ServeDir::new(ui_dir))
49+
pub fn router(state: AppState) -> Router {
50+
if let Some(dir) = state.dev_ui_dir.clone() {
51+
tracing::info!(path = %dir.display(), "serving UI from disk (dev mode)");
52+
Router::new()
53+
.nest("/api", api_router(state))
54+
.fallback_service(tower_http::services::ServeDir::new(dir))
55+
} else {
56+
Router::new()
57+
.nest("/api", api_router(state))
58+
.fallback(serve_embedded)
59+
}
60+
}
61+
62+
async fn serve_embedded(uri: axum::http::Uri) -> Response {
63+
let path = uri.path().trim_start_matches('/');
64+
let path = if path.is_empty() { "index.html" } else { path };
65+
66+
match UiAssets::get(path) {
67+
Some(file) => {
68+
let mime = mime_guess::from_path(path).first_or_octet_stream();
69+
Response::builder()
70+
.header(
71+
header::CONTENT_TYPE,
72+
HeaderValue::from_str(mime.as_ref()).unwrap(),
73+
)
74+
.body(Body::from(file.data.to_vec()))
75+
.unwrap()
76+
.into_response()
77+
}
78+
None => (StatusCode::NOT_FOUND, "not found").into_response(),
79+
}
3880
}
3981

4082
fn api_router(state: AppState) -> Router {

dial9-viewer/tests/server_test.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ fn fake_s3_client(fs_root: &std::path::Path) -> aws_sdk_s3::Client {
5656

5757
async fn start_server(state: AppState) -> String {
5858
let ui_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("ui");
59-
let app = router(state, &ui_dir);
59+
let state = state.with_dev_ui_dir(ui_dir);
60+
let app = router(state);
6061
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6162
let addr = listener.local_addr().unwrap();
6263
tokio::spawn(async move {

0 commit comments

Comments
 (0)