From ba304a3764c8a7f02ab8b4858ca0009ab13c975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Sat, 10 Oct 2026 08:48:05 +0200 Subject: [PATCH] fix: show absolute dates as UTC RFC 3339 everywhere Projects, environments and webhooks rendered dates in local time with no zone, secrets metadata printed raw API strings, and agent Docker networks printed Go's time.Time string. All now go through one formatter (`2026-10-09T07:57:15Z`), matching what `agent sessions` already showed. Docker network times are read with `{{json .Created}}`, which works whether the CLI exposes `.Created` as a string or a time.Time, and fall back to Docker's raw value if it doesn't parse. --- src/handlers/agent/sessions.rs | 32 ++++----------- src/handlers/run/docker_sandbox.rs | 26 +++++++++++- src/handlers/secrets/metadata.rs | 15 ++++--- src/models/secrets.rs | 13 +++--- src/utils/human_datetime.rs | 66 ++++++++++++++++++++++++++---- 5 files changed, 106 insertions(+), 46 deletions(-) diff --git a/src/handlers/agent/sessions.rs b/src/handlers/agent/sessions.rs index 56524651..2e0694cf 100644 --- a/src/handlers/agent/sessions.rs +++ b/src/handlers/agent/sessions.rs @@ -6,11 +6,15 @@ use std::{ }; use anyhow::{Context, Result}; -use chrono::{DateTime, SecondsFormat, Utc}; +use chrono::Utc; use serde::{Deserialize, Serialize}; use tabled::Tabled; -use crate::utils::{interaction, spinner::request_spinner}; +use crate::utils::{ + human_datetime::{format_utc, format_utc_timestamp}, + interaction, + spinner::request_spinner, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalAgentSession { @@ -36,7 +40,7 @@ impl LocalAgentSessionGuard { let session = LocalAgentSession { session_id: session_id.clone(), - started_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + started_at: format_utc(Utc::now()), process_id: std::process::id(), process_started_at: process_start_time(std::process::id())?, revoked: false, @@ -196,12 +200,6 @@ impl From for AgentSessionRow { } } -fn format_utc_timestamp(value: &str) -> Result { - Ok(DateTime::parse_from_rfc3339(value)? - .with_timezone(&Utc) - .to_rfc3339_opts(SecondsFormat::Secs, true)) -} - pub fn format_sessions(rows: &Vec, json: bool) -> Result<()> { if json { println!( @@ -219,8 +217,8 @@ pub fn format_sessions(rows: &Vec, json: bool) -> Result<()> { #[cfg(test)] mod tests { use super::{ - format_utc_timestamp, is_valid_agent_session_id, process_start_time, - should_confirm_bulk_revoke, validate_bulk_scope, AgentSessionRow, + is_valid_agent_session_id, process_start_time, should_confirm_bulk_revoke, + validate_bulk_scope, AgentSessionRow, }; #[test] @@ -231,18 +229,6 @@ mod tests { assert_eq!(process_start_time(pid).unwrap(), first); } - #[test] - fn session_timestamps_are_utc_seconds() { - assert_eq!( - format_utc_timestamp("2026-09-13T11:53:59.237435+00:00").unwrap(), - "2026-09-13T11:53:59Z" - ); - assert_eq!( - format_utc_timestamp("2026-07-03T16:10:07+02:00").unwrap(), - "2026-07-03T14:10:07Z" - ); - } - #[test] fn session_rows_do_not_include_expiration() { let row = AgentSessionRow { diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 3b2440d7..047e8f32 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -413,7 +413,8 @@ pub(crate) struct ExistingRunNetwork { /// `None` for the pre-session-naming fallback (a bare UUID) — see /// `generate_run_network_name`. pub session_id: Option, - /// RFC 3339 creation timestamp, straight from `docker network inspect`. + /// Creation timestamp as UTC RFC 3339 (see `format_utc`), or empty when + /// `docker network inspect` couldn't report it. pub created_at: String, } @@ -453,7 +454,7 @@ pub(crate) fn list_run_networks() -> Result, String> { .output() .map_err(|error| format!("failed to run `docker network inspect`: {error}"))?; let created_at = if inspect.status.success() { - String::from_utf8_lossy(&inspect.stdout).trim().to_owned() + normalize_network_created_at(&String::from_utf8_lossy(&inspect.stdout)) } else { // The network could have been removed between the `ls` and // this `inspect` (e.g. a concurrent run finishing normally) @@ -647,6 +648,13 @@ pub(crate) const STALE_IMAGE_AFTER_DAYS: i64 = 14; /// When the local image for `source` was built, or `None` if it isn't /// built (or `docker` can't say). Not meaningful for a plain image /// reference, which `docker run` pulls on demand. +/// Turns `docker network inspect --format '{{.Created}}'` output into a UTC +/// RFC 3339 timestamp, keeping the raw value if it doesn't parse. +fn normalize_network_created_at(output: &str) -> String { + let raw = output.trim().trim_matches('"'); + crate::utils::human_datetime::display_utc_timestamp(raw) +} + pub(crate) fn sandbox_image_created_at( source: &AgentImageSource, ) -> Option> { @@ -1831,6 +1839,20 @@ mod tests { assert!(arg_line < install_line); } + #[test] + fn network_created_at_is_normalized_to_utc() { + // Docker Engine exposes `.Created` as an RFC 3339 string. + assert_eq!( + normalize_network_created_at("2026-10-09T09:57:15.791491833+02:00\n"), + "2026-10-09T07:57:15Z" + ); + assert_eq!( + normalize_network_created_at("2026-10-09T07:57:15.791491833Z"), + "2026-10-09T07:57:15Z" + ); + assert_eq!(normalize_network_created_at("not-a-date"), "not-a-date"); + } + #[test] fn format_image_age_reads_naturally() { let now = chrono::Utc::now(); diff --git a/src/handlers/secrets/metadata.rs b/src/handlers/secrets/metadata.rs index 4d6cba6a..64e77e6c 100644 --- a/src/handlers/secrets/metadata.rs +++ b/src/handlers/secrets/metadata.rs @@ -13,6 +13,7 @@ use crate::{ validation::InputValidationError, }, utils::{ + human_datetime::display_utc_timestamp, output::{get_formatted_json_string, ColorizeIfColoredOutput}, spinner::request_spinner, tables::build::build_table, @@ -257,7 +258,8 @@ fn print_secret_metadata_list(secret_metadata: Vec, format: &Sec .iter() .map(|s| { let comment = s.comment.clone().unwrap_or_default(); - let last_accessed_at = s.last_accessed_at.clone().unwrap_or_default(); + let last_accessed_at = + display_utc_timestamp(&s.last_accessed_at.clone().unwrap_or_default()); format!( "{}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", @@ -269,9 +271,9 @@ fn print_secret_metadata_list(secret_metadata: Vec, format: &Sec "has_value:".blue_if_tty(), s.has_value, "created_at:".blue_if_tty(), - s.created_at, + display_utc_timestamp(&s.created_at), "updated_at:".blue_if_tty(), - s.updated_at, + display_utc_timestamp(&s.updated_at), "last_accessed_at:".blue_if_tty(), last_accessed_at, ) @@ -317,7 +319,8 @@ fn print_secret_metadata(secret_metadata: SecretMetadata, format: &SecretsOutput } SecretsOutputFormat::Plain | SecretsOutputFormat::Dotenv => { let comment = secret_metadata.comment.unwrap_or_default(); - let last_accessed_at = secret_metadata.last_accessed_at.unwrap_or_default(); + let last_accessed_at = + display_utc_timestamp(&secret_metadata.last_accessed_at.unwrap_or_default()); println!( "{}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", @@ -329,9 +332,9 @@ fn print_secret_metadata(secret_metadata: SecretMetadata, format: &SecretsOutput "has_value:".blue_if_tty(), secret_metadata.has_value, "created_at:".blue_if_tty(), - secret_metadata.created_at, + display_utc_timestamp(&secret_metadata.created_at), "updated_at:".blue_if_tty(), - secret_metadata.updated_at, + display_utc_timestamp(&secret_metadata.updated_at), "last_accessed_at:".blue_if_tty(), last_accessed_at, ); diff --git a/src/models/secrets.rs b/src/models/secrets.rs index be49e159..e87b79fe 100644 --- a/src/models/secrets.rs +++ b/src/models/secrets.rs @@ -13,6 +13,7 @@ use crate::{ cmd::config::SecretsOutputFormat, utils::{ self, + human_datetime::display_utc_timestamp, output::{is_color_enabled, write_indented, ColorizeIfColoredOutput}, }, }; @@ -187,9 +188,9 @@ impl From for SecretMetadataTable { comment: value.comment.unwrap_or_default(), version: value.version, has_value: value.has_value, - created_at: value.created_at, - updated_at: value.updated_at, - last_accessed_at: value.last_accessed_at.unwrap_or_default(), + created_at: display_utc_timestamp(&value.created_at), + updated_at: display_utc_timestamp(&value.updated_at), + last_accessed_at: display_utc_timestamp(&value.last_accessed_at.unwrap_or_default()), } } } @@ -200,9 +201,9 @@ impl From for SecretMetadataTableWithoutComment { name: value.name, version: value.version, has_value: value.has_value, - created_at: value.created_at, - updated_at: value.updated_at, - last_accessed_at: value.last_accessed_at.unwrap_or_default(), + created_at: display_utc_timestamp(&value.created_at), + updated_at: display_utc_timestamp(&value.updated_at), + last_accessed_at: display_utc_timestamp(&value.last_accessed_at.unwrap_or_default()), } } } diff --git a/src/utils/human_datetime.rs b/src/utils/human_datetime.rs index 32677f5e..3dcce6b7 100644 --- a/src/utils/human_datetime.rs +++ b/src/utils/human_datetime.rs @@ -1,18 +1,34 @@ -use chrono::prelude::DateTime; -use chrono::Local; +use anyhow::Result; +use chrono::{DateTime, SecondsFormat, Utc}; use chrono_humanize::HumanTime; use log::debug; +/// Formats an instant the way every absolute date in the CLI is shown: +/// UTC, whole seconds, RFC 3339 with a `Z` (e.g. `2026-09-13T11:53:59Z`). +pub fn format_utc(date_time: DateTime) -> String { + date_time.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +/// Parses an RFC 3339 timestamp in any offset and re-formats it with +/// [`format_utc`]. +pub fn format_utc_timestamp(value: &str) -> Result { + Ok(format_utc( + DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc), + )) +} + +/// [`format_utc_timestamp`] for display: anything that doesn't parse +/// (including an empty "never" value) is shown unchanged. +pub fn display_utc_timestamp(value: &str) -> String { + format_utc_timestamp(value).unwrap_or_else(|_| value.to_owned()) +} + pub fn get_human_datetime(date_time_str: &str) -> (String, HumanTime) { match DateTime::parse_from_rfc3339(date_time_str) { Ok(d) => { - let datetime = DateTime::::from(d); - - let date_time = datetime.format("%Y-%m-%d %H:%M").to_string(); - let dt = datetime - chrono::Local::now(); - - let ht = HumanTime::from(dt); - return (date_time, ht); + let datetime = d.with_timezone(&Utc); + let ht = HumanTime::from(datetime - Utc::now()); + (format_utc(datetime), ht) } Err(e) => { debug!("{:#?}", &e); @@ -23,3 +39,35 @@ pub fn get_human_datetime(date_time_str: &str) -> (String, HumanTime) { } } } + +#[cfg(test)] +mod tests { + use super::{display_utc_timestamp, format_utc_timestamp, get_human_datetime}; + + #[test] + fn timestamps_are_utc_seconds() { + assert_eq!( + format_utc_timestamp("2026-09-13T11:53:59.237435+00:00").unwrap(), + "2026-09-13T11:53:59Z" + ); + assert_eq!( + format_utc_timestamp("2026-07-03T16:10:07+02:00").unwrap(), + "2026-07-03T14:10:07Z" + ); + } + + #[test] + fn human_datetime_is_utc_regardless_of_local_timezone() { + let (formatted, _) = get_human_datetime("2026-07-03T16:10:07.5+02:00"); + assert_eq!(formatted, "2026-07-03T14:10:07Z"); + } + + #[test] + fn display_keeps_unparseable_values() { + assert_eq!( + display_utc_timestamp("2026-07-03T16:10:07+02:00"), + "2026-07-03T14:10:07Z" + ); + assert_eq!(display_utc_timestamp(""), ""); + } +}