From 760416f641465b5062e690c496efb6852f08df9e Mon Sep 17 00:00:00 2001 From: YanLien <128586861+YanLien@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:01:56 +0800 Subject: [PATCH 1/2] fix(board): parse and display board type leases in summary table Add optional `leases` field to `BoardTypeSummary` and introduce `BoardLease` struct to capture per-board lease details (board_id, date_begin, date_end). Extend `render_board_table` to show active leases, with each lease on its own row and proper column alignment. Handle responses without leases gracefully. --- ostool/src/board/client.rs | 55 +++++++++++++- ostool/src/board/mod.rs | 143 +++++++++++++++++++++++++++++++++---- 2 files changed, 184 insertions(+), 14 deletions(-) diff --git a/ostool/src/board/client.rs b/ostool/src/board/client.rs index 1181b84..40987ca 100644 --- a/ostool/src/board/client.rs +++ b/ostool/src/board/client.rs @@ -37,6 +37,14 @@ pub struct BoardTypeSummary { pub tags: Vec, pub total: usize, pub available: usize, + pub leases: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BoardLease { + pub board_id: String, + pub date_begin: String, + pub date_end: String, } #[derive(Debug, Clone, Serialize)] @@ -637,7 +645,7 @@ mod tests { use serde::Serialize; use url::Url; - use super::{BoardServerClient, BootConfig, parse_error_body}; + use super::{BoardServerClient, BoardTypeSummary, BootConfig, parse_error_body}; use crate::board::global_config::{AuthMode, BoardEndpoint}; #[derive(Serialize)] @@ -651,6 +659,51 @@ mod tests { checksum: String, } + #[test] + fn board_type_summary_accepts_response_without_leases() { + let summaries: Vec = serde_json::from_str( + r#"[ + { + "board_type": "rk3568", + "tags": [], + "total": 2, + "available": 1 + } + ]"#, + ) + .unwrap(); + + assert!(summaries[0].leases.is_none()); + } + + #[test] + fn board_type_summary_parses_optional_leases() { + let summaries: Vec = serde_json::from_str( + r#"[ + { + "board_type": "Rock-4D", + "tags": [], + "total": 1, + "available": 1, + "leases": [ + { + "board_id": "Rock-4D-1", + "date_begin": "2026-07-31 15:40:00", + "date_end": "2026-08-03 15:40:00" + } + ] + } + ]"#, + ) + .unwrap(); + + let leases = summaries[0].leases.as_ref().unwrap(); + assert_eq!(leases.len(), 1); + assert_eq!(leases[0].board_id, "Rock-4D-1"); + assert_eq!(leases[0].date_begin, "2026-07-31 15:40:00"); + assert_eq!(leases[0].date_end, "2026-08-03 15:40:00"); + } + #[test] fn resolve_relative_ws_url_uses_server_defaults() { let client = BoardServerClient::new("127.0.0.1", 8080).unwrap(); diff --git a/ostool/src/board/mod.rs b/ostool/src/board/mod.rs index 5ed6586..465dde1 100644 --- a/ostool/src/board/mod.rs +++ b/ostool/src/board/mod.rs @@ -80,16 +80,54 @@ pub fn render_board_table(boards: &[BoardTypeSummary]) -> String { .max() .unwrap_or(1) .max("TOTAL".len()); + let tags_width = boards + .iter() + .map(|item| { + if item.tags.is_empty() { + 1 + } else { + item.tags.join(",").len() + } + }) + .max() + .unwrap_or(1) + .max("TAGS".len()); + let board_id_width = boards + .iter() + .filter_map(|item| item.leases.as_ref()) + .flatten() + .map(|lease| lease.board_id.len()) + .max() + .unwrap_or(1) + .max("BOARD ID".len()); + let date_begin_width = boards + .iter() + .filter_map(|item| item.leases.as_ref()) + .flatten() + .map(|lease| lease.date_begin.len()) + .max() + .unwrap_or(1) + .max("DATE BEGIN".len()); - let mut lines = Vec::with_capacity(boards.len() + 1); + let row_count = boards + .iter() + .map(|item| item.leases.as_ref().map_or(1, |leases| leases.len().max(1))) + .sum::(); + let mut lines = Vec::with_capacity(row_count + 1); lines.push(format!( - "{:avail_width$} {:>total_width$} TAGS", + "{:avail_width$} {:>total_width$} {: String { } else { item.tags.join(",") }; - lines.push(format!( - "{:avail_width$} {:>total_width$} {}", - item.board_type, - item.available, - item.total, - tags, - type_width = type_width, - avail_width = avail_width, - total_width = total_width, - )); + let mut push_row = |board_id: &str, date_begin: &str, date_end: &str| { + lines.push(format!( + "{:avail_width$} {:>total_width$} {: { + for lease in leases { + push_row(&lease.board_id, &lease.date_begin, &lease.date_end); + } + } + Some(_) | None => push_row("-", "-", "-"), + } } lines.join("\n") @@ -422,7 +477,7 @@ async fn run_allocated_board( #[cfg(test)] mod tests { use super::{RunBoardOptions, board_session_setup_required, render_board_table}; - use crate::board::client::BoardTypeSummary; + use crate::board::client::{BoardLease, BoardTypeSummary}; use crate::board::config::BoardRunConfig; #[test] @@ -460,11 +515,73 @@ mod tests { tags: vec!["arm64".into(), "lab".into()], total: 3, available: 2, + leases: None, }]); assert!(rendered.contains("BOARD TYPE")); + assert!(rendered.contains("BOARD ID")); + assert!(rendered.contains("DATE BEGIN")); + assert!(rendered.contains("DATE END")); assert!(rendered.contains("rk3568")); assert!(rendered.contains("arm64,lab")); + assert_eq!( + rendered + .lines() + .nth(1) + .unwrap() + .split_whitespace() + .collect::>(), + vec!["rk3568", "2", "3", "arm64,lab", "-", "-", "-"] + ); + } + + #[test] + fn render_board_table_places_each_lease_on_its_own_row() { + let rendered = render_board_table(&[BoardTypeSummary { + board_type: "Rock-4D".into(), + tags: vec![], + total: 2, + available: 0, + leases: Some(vec![ + BoardLease { + board_id: "Rock-4D-1".into(), + date_begin: "2026-07-31 15:40:00".into(), + date_end: "2026-08-03 15:40:00".into(), + }, + BoardLease { + board_id: "Rock-4D-2".into(), + date_begin: "2026-08-01 10:00:00".into(), + date_end: "2026-08-02 10:00:00".into(), + }, + ]), + }]); + + let rows = rendered.lines().skip(1).collect::>(); + assert_eq!(rows.len(), 2); + assert!(rows[0].contains("Rock-4D-1")); + assert!(rows[0].contains("2026-07-31 15:40:00")); + assert!(rows[0].contains("2026-08-03 15:40:00")); + assert!(rows[1].contains("Rock-4D-2")); + assert!(rows[1].contains("2026-08-01 10:00:00")); + assert!(rows[1].contains("2026-08-02 10:00:00")); + } + + #[test] + fn render_board_table_handles_empty_leases() { + let rendered = render_board_table(&[BoardTypeSummary { + board_type: "Rock-4D".into(), + tags: vec![], + total: 1, + available: 1, + leases: Some(vec![]), + }]); + + let rows = rendered.lines().skip(1).collect::>(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].split_whitespace().collect::>(), + vec!["Rock-4D", "1", "1", "-", "-", "-", "-"] + ); } #[test] From 21d6183715c8936153e5a9da3c08b70908721c1b Mon Sep 17 00:00:00 2001 From: YanLien <128586861+YanLien@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:53:38 +0800 Subject: [PATCH 2/2] fix(board): format lease expiry timestamps in local time zone Add `format_local_time` utility to convert UTC timestamps to the local time zone and apply it to lease expiry and auth token expiry displays for better readability. --- ostool/src/board/mod.rs | 11 +++++++++-- ostool/src/main.rs | 3 ++- ostool/src/utils.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/ostool/src/board/mod.rs b/ostool/src/board/mod.rs index 465dde1..d86c2ea 100644 --- a/ostool/src/board/mod.rs +++ b/ostool/src/board/mod.rs @@ -26,6 +26,7 @@ use crate::{ build::config::{BuildConfig, BuildSystem, Cargo}, invocation::Invocation, project::variables::{self, VariableScope}, + utils::format_local_time, }; #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -246,7 +247,10 @@ async fn connect_allocated_board( } else { let lease_expires_at = session.current_lease_expires_at().await; println!("Board has no serial configuration; keeping session alive until Ctrl+C."); - println!(" lease_expires_at: {lease_expires_at}"); + println!( + " lease_expires_at: {}", + format_local_time(lease_expires_at) + ); tokio::signal::ctrl_c() .await .context("failed to wait for Ctrl+C")?; @@ -261,7 +265,10 @@ pub(crate) fn print_allocated_board_session(session: &BoardSession, board_type: println!(" board_type: {board_type}"); println!(" board_id: {}", session.info().board_id); println!(" session_id: {}", session.info().session_id); - println!(" lease_expires_at: {}", session.info().lease_expires_at); + println!( + " lease_expires_at: {}", + format_local_time(session.info().lease_expires_at) + ); println!(" boot_mode: {}", session.info().boot_mode); } diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 3902aef..bd5e31e 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -18,6 +18,7 @@ use ostool::{ qemu::{QemuConfig, RunQemuOptions}, uboot::UbootConfig, }, + utils::format_local_time, }; #[derive(Parser, Debug)] @@ -256,7 +257,7 @@ async fn try_main() -> Result<()> { Some(kind) => { println!("credential: {kind}"); if let Some(expires_at) = status.expires_at { - println!("expires_at: {expires_at}"); + println!("expires_at: {}", format_local_time(expires_at)); } if let Some(scope) = status.scope { println!("scope: {scope}"); diff --git a/ostool/src/utils.rs b/ostool/src/utils.rs index 0e76d58..9359a90 100644 --- a/ostool/src/utils.rs +++ b/ostool/src/utils.rs @@ -11,8 +11,22 @@ use std::{ use anyhow::Context; use anyhow::bail; +use chrono::{DateTime, Local, TimeZone, Utc}; use colored::Colorize; +/// Formats a UTC timestamp in the operating system's local time zone. +pub fn format_local_time(datetime: DateTime) -> String { + format_time(datetime, &Local) +} + +fn format_time(datetime: DateTime, timezone: &Tz) -> String +where + Tz: TimeZone, + Tz::Offset: std::fmt::Display, +{ + datetime.with_timezone(timezone).to_string() +} + /// A command builder wrapper with variable substitution support. /// /// `Command` wraps `std::process::Command` and adds support for automatic @@ -233,8 +247,28 @@ where #[cfg(test)] mod tests { use super::*; + use chrono::{FixedOffset, Timelike as _, Utc}; use std::env; + #[test] + fn format_time_converts_to_requested_timezone() { + let utc = Utc + .with_ymd_and_hms(2026, 8, 2, 18, 19, 22) + .unwrap() + .with_nanosecond(987_576_390) + .unwrap(); + let timezone = FixedOffset::east_opt(8 * 60 * 60).unwrap(); + + assert_eq!( + format_time(utc, &timezone), + "2026-08-03 02:19:22.987576390 +08:00" + ); + assert_eq!( + format_time(utc.with_nanosecond(0).unwrap(), &timezone), + "2026-08-03 02:19:22 +08:00" + ); + } + #[test] fn test_replace_placeholders_supports_custom_variables() { unsafe {