1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum Status {
7 Running,
8 Success,
9 Stopped,
10 Pending,
11 Failed,
12 Unknown(String),
13}
14
15impl FromStr for Status {
16 type Err = std::convert::Infallible;
17
18 fn from_str(s: &str) -> Result<Self, Self::Err> {
19 Ok(match s.to_uppercase().as_str() {
20 "RUNNING" => Status::Running,
21 "SUCCESS" | "COMPLETED" | "FINISHED" => Status::Success,
22 "IDLE" | "STOPPED" | "TERMINATED" | "DELETED" | "SKIPPED" | "CANCELED" => {
23 Status::Stopped
24 }
25 "PENDING"
26 | "STARTING"
27 | "RESTARTING"
28 | "DELETING"
29 | "TERMINATING"
30 | "QUEUED"
31 | "WAITING_FOR_RETRY"
32 | "BLOCKED"
33 | "CREATED"
34 | "INITIALIZING"
35 | "RESETTING"
36 | "SETTING_UP_TABLES"
37 | "WAITING_FOR_RESOURCES"
38 | "STOPPING" => Status::Pending,
39 "FAILED" | "ERROR" | "TIMEDOUT" | "TIMED_OUT" | "INTERNAL_ERROR" => Status::Failed,
40 other => Status::Unknown(other.to_string()),
41 })
42 }
43}
44
45impl Status {
46 pub fn rank(&self) -> u8 {
49 match self {
50 Status::Running => 0,
51 Status::Pending => 1,
52 Status::Failed => 2,
53 Status::Success => 3,
54 Status::Stopped => 4,
55 Status::Unknown(_) => 5,
56 }
57 }
58
59 pub fn label(&self) -> &str {
60 match self {
61 Status::Running => "RUNNING",
62 Status::Success => "SUCCESS",
63 Status::Stopped => "IDLE",
64 Status::Pending => "PENDING",
65 Status::Failed => "FAILED",
66 Status::Unknown(s) => s.as_str(),
67 }
68 }
69}
70
71pub fn relative_time(epoch_ms: u64) -> String {
73 let now = std::time::SystemTime::now()
74 .duration_since(std::time::UNIX_EPOCH)
75 .map(|d| d.as_millis() as u64)
76 .unwrap_or(0);
77 let secs = now.saturating_sub(epoch_ms) / 1000;
78 match secs {
79 0..=59 => "just now".to_string(),
80 60..=3599 => format!("{}m ago", secs / 60),
81 3600..=86_399 => format!("{}h ago", secs / 3600),
82 _ => format!("{}d ago", secs / 86_400),
83 }
84}
85
86pub fn fmt_duration_ms(ms: u64) -> String {
88 let secs = ms / 1000;
89 if secs < 60 {
90 format!("{}s", secs)
91 } else if secs < 3600 {
92 format!("{}m {}s", secs / 60, secs % 60)
93 } else {
94 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
95 }
96}
97
98#[derive(Debug, Clone)]
99pub enum Shape {
100 List(Vec<ListItem>),
101 Table(TableData),
102 Badge(BadgeData),
103 Text(String),
104}
105
106#[derive(Debug, Clone, Default)]
107pub struct ListItem {
108 pub name: String,
109 pub status: Status,
110 pub detail: Option<String>,
111 pub id: Option<String>,
113 pub history: Vec<Status>,
115 pub alert: Option<String>,
118}
119
120impl Default for Status {
121 fn default() -> Self {
122 Status::Unknown(String::new())
123 }
124}
125
126pub fn item_matches(item: &ListItem, query: &str) -> bool {
129 if query.is_empty() {
130 return true;
131 }
132 let q = query.to_lowercase();
133 item.name.to_lowercase().contains(&q)
134 || item
135 .detail
136 .as_deref()
137 .is_some_and(|d| d.to_lowercase().contains(&q))
138 || item.status.label().to_lowercase().contains(&q)
139}
140
141#[derive(Debug, Clone)]
143pub struct DetailData {
144 pub summary: Vec<(String, String)>,
146 pub activity: Vec<(Status, String)>,
148 pub raw: String,
150}
151
152#[derive(Debug, Clone)]
153pub struct TableData {
154 pub headers: Vec<String>,
155 pub rows: Vec<Vec<String>>,
156}
157
158impl TableData {
159 pub fn to_csv(&self) -> String {
162 fn field(s: &str) -> String {
163 if s.contains([',', '"', '\n', '\r']) {
164 format!("\"{}\"", s.replace('"', "\"\""))
165 } else {
166 s.to_string()
167 }
168 }
169 let mut out = String::new();
170 out.push_str(
171 &self
172 .headers
173 .iter()
174 .map(|h| field(h))
175 .collect::<Vec<_>>()
176 .join(","),
177 );
178 out.push('\n');
179 for row in &self.rows {
180 out.push_str(&row.iter().map(|c| field(c)).collect::<Vec<_>>().join(","));
181 out.push('\n');
182 }
183 out
184 }
185}
186
187#[derive(Debug, Clone)]
188pub struct BadgeData {
189 pub label: String,
190 pub value: String,
191}