Apply DISPLAY_TIMEZONE to GC and integrity history initial render so the system dashboard table doesn't switch zones after refresh

This commit is contained in:
2026-04-28 00:10:22 +08:00
parent 4d923df16c
commit d57ea8378a
8 changed files with 81 additions and 26 deletions

1
Cargo.lock generated
View File

@@ -2729,6 +2729,7 @@ dependencies = [
"base64",
"bytes",
"chrono",
"chrono-tz",
"clap",
"cookie",
"crc32fast",

View File

@@ -41,6 +41,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.9"
base64 = "0.22"
tokio-util = { version = "0.7", features = ["io", "io-util"] }
tokio-stream = "0.1"

View File

@@ -25,6 +25,7 @@ tracing-subscriber = { workspace = true }
tokio-util = { workspace = true }
tokio-stream = { workspace = true }
chrono = { workspace = true }
chrono-tz = { workspace = true }
uuid = { workspace = true }
futures = { workspace = true }
http-body = "1"

View File

@@ -84,6 +84,7 @@ pub struct ServerConfig {
pub cors_expose_headers: Vec<String>,
pub session_lifetime_days: u64,
pub log_level: String,
pub display_timezone: String,
pub multipart_min_part_size: u64,
pub bulk_delete_max_keys: usize,
pub stream_chunk_size: usize,
@@ -240,6 +241,19 @@ impl ServerConfig {
let cors_expose_headers = parse_list_env("CORS_EXPOSE_HEADERS", "*");
let session_lifetime_days = parse_u64_env("SESSION_LIFETIME_DAYS", 1);
let log_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| "INFO".to_string());
let display_timezone = {
let raw = std::env::var("DISPLAY_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
match raw.parse::<chrono_tz::Tz>() {
Ok(_) => raw,
Err(_) => {
tracing::warn!(
"Invalid DISPLAY_TIMEZONE '{}', falling back to UTC",
raw
);
"UTC".to_string()
}
}
};
let multipart_min_part_size = parse_u64_env("MULTIPART_MIN_PART_SIZE", 5_242_880);
let bulk_delete_max_keys = parse_usize_env("BULK_DELETE_MAX_KEYS", 1000);
let stream_chunk_size = parse_usize_env("STREAM_CHUNK_SIZE", 1_048_576);
@@ -331,6 +345,7 @@ impl ServerConfig {
cors_expose_headers,
session_lifetime_days,
log_level,
display_timezone,
multipart_min_part_size,
bulk_delete_max_keys,
stream_chunk_size,
@@ -425,6 +440,7 @@ impl Default for ServerConfig {
cors_expose_headers: vec!["*".to_string()],
session_lifetime_days: 1,
log_level: "INFO".to_string(),
display_timezone: "UTC".to_string(),
multipart_min_part_size: 5_242_880,
bulk_delete_max_keys: 1000,
stream_chunk_size: 1_048_576,

View File

@@ -1951,13 +1951,17 @@ pub async fn metrics_dashboard(
render(&state, "metrics.html", &ctx)
}
fn format_history_timestamp(timestamp: Option<f64>) -> String {
fn format_history_timestamp(timestamp: Option<f64>, tz: chrono_tz::Tz) -> String {
let Some(timestamp) = timestamp else {
return "-".to_string();
};
let millis = (timestamp * 1000.0).round() as i64;
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.map(|dt| {
dt.with_timezone(&tz)
.format("%Y-%m-%d %H:%M:%S %Z")
.to_string()
})
.unwrap_or_else(|| "-".to_string())
}
@@ -1976,7 +1980,7 @@ fn format_byte_count(bytes: u64) -> String {
}
}
fn decorate_gc_history(executions: &[Value]) -> Vec<Value> {
fn decorate_gc_history(executions: &[Value], tz: chrono_tz::Tz) -> Vec<Value> {
executions
.iter()
.cloned()
@@ -1990,7 +1994,7 @@ fn decorate_gc_history(executions: &[Value]) -> Vec<Value> {
if let Some(obj) = execution.as_object_mut() {
obj.insert(
"timestamp_display".to_string(),
Value::String(format_history_timestamp(timestamp)),
Value::String(format_history_timestamp(timestamp, tz)),
);
obj.insert(
"bytes_freed_display".to_string(),
@@ -2002,7 +2006,7 @@ fn decorate_gc_history(executions: &[Value]) -> Vec<Value> {
.collect()
}
fn decorate_integrity_history(executions: &[Value]) -> Vec<Value> {
fn decorate_integrity_history(executions: &[Value], tz: chrono_tz::Tz) -> Vec<Value> {
executions
.iter()
.cloned()
@@ -2011,7 +2015,7 @@ fn decorate_integrity_history(executions: &[Value]) -> Vec<Value> {
if let Some(obj) = execution.as_object_mut() {
obj.insert(
"timestamp_display".to_string(),
Value::String(format_history_timestamp(timestamp)),
Value::String(format_history_timestamp(timestamp, tz)),
);
}
execution
@@ -2024,6 +2028,11 @@ pub async fn system_dashboard(
Extension(session): Extension<SessionHandle>,
) -> Response {
let mut ctx = page_context(&state, &session, "ui.system_dashboard");
let display_tz: chrono_tz::Tz = state
.config
.display_timezone
.parse()
.unwrap_or(chrono_tz::UTC);
let gc_status = match &state.gc {
Some(gc) => gc.status().await,
@@ -2045,7 +2054,7 @@ pub async fn system_dashboard(
.await
.get("executions")
.and_then(|value| value.as_array())
.map(|values| decorate_gc_history(values))
.map(|values| decorate_gc_history(values, display_tz))
.unwrap_or_default(),
None => Vec::new(),
};
@@ -2069,7 +2078,7 @@ pub async fn system_dashboard(
.await
.get("executions")
.and_then(|value| value.as_array())
.map(|values| decorate_integrity_history(values))
.map(|values| decorate_integrity_history(values, display_tz))
.unwrap_or_default(),
None => Vec::new(),
};
@@ -2081,7 +2090,7 @@ pub async fn system_dashboard(
ctx.insert("gc_status", &gc_status);
ctx.insert("integrity_status", &integrity_status);
ctx.insert("app_version", &env!("CARGO_PKG_VERSION"));
ctx.insert("display_timezone", &"UTC");
ctx.insert("display_timezone", &state.config.display_timezone);
ctx.insert("platform", &std::env::consts::OS);
ctx.insert(
"storage_root",

View File

@@ -192,7 +192,7 @@ impl AppState {
None
};
let templates = init_templates(&config.templates_dir);
let templates = init_templates(&config.templates_dir, &config.display_timezone);
let access_logging = Arc::new(AccessLoggingService::new(&config.storage_root));
let session_ttl = Duration::from_secs(config.session_lifetime_days.saturating_mul(86_400));
Self {
@@ -259,15 +259,18 @@ impl AppState {
}
}
fn init_templates(templates_dir: &std::path::Path) -> Option<Arc<TemplateEngine>> {
fn init_templates(
templates_dir: &std::path::Path,
display_timezone: &str,
) -> Option<Arc<TemplateEngine>> {
let use_disk = std::env::var("TEMPLATES_DIR").is_ok() && templates_dir.is_dir();
let result = if use_disk {
let glob = format!("{}/*.html", templates_dir.display()).replace('\\', "/");
tracing::info!("Loading templates from disk: {}", templates_dir.display());
TemplateEngine::new(&glob)
TemplateEngine::new(&glob, display_timezone)
} else {
tracing::info!("Loading templates from embedded assets");
TemplateEngine::from_embedded()
TemplateEngine::from_embedded(display_timezone)
};
match result {
Ok(engine) => {

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use parking_lot::RwLock;
use serde_json::Value;
use tera::{Context, Error as TeraError, Tera};
@@ -16,10 +17,10 @@ pub struct TemplateEngine {
}
impl TemplateEngine {
pub fn new(template_glob: &str) -> Result<Self, TeraError> {
pub fn new(template_glob: &str, display_timezone: &str) -> Result<Self, TeraError> {
let mut tera = Tera::new(template_glob)?;
tera.set_escape_fn(html_escape);
register_filters(&mut tera);
register_filters(&mut tera, display_timezone);
let endpoints: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
@@ -31,10 +32,10 @@ impl TemplateEngine {
})
}
pub fn from_embedded() -> Result<Self, TeraError> {
pub fn from_embedded(display_timezone: &str) -> Result<Self, TeraError> {
let mut tera = Tera::default();
tera.set_escape_fn(html_escape);
register_filters(&mut tera);
register_filters(&mut tera, display_timezone);
let names = crate::embedded::template_names();
let mut entries: Vec<(String, String)> = Vec::with_capacity(names.len());
@@ -95,8 +96,14 @@ fn html_escape(input: &str) -> String {
out
}
fn register_filters(tera: &mut Tera) {
tera.register_filter("format_datetime", format_datetime_filter);
fn register_filters(tera: &mut Tera, display_timezone: &str) {
let tz: Tz = display_timezone.parse().unwrap_or(chrono_tz::UTC);
tera.register_filter(
"format_datetime",
move |value: &Value, args: &HashMap<String, Value>| -> tera::Result<Value> {
format_datetime_filter(value, args, tz)
},
);
tera.register_filter("filesizeformat", filesizeformat_filter);
tera.register_filter("slice", slice_filter);
}
@@ -186,11 +193,15 @@ fn urlencode_query(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, UNRESERVED).to_string()
}
fn format_datetime_filter(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
fn format_datetime_filter(
value: &Value,
args: &HashMap<String, Value>,
tz: Tz,
) -> tera::Result<Value> {
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%Y-%m-%d %H:%M:%S UTC");
.unwrap_or("%Y-%m-%d %H:%M:%S %Z");
let dt: Option<DateTime<Utc>> = match value {
Value::String(s) => DateTime::parse_from_rfc3339(s)
@@ -210,7 +221,7 @@ fn format_datetime_filter(value: &Value, args: &HashMap<String, Value>) -> tera:
};
match dt {
Some(d) => Ok(Value::String(d.format(format).to_string())),
Some(d) => Ok(Value::String(d.with_timezone(&tz).format(format).to_string())),
None => Ok(value.clone()),
}
}
@@ -291,7 +302,7 @@ mod tests {
let tpl = tmp.path().join("t.html");
std::fs::write(&tpl, "").unwrap();
let glob = format!("{}/*.html", tmp.path().display());
let engine = TemplateEngine::new(&glob).unwrap();
let engine = TemplateEngine::new(&glob, "UTC").unwrap();
engine.register_endpoints(&[
("ui.buckets_overview", "/ui/buckets"),
("ui.bucket_detail", "/ui/buckets/{bucket_name}"),
@@ -356,7 +367,7 @@ mod tests {
path.push("templates");
path.push("*.html");
let glob = path.to_string_lossy().replace('\\', "/");
let engine = TemplateEngine::new(&glob).expect("Tera parse failed");
let engine = TemplateEngine::new(&glob, "UTC").expect("Tera parse failed");
let names: Vec<String> = engine
.tera
.read()
@@ -372,7 +383,7 @@ mod tests {
#[test]
fn embedded_templates_parse() {
let engine = TemplateEngine::from_embedded().expect("Embedded Tera parse failed");
let engine = TemplateEngine::from_embedded("UTC").expect("Embedded Tera parse failed");
let names: Vec<String> = engine
.tera
.read()
@@ -393,8 +404,21 @@ mod tests {
let v = format_datetime_filter(
&Value::String("2024-06-15T12:34:56Z".into()),
&HashMap::new(),
chrono_tz::UTC,
)
.unwrap();
assert_eq!(v, Value::String("2024-06-15 12:34:56 UTC".into()));
}
#[test]
fn format_datetime_custom_timezone() {
let tz: Tz = "America/New_York".parse().unwrap();
let v = format_datetime_filter(
&Value::String("2024-06-15T12:34:56Z".into()),
&HashMap::new(),
tz,
)
.unwrap();
assert_eq!(v, Value::String("2024-06-15 08:34:56 EDT".into()));
}
}

View File

@@ -10,7 +10,7 @@ fn engine() -> TemplateEngine {
path.push("templates");
path.push("*.html");
let glob = path.to_string_lossy().replace('\\', "/");
let engine = TemplateEngine::new(&glob).expect("template parse");
let engine = TemplateEngine::new(&glob, "UTC").expect("template parse");
myfsio_server::handlers::ui_pages::register_ui_endpoints(&engine);
engine
}