use chrono::{DateTime, Duration as ChronoDuration, Utc};
use chrono_tz::Tz;
use gflow::core::reservation::{GpuReservation, ReservationStatus};
use std::time::{Duration, SystemTime};
pub struct TimelineConfig<'a> {
pub width: usize,
pub time_range: (SystemTime, SystemTime),
pub timezone: Option<&'a str>,
}
impl<'a> Default for TimelineConfig<'a> {
fn default() -> Self {
let now = SystemTime::now();
let start = now - Duration::from_secs(12 * 3600);
let end = now + Duration::from_secs(12 * 3600);
Self {
width: 80,
time_range: (start, end),
timezone: None,
}
}
}
pub fn render_timeline(reservations: &[GpuReservation], config: TimelineConfig) {
render_timeline_to_writer(reservations, config, &mut std::io::stdout());
}
fn render_timeline_to_writer<W: std::io::Write>(
reservations: &[GpuReservation],
config: TimelineConfig,
writer: &mut W,
) {
if reservations.is_empty() {
writeln!(writer, "No reservations found.").ok();
return;
}
let now = SystemTime::now();
let (range_start, range_end) = config.time_range;
let tz = get_timezone(config.timezone);
let now_dt = system_time_to_datetime(now, &tz);
writeln!(
writer,
"\nGPU Reservations Timeline ({})",
now_dt.format("%Y-%m-%d %H:%M:%S %Z")
)
.ok();
writeln!(writer, "{}", "═".repeat(config.width)).ok();
let aligned_start =
print_time_axis_to_writer(range_start, range_end, config.width, now, &tz, writer);
writeln!(writer).ok();
let mut sorted_reservations = reservations.to_vec();
sorted_reservations.sort_by_key(|r| r.start_time);
for reservation in sorted_reservations {
print_reservation_bar_to_writer(
&reservation,
aligned_start,
range_end,
config.width,
now,
&tz,
writer,
);
}
writeln!(writer).ok();
print_summary_to_writer(reservations, now, writer);
}
fn print_time_axis_to_writer<W: std::io::Write>(
start: SystemTime,
end: SystemTime,
width: usize,
now: SystemTime,
tz: &Tz,
writer: &mut W,
) -> SystemTime {
use chrono::Timelike;
let start_dt = system_time_to_datetime(start, tz);
let end_dt = system_time_to_datetime(end, tz);
let duration = end.duration_since(start).unwrap_or_default();
let hours = duration.as_secs() / 3600;
let interval_hours = if hours <= 12 {
2
} else if hours <= 24 {
4
} else if hours <= 48 {
6
} else {
12
};
let start_hour = start_dt.hour();
let rounded_hour = (start_hour / interval_hours) * interval_hours;
let mut current = start_dt
.with_hour(rounded_hour)
.unwrap()
.with_minute(0)
.unwrap()
.with_second(0)
.unwrap()
.with_nanosecond(0)
.unwrap();
if datetime_to_system_time(current) < start {
current += ChronoDuration::hours(interval_hours as i64);
}
current -= ChronoDuration::hours(interval_hours as i64);
let aligned_start = datetime_to_system_time(current);
let mut time_markers = Vec::new();
let mut last_date = None;
while current <= end_dt {
let pos = time_to_position(datetime_to_system_time(current), aligned_start, end, width);
let current_date = current.date_naive();
let time_str = if last_date.is_none() || last_date != Some(current_date) {
last_date = Some(current_date);
current.format("%m/%d %H:%M").to_string()
} else {
current.format("%H:%M").to_string()
};
time_markers.push((pos, time_str));
current += ChronoDuration::hours(interval_hours as i64);
}
let mut axis = vec!['─'; width];
for (pos, _) in &time_markers {
if *pos < width {
axis[*pos] = '┬';
}
}
let now_pos = time_to_position(now, aligned_start, end, width);
if now_pos < width {
axis[now_pos] = '┃';
}
writeln!(writer, "{}", axis.iter().collect::<String>()).ok();
let mut label_line = vec![' '; width];
let now_pos = time_to_position(now, aligned_start, end, width);
for (pos, time_str) in &time_markers {
if (*pos as i32 - now_pos as i32).abs() < 6 {
continue;
}
let available_space = width.saturating_sub(*pos);
if *pos < width && time_str.len() <= available_space {
for (i, ch) in time_str.chars().enumerate() {
if pos + i < width {
label_line[pos + i] = ch;
}
}
}
}
if now_pos >= 2 && now_pos + 2 < width {
let now_label = "Now";
let start_pos = now_pos.saturating_sub(1);
for (i, ch) in now_label.chars().enumerate() {
if start_pos + i < width {
label_line[start_pos + i] = ch;
}
}
}
writeln!(writer, "{}", label_line.iter().collect::<String>()).ok();
aligned_start
}
fn print_reservation_bar_to_writer<W: std::io::Write>(
reservation: &GpuReservation,
range_start: SystemTime,
range_end: SystemTime,
width: usize,
_now: SystemTime,
tz: &Tz,
writer: &mut W,
) {
let res_start = reservation.start_time;
let res_end = reservation.end_time();
if res_end < range_start || res_start > range_end {
return;
}
const LABEL_WIDTH: usize = 16;
let bar_width = width.saturating_sub(LABEL_WIDTH);
let bar_start = time_to_position(res_start.max(range_start), range_start, range_end, width);
let bar_end = time_to_position(res_end.min(range_end), range_start, range_end, width);
let bar_length = bar_end.saturating_sub(bar_start).max(1);
let mut bar = vec![' '; bar_width];
let bar_char = match reservation.status {
ReservationStatus::Active => '█',
ReservationStatus::Pending => '░',
ReservationStatus::Completed => '▓',
ReservationStatus::Cancelled => '▒',
};
#[allow(clippy::needless_range_loop)]
for pos in bar_start..bar_start + bar_length {
if pos >= LABEL_WIDTH && pos - LABEL_WIDTH < bar_width {
bar[pos - LABEL_WIDTH] = bar_char;
}
}
let gpu_spec_str = format_gpu_spec(&reservation.gpu_spec);
let label = format!("{} (GPU: {})", reservation.user, gpu_spec_str);
let bar_str: String = bar.iter().collect();
writeln!(writer, "{:<15} {}", label, bar_str).ok();
let status_info = format!(
" └─ {} ({}→{})",
format_status(reservation.status),
format_time_short(res_start, tz),
format_time_short(res_end, tz)
);
writeln!(writer, "{}", status_info).ok();
}
fn time_to_position(
time: SystemTime,
range_start: SystemTime,
range_end: SystemTime,
width: usize,
) -> usize {
let total_duration = range_end
.duration_since(range_start)
.unwrap_or_default()
.as_secs_f64();
let time_offset = time
.duration_since(range_start)
.unwrap_or_default()
.as_secs_f64();
let ratio = time_offset / total_duration;
(ratio * width as f64).round() as usize
}
fn format_status(status: ReservationStatus) -> String {
match status {
ReservationStatus::Active => "Active".to_string(),
ReservationStatus::Pending => "Pending".to_string(),
ReservationStatus::Completed => "Completed".to_string(),
ReservationStatus::Cancelled => "Cancelled".to_string(),
}
}
fn format_gpu_spec(spec: &gflow::core::reservation::GpuSpec) -> String {
use gflow::core::reservation::GpuSpec;
match spec {
GpuSpec::Count(count) => {
format!("{} GPU{}", count, if *count > 1 { "s" } else { "" })
}
GpuSpec::Indices(indices) => {
let indices_str = indices
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
format!("GPU[{}]", indices_str)
}
}
}
fn format_time_short(time: SystemTime, tz: &Tz) -> String {
let dt = system_time_to_datetime(time, tz);
dt.format("%H:%M").to_string()
}
fn print_summary_to_writer<W: std::io::Write>(
reservations: &[GpuReservation],
_now: SystemTime,
writer: &mut W,
) {
let active_count = reservations
.iter()
.filter(|r| r.status == ReservationStatus::Active)
.count();
let pending_count = reservations
.iter()
.filter(|r| r.status == ReservationStatus::Pending)
.count();
let total_active_gpus: u32 = reservations
.iter()
.filter(|r| r.status == ReservationStatus::Active)
.map(|r| r.gpu_spec.count())
.sum();
writeln!(writer, "{}", "─".repeat(80)).ok();
writeln!(
writer,
"Summary: {} active, {} pending | {} GPUs currently reserved",
active_count, pending_count, total_active_gpus
)
.ok();
writeln!(writer).ok();
writeln!(writer, "Legend: █ Active ░ Pending").ok();
}
fn get_timezone(config_tz: Option<&str>) -> Tz {
if let Some(tz_str) = config_tz {
tz_str.parse::<Tz>().unwrap_or(chrono_tz::UTC)
} else {
gflow::utils::timezone::get_timezone(None).unwrap_or(chrono_tz::UTC)
}
}
fn system_time_to_datetime(time: SystemTime, tz: &Tz) -> DateTime<Tz> {
let duration = time
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
DateTime::<Utc>::from_timestamp(duration.as_secs() as i64, 0)
.unwrap_or_default()
.with_timezone(tz)
}
fn datetime_to_system_time<T: chrono::TimeZone>(dt: DateTime<T>) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(dt.timestamp() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use compact_str::CompactString;
use gflow::core::reservation::GpuSpec;
#[test]
fn test_time_to_position() {
let start = SystemTime::UNIX_EPOCH + Duration::from_secs(1000);
let end = start + Duration::from_secs(3600); let width = 100;
assert_eq!(time_to_position(start, start, end, width), 0);
assert_eq!(time_to_position(end, start, end, width), 100);
let middle = start + Duration::from_secs(1800);
let pos = time_to_position(middle, start, end, width);
assert!((49..=51).contains(&pos)); }
#[test]
fn test_render_empty_reservations() {
let reservations: Vec<GpuReservation> = vec![];
let config = TimelineConfig::default();
render_timeline(&reservations, config);
}
#[test]
fn test_render_single_reservation() {
let now = SystemTime::now();
let reservation = GpuReservation {
id: 1,
user: CompactString::from("alice"),
gpu_spec: GpuSpec::Count(2),
start_time: now,
duration: Duration::from_secs(3600),
status: ReservationStatus::Active,
created_at: now,
cancelled_at: None,
};
let config = TimelineConfig::default();
render_timeline(&[reservation], config);
}
#[test]
fn test_timeline_alignment() {
let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let range_start = base_time;
let range_end = base_time + Duration::from_secs(12 * 3600);
let res_start = base_time + Duration::from_secs(6 * 3600); let reservation = GpuReservation {
id: 1,
user: CompactString::from("testuser"),
gpu_spec: GpuSpec::Count(1),
start_time: res_start,
duration: Duration::from_secs(2 * 3600), status: ReservationStatus::Active,
created_at: base_time,
cancelled_at: None,
};
let config = TimelineConfig {
width: 80,
time_range: (range_start, range_end),
timezone: None,
};
let mut output = Vec::new();
render_timeline_to_writer(&[reservation], config, &mut output);
let output_str = String::from_utf8(output).unwrap();
assert!(output_str.contains("GPU Reservations Timeline"));
assert!(output_str.contains("testuser (GPU: 1 GPU)"));
assert!(output_str.contains("Active"));
assert!(output_str.contains("Legend:"));
let lines: Vec<&str> = output_str.lines().collect();
let axis_line = lines
.iter()
.find(|l| l.contains('┬') || l.contains('─'))
.unwrap();
let bar_line = lines
.iter()
.find(|l| l.contains('█') || l.contains('░'))
.unwrap();
let axis_prefix_len = axis_line.chars().take_while(|c| *c == ' ').count();
assert_eq!(axis_prefix_len, 0, "Axis should start at position 0");
let bar_char_pos = bar_line.chars().position(|c| c == '█' || c == '░').unwrap();
assert!(
bar_char_pos >= 16,
"Bar should start at or after position 16 (after label), found at {}",
bar_char_pos
);
}
#[test]
fn test_timeline_bar_position() {
let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let range_start = base_time;
let range_end = base_time + Duration::from_secs(10 * 3600); let width = 100;
let res_at_start = GpuReservation {
id: 1,
user: CompactString::from("user1"),
gpu_spec: GpuSpec::Count(1),
start_time: range_start,
duration: Duration::from_secs(3600), status: ReservationStatus::Pending,
created_at: base_time,
cancelled_at: None,
};
let res_at_middle = GpuReservation {
id: 2,
user: CompactString::from("user2"),
gpu_spec: GpuSpec::Count(1),
start_time: range_start + Duration::from_secs(5 * 3600), duration: Duration::from_secs(3600), status: ReservationStatus::Active,
created_at: base_time,
cancelled_at: None,
};
let config = TimelineConfig {
width,
time_range: (range_start, range_end),
timezone: None,
};
let mut output = Vec::new();
render_timeline_to_writer(&[res_at_start, res_at_middle], config, &mut output);
let output_str = String::from_utf8(output).unwrap();
assert!(output_str.contains("user1"));
assert!(output_str.contains("user2"));
assert!(output_str.contains('░')); assert!(output_str.contains('█')); }
#[test]
fn test_timeline_end_to_end_output() {
let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let range_start = base_time;
let range_end = base_time + Duration::from_secs(8 * 3600);
let reservation1 = GpuReservation {
id: 1,
user: CompactString::from("alice"),
gpu_spec: GpuSpec::Count(2),
start_time: range_start + Duration::from_secs(2 * 3600), duration: Duration::from_secs(2 * 3600), status: ReservationStatus::Active,
created_at: base_time,
cancelled_at: None,
};
let reservation2 = GpuReservation {
id: 2,
user: CompactString::from("bob"),
gpu_spec: GpuSpec::Count(1),
start_time: range_start + Duration::from_secs(5 * 3600), duration: Duration::from_secs(3600), status: ReservationStatus::Pending,
created_at: base_time,
cancelled_at: None,
};
let config = TimelineConfig {
width: 80,
time_range: (range_start, range_end),
timezone: None,
};
let mut output = Vec::new();
render_timeline_to_writer(&[reservation1, reservation2], config, &mut output);
let actual_output = String::from_utf8(output).unwrap();
assert!(actual_output.contains("GPU Reservations Timeline"));
assert!(actual_output.contains("alice (GPU: 2 GPUs)"));
assert!(actual_output.contains("bob (GPU: 1 GPU)"));
assert!(actual_output.contains("Active"));
assert!(actual_output.contains("Pending"));
assert!(actual_output.contains("Legend: █ Active ░ Pending"));
assert!(actual_output.contains("Summary:"));
let lines: Vec<&str> = actual_output.lines().collect();
let header_line = lines
.iter()
.find(|l| l.contains("GPU Reservations Timeline"))
.unwrap();
assert!(header_line.contains("("));
assert!(header_line.contains(")"));
let timestamp_part = header_line
.split('(')
.nth(1)
.unwrap()
.split(')')
.next()
.unwrap();
assert!(timestamp_part.contains('-')); assert!(timestamp_part.contains(':'));
assert!(lines.iter().any(|l| l.contains('┬') && l.contains('─')));
assert!(lines.iter().any(|l| l.contains('█') || l.contains('░')));
}
}