1use std::collections::HashMap;
2use std::time::Instant;
3
4use ratatui::prelude::{Color, Style};
5use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7use crate::dashboard_core::WindowStats;
8pub(in crate::tui) use crate::dashboard_core::window_stats::compute_window_stats;
9use crate::state::{
10 ActiveRequest, ConfigHealth, FinishedRequest, HealthCheckStatus, LbConfigView, ProxyState,
11 SessionStats, UsageRollupView,
12};
13use crate::usage::UsageMetrics;
14
15#[derive(Debug, Clone, PartialEq, Eq, Default)]
16pub struct UpstreamSummary {
17 pub base_url: String,
18 pub provider_id: Option<String>,
19 pub auth: String,
20 pub tags: Vec<(String, String)>,
21 pub supported_models: Vec<String>,
22 pub model_mapping: Vec<(String, String)>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct ProviderOption {
27 pub name: String,
28 pub alias: Option<String>,
29 pub enabled: bool,
30 pub level: u8,
31 pub active: bool,
32 pub upstreams: Vec<UpstreamSummary>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub(in crate::tui) struct SessionRow {
37 pub(in crate::tui) session_id: Option<String>,
38 pub(in crate::tui) cwd: Option<String>,
39 pub(in crate::tui) active_count: usize,
40 pub(in crate::tui) active_started_at_ms_min: Option<u64>,
41 pub(in crate::tui) active_last_method: Option<String>,
42 pub(in crate::tui) active_last_path: Option<String>,
43 pub(in crate::tui) last_status: Option<u16>,
44 pub(in crate::tui) last_duration_ms: Option<u64>,
45 pub(in crate::tui) last_ended_at_ms: Option<u64>,
46 pub(in crate::tui) last_model: Option<String>,
47 pub(in crate::tui) last_reasoning_effort: Option<String>,
48 pub(in crate::tui) last_provider_id: Option<String>,
49 pub(in crate::tui) last_config_name: Option<String>,
50 pub(in crate::tui) last_usage: Option<UsageMetrics>,
51 pub(in crate::tui) total_usage: Option<UsageMetrics>,
52 pub(in crate::tui) turns_total: Option<u64>,
53 pub(in crate::tui) turns_with_usage: Option<u64>,
54 pub(in crate::tui) override_effort: Option<String>,
55 pub(in crate::tui) override_config_name: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59pub(in crate::tui) struct Snapshot {
60 pub(in crate::tui) rows: Vec<SessionRow>,
61 pub(in crate::tui) recent: Vec<FinishedRequest>,
62 pub(in crate::tui) overrides: HashMap<String, String>,
63 pub(in crate::tui) config_overrides: HashMap<String, String>,
64 pub(in crate::tui) global_override: Option<String>,
65 pub(in crate::tui) config_meta_overrides: HashMap<String, (Option<bool>, Option<u8>)>,
66 pub(in crate::tui) usage_rollup: UsageRollupView,
67 pub(in crate::tui) config_health: HashMap<String, ConfigHealth>,
68 pub(in crate::tui) health_checks: HashMap<String, HealthCheckStatus>,
69 pub(in crate::tui) lb_view: HashMap<String, LbConfigView>,
70 pub(in crate::tui) stats_5m: WindowStats,
71 pub(in crate::tui) stats_1h: WindowStats,
72 pub(in crate::tui) refreshed_at: Instant,
73}
74
75#[derive(Debug, Clone, Copy)]
76pub(in crate::tui) struct Palette {
77 pub(in crate::tui) bg: Color,
78 pub(in crate::tui) panel: Color,
79 pub(in crate::tui) border: Color,
80 pub(in crate::tui) text: Color,
81 pub(in crate::tui) muted: Color,
82 pub(in crate::tui) accent: Color,
83 pub(in crate::tui) focus: Color,
84 pub(in crate::tui) good: Color,
85 pub(in crate::tui) warn: Color,
86 pub(in crate::tui) bad: Color,
87}
88
89impl Default for Palette {
90 fn default() -> Self {
91 Self {
92 bg: Color::Rgb(14, 17, 22),
93 panel: Color::Rgb(18, 22, 28),
94 border: Color::Rgb(54, 62, 74),
95 text: Color::Rgb(224, 228, 234),
96 muted: Color::Rgb(144, 154, 164),
97 accent: Color::Rgb(88, 166, 255),
98 focus: Color::Rgb(121, 192, 255),
99 good: Color::Rgb(63, 185, 80),
100 warn: Color::Rgb(210, 153, 34),
101 bad: Color::Rgb(248, 81, 73),
102 }
103 }
104}
105
106pub(in crate::tui) fn now_ms() -> u64 {
107 std::time::SystemTime::now()
108 .duration_since(std::time::UNIX_EPOCH)
109 .map(|d| d.as_millis() as u64)
110 .unwrap_or(0)
111}
112
113pub(in crate::tui) const CODEX_RECENT_WINDOWS: [(u64, &str); 6] = [
114 (30 * 60, "30m"),
115 (60 * 60, "1h"),
116 (3 * 60 * 60, "3h"),
117 (8 * 60 * 60, "8h"),
118 (12 * 60 * 60, "12h"),
119 (24 * 60 * 60, "24h"),
120];
121
122pub(in crate::tui) fn codex_recent_window_label(idx: usize) -> &'static str {
123 CODEX_RECENT_WINDOWS[idx.min(CODEX_RECENT_WINDOWS.len() - 1)].1
124}
125
126pub(in crate::tui) fn codex_recent_window_threshold_ms(now_ms: u64, idx: usize) -> u64 {
127 let secs = CODEX_RECENT_WINDOWS[idx.min(CODEX_RECENT_WINDOWS.len() - 1)].0;
128 now_ms.saturating_sub(secs.saturating_mul(1000))
129}
130
131pub(in crate::tui) fn basename(path: &str) -> &str {
132 let path = path.trim_end_matches(['/', '\\']);
133 let slash = path.rfind('/');
134 let backslash = path.rfind('\\');
135 let idx = match (slash, backslash) {
136 (Some(a), Some(b)) => Some(a.max(b)),
137 (Some(a), None) => Some(a),
138 (None, Some(b)) => Some(b),
139 (None, None) => None,
140 };
141 if let Some(i) = idx {
142 &path[i.saturating_add(1)..]
143 } else {
144 path
145 }
146}
147
148pub(in crate::tui) fn shorten(s: &str, max: usize) -> String {
149 shorten_head(s, max)
150}
151
152pub(in crate::tui) fn shorten_middle(s: &str, max: usize) -> String {
153 if display_width(s) <= max {
154 return s.to_string();
155 }
156 if max == 0 {
157 return String::new();
158 }
159 if max == 1 {
160 return "…".to_string();
161 }
162 let remaining = max.saturating_sub(1);
163 let head_w = remaining / 2;
164 let tail_w = remaining.saturating_sub(head_w);
165 let head = prefix_by_width(s, head_w);
166 let tail = suffix_by_width(s, tail_w);
167 format!("{head}…{tail}")
168}
169
170fn shorten_head(s: &str, max: usize) -> String {
171 if display_width(s) <= max {
172 return s.to_string();
173 }
174 if max == 0 {
175 return String::new();
176 }
177 if max == 1 {
178 return "…".to_string();
179 }
180 let head = prefix_by_width(s, max.saturating_sub(1));
181 format!("{head}…")
182}
183
184fn display_width(s: &str) -> usize {
185 UnicodeWidthStr::width(s)
186}
187
188fn prefix_by_width(s: &str, max_width: usize) -> &str {
189 if max_width == 0 {
190 return "";
191 }
192 let mut width = 0usize;
193 let mut end = 0usize;
194 for (i, ch) in s.char_indices() {
195 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
196 if width.saturating_add(w) > max_width {
197 break;
198 }
199 width = width.saturating_add(w);
200 end = i.saturating_add(ch.len_utf8());
201 }
202 &s[..end]
203}
204
205fn suffix_by_width(s: &str, max_width: usize) -> &str {
206 if max_width == 0 {
207 return "";
208 }
209 let mut width = 0usize;
210 let mut start = s.len();
211 for (i, ch) in s.char_indices().rev() {
212 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
213 if width.saturating_add(w) > max_width {
214 break;
215 }
216 width = width.saturating_add(w);
217 start = i;
218 }
219 &s[start..]
220}
221
222pub(in crate::tui) fn short_sid(sid: &str, max: usize) -> String {
223 shorten_head(sid, max)
226}
227
228pub fn build_provider_options(
229 cfg: &crate::config::ProxyConfig,
230 service_name: &str,
231) -> Vec<ProviderOption> {
232 let upstream_summary = |u: &crate::config::UpstreamConfig| -> UpstreamSummary {
233 let auth = if let Some(env) = u.auth.auth_token_env.as_deref()
234 && !env.trim().is_empty()
235 {
236 format!("bearer env {env}")
237 } else if u
238 .auth
239 .auth_token
240 .as_deref()
241 .is_some_and(|s| !s.trim().is_empty())
242 {
243 "bearer inline".to_string()
244 } else if let Some(env) = u.auth.api_key_env.as_deref()
245 && !env.trim().is_empty()
246 {
247 format!("x-api-key env {env}")
248 } else if u
249 .auth
250 .api_key
251 .as_deref()
252 .is_some_and(|s| !s.trim().is_empty())
253 {
254 "x-api-key inline".to_string()
255 } else {
256 "-".to_string()
257 };
258
259 let mut tags = u
260 .tags
261 .iter()
262 .map(|(k, v)| (k.clone(), v.clone()))
263 .collect::<Vec<_>>();
264 tags.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
265
266 let mut supported_models = u.supported_models.keys().cloned().collect::<Vec<_>>();
267 supported_models.sort();
268
269 let mut model_mapping = u
270 .model_mapping
271 .iter()
272 .map(|(k, v)| (k.clone(), v.clone()))
273 .collect::<Vec<_>>();
274 model_mapping.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
275
276 UpstreamSummary {
277 base_url: u.base_url.clone(),
278 provider_id: u.tags.get("provider_id").cloned(),
279 auth,
280 tags,
281 supported_models,
282 model_mapping,
283 }
284 };
285
286 let mut providers: Vec<ProviderOption> = match service_name {
287 "claude" => cfg
288 .claude
289 .configs
290 .iter()
291 .map(|(name, svc)| ProviderOption {
292 name: name.clone(),
293 alias: svc.alias.clone(),
294 enabled: svc.enabled,
295 level: svc.level.clamp(1, 10),
296 active: cfg.claude.active.as_deref() == Some(name.as_str()),
297 upstreams: svc.upstreams.iter().map(upstream_summary).collect(),
298 })
299 .collect(),
300 _ => cfg
301 .codex
302 .configs
303 .iter()
304 .map(|(name, svc)| ProviderOption {
305 name: name.clone(),
306 alias: svc.alias.clone(),
307 enabled: svc.enabled,
308 level: svc.level.clamp(1, 10),
309 active: cfg.codex.active.as_deref() == Some(name.as_str()),
310 upstreams: svc.upstreams.iter().map(upstream_summary).collect(),
311 })
312 .collect(),
313 };
314 providers.sort_by(|a, b| a.level.cmp(&b.level).then_with(|| a.name.cmp(&b.name)));
315 providers
316}
317
318fn session_sort_key(row: &SessionRow) -> u64 {
319 row.last_ended_at_ms
320 .unwrap_or(0)
321 .max(row.active_started_at_ms_min.unwrap_or(0))
322}
323
324pub(in crate::tui) fn format_age(now_ms: u64, ts_ms: Option<u64>) -> String {
325 let Some(ts) = ts_ms else {
326 return "-".to_string();
327 };
328 if now_ms <= ts {
329 return "0s".to_string();
330 }
331 let mut secs = (now_ms - ts) / 1000;
332 let days = secs / 86400;
333 secs %= 86400;
334 let hours = secs / 3600;
335 secs %= 3600;
336 let mins = secs / 60;
337 secs %= 60;
338 if days > 0 {
339 format!("{days}d{hours}h")
340 } else if hours > 0 {
341 format!("{hours}h{mins}m")
342 } else if mins > 0 {
343 format!("{mins}m{secs}s")
344 } else {
345 format!("{secs}s")
346 }
347}
348
349pub(in crate::tui) fn tokens_short(n: i64) -> String {
350 let n = n.max(0) as f64;
351 if n >= 1_000_000.0 {
352 format!("{:.1}m", n / 1_000_000.0)
353 } else if n >= 1_000.0 {
354 format!("{:.1}k", n / 1_000.0)
355 } else {
356 format!("{:.0}", n)
357 }
358}
359
360pub(in crate::tui) fn usage_line(usage: &UsageMetrics) -> String {
361 format!(
362 "tok in/out/rsn/ttl: {}/{}/{}/{}",
363 tokens_short(usage.input_tokens),
364 tokens_short(usage.output_tokens),
365 tokens_short(usage.reasoning_tokens),
366 tokens_short(usage.total_tokens)
367 )
368}
369
370pub(in crate::tui) fn status_style(p: Palette, status: Option<u16>) -> Style {
371 match status {
372 Some(s) if (200..300).contains(&s) => Style::default().fg(p.good),
373 Some(s) if (300..400).contains(&s) => Style::default().fg(p.accent),
374 Some(s) if (400..500).contains(&s) => Style::default().fg(p.warn),
375 Some(_) => Style::default().fg(p.bad),
376 None => Style::default().fg(p.muted),
377 }
378}
379
380fn build_session_rows(
381 active: Vec<ActiveRequest>,
382 recent: &[FinishedRequest],
383 overrides: &HashMap<String, String>,
384 config_overrides: &HashMap<String, String>,
385 stats: &HashMap<String, SessionStats>,
386) -> Vec<SessionRow> {
387 use std::collections::HashMap as StdHashMap;
388
389 let mut map: StdHashMap<Option<String>, SessionRow> = StdHashMap::new();
390
391 for req in active {
392 let key = req.session_id.clone();
393 let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
394 session_id: key,
395 cwd: req.cwd.clone(),
396 active_count: 0,
397 active_started_at_ms_min: Some(req.started_at_ms),
398 active_last_method: Some(req.method.clone()),
399 active_last_path: Some(req.path.clone()),
400 last_status: None,
401 last_duration_ms: None,
402 last_ended_at_ms: None,
403 last_model: req.model.clone(),
404 last_reasoning_effort: req.reasoning_effort.clone(),
405 last_provider_id: req.provider_id.clone(),
406 last_config_name: req.config_name.clone(),
407 last_usage: None,
408 total_usage: None,
409 turns_total: None,
410 turns_with_usage: None,
411 override_effort: None,
412 override_config_name: None,
413 });
414
415 entry.active_count += 1;
416 entry.active_started_at_ms_min = Some(
417 entry
418 .active_started_at_ms_min
419 .unwrap_or(req.started_at_ms)
420 .min(req.started_at_ms),
421 );
422 entry.active_last_method = Some(req.method);
423 entry.active_last_path = Some(req.path);
424 if entry.cwd.is_none() {
425 entry.cwd = req.cwd;
426 }
427 if let Some(effort) = req.reasoning_effort {
428 entry.last_reasoning_effort = Some(effort);
429 }
430 if entry.last_model.is_none() {
431 entry.last_model = req.model;
432 }
433 if entry.last_provider_id.is_none() {
434 entry.last_provider_id = req.provider_id;
435 }
436 if entry.last_config_name.is_none() {
437 entry.last_config_name = req.config_name;
438 }
439 }
440
441 for r in recent {
442 let key = r.session_id.clone();
443 let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
444 session_id: key,
445 cwd: r.cwd.clone(),
446 active_count: 0,
447 active_started_at_ms_min: None,
448 active_last_method: None,
449 active_last_path: None,
450 last_status: None,
451 last_duration_ms: None,
452 last_ended_at_ms: None,
453 last_model: r.model.clone(),
454 last_reasoning_effort: r.reasoning_effort.clone(),
455 last_provider_id: r.provider_id.clone(),
456 last_config_name: r.config_name.clone(),
457 last_usage: r.usage.clone(),
458 total_usage: None,
459 turns_total: None,
460 turns_with_usage: None,
461 override_effort: None,
462 override_config_name: None,
463 });
464
465 let should_update = entry
466 .last_ended_at_ms
467 .map(|t| r.ended_at_ms >= t)
468 .unwrap_or(true);
469 if should_update {
470 entry.last_status = Some(r.status_code);
471 entry.last_duration_ms = Some(r.duration_ms);
472 entry.last_ended_at_ms = Some(r.ended_at_ms);
473 if r.reasoning_effort.is_some() {
474 entry.last_reasoning_effort = r.reasoning_effort.clone();
475 }
476 if r.model.is_some() {
477 entry.last_model = r.model.clone();
478 }
479 if r.provider_id.is_some() {
480 entry.last_provider_id = r.provider_id.clone();
481 }
482 if r.config_name.is_some() {
483 entry.last_config_name = r.config_name.clone();
484 }
485 if r.usage.is_some() {
486 entry.last_usage = r.usage.clone();
487 }
488 }
489 if entry.cwd.is_none() {
490 entry.cwd = r.cwd.clone();
491 }
492 }
493
494 for (sid, st) in stats.iter() {
495 let key = Some(sid.clone());
496 let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
497 session_id: key,
498 cwd: None,
499 active_count: 0,
500 active_started_at_ms_min: None,
501 active_last_method: None,
502 active_last_path: None,
503 last_status: None,
504 last_duration_ms: None,
505 last_ended_at_ms: None,
506 last_model: st.last_model.clone(),
507 last_reasoning_effort: st.last_reasoning_effort.clone(),
508 last_provider_id: st.last_provider_id.clone(),
509 last_config_name: st.last_config_name.clone(),
510 last_usage: st.last_usage.clone(),
511 total_usage: Some(st.total_usage.clone()),
512 turns_total: None,
513 turns_with_usage: Some(st.turns_with_usage),
514 override_effort: None,
515 override_config_name: None,
516 });
517 entry.turns_total = Some(st.turns_total);
518 if entry.last_model.is_none() {
519 entry.last_model = st.last_model.clone();
520 }
521 if entry.last_reasoning_effort.is_none() {
522 entry.last_reasoning_effort = st.last_reasoning_effort.clone();
523 }
524 if entry.last_provider_id.is_none() {
525 entry.last_provider_id = st.last_provider_id.clone();
526 }
527 if entry.last_config_name.is_none() {
528 entry.last_config_name = st.last_config_name.clone();
529 }
530 if entry.last_usage.is_none() {
531 entry.last_usage = st.last_usage.clone();
532 }
533 if entry.total_usage.is_none() {
534 entry.total_usage = Some(st.total_usage.clone());
535 }
536 if entry.turns_with_usage.is_none() {
537 entry.turns_with_usage = Some(st.turns_with_usage);
538 }
539 }
540
541 for (sid, eff) in overrides.iter() {
542 let key = Some(sid.clone());
543 let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
544 session_id: key,
545 cwd: None,
546 active_count: 0,
547 active_started_at_ms_min: None,
548 active_last_method: None,
549 active_last_path: None,
550 last_status: None,
551 last_duration_ms: None,
552 last_ended_at_ms: None,
553 last_model: None,
554 last_reasoning_effort: None,
555 last_provider_id: None,
556 last_config_name: None,
557 last_usage: None,
558 total_usage: None,
559 turns_total: None,
560 turns_with_usage: None,
561 override_effort: None,
562 override_config_name: None,
563 });
564 entry.override_effort = Some(eff.clone());
565 }
566
567 for (sid, cfg_name) in config_overrides.iter() {
568 let key = Some(sid.clone());
569 let entry = map.entry(key.clone()).or_insert_with(|| SessionRow {
570 session_id: key,
571 cwd: None,
572 active_count: 0,
573 active_started_at_ms_min: None,
574 active_last_method: None,
575 active_last_path: None,
576 last_status: None,
577 last_duration_ms: None,
578 last_ended_at_ms: None,
579 last_model: None,
580 last_reasoning_effort: None,
581 last_provider_id: None,
582 last_config_name: None,
583 last_usage: None,
584 total_usage: None,
585 turns_total: None,
586 turns_with_usage: None,
587 override_effort: None,
588 override_config_name: None,
589 });
590 entry.override_config_name = Some(cfg_name.clone());
591 }
592
593 let mut rows = map.into_values().collect::<Vec<_>>();
594 rows.sort_by_key(|r| std::cmp::Reverse(session_sort_key(r)));
595 rows
596}
597
598pub(in crate::tui) async fn refresh_snapshot(
599 state: &ProxyState,
600 service_name: &str,
601 stats_days: usize,
602) -> Snapshot {
603 let (snap, config_meta) = tokio::join!(
604 crate::dashboard_core::build_dashboard_snapshot(state, service_name, 2_000, stats_days),
605 state.get_config_meta_overrides(service_name),
606 );
607
608 let rows = build_session_rows(
609 snap.active.clone(),
610 &snap.recent,
611 &snap.session_effort_overrides,
612 &snap.session_config_overrides,
613 &snap.session_stats,
614 );
615 Snapshot {
616 rows,
617 recent: snap.recent,
618 overrides: snap.session_effort_overrides,
619 config_overrides: snap.session_config_overrides,
620 global_override: snap.global_override,
621 config_meta_overrides: config_meta,
622 usage_rollup: snap.usage_rollup,
623 config_health: snap.config_health,
624 health_checks: snap.health_checks,
625 lb_view: snap.lb_view,
626 stats_5m: snap.stats_5m,
627 stats_1h: snap.stats_1h,
628 refreshed_at: Instant::now(),
629 }
630}
631
632pub(in crate::tui) fn filtered_requests_len(
633 snapshot: &Snapshot,
634 selected_session_idx: usize,
635) -> usize {
636 let selected_sid = snapshot
637 .rows
638 .get(selected_session_idx)
639 .and_then(|r| r.session_id.as_deref());
640 snapshot
641 .recent
642 .iter()
643 .filter(|r| match (selected_sid, r.session_id.as_deref()) {
644 (Some(sid), Some(rid)) => sid == rid,
645 (Some(_), None) => false,
646 (None, _) => true,
647 })
648 .take(60)
649 .count()
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 use unicode_width::UnicodeWidthStr;
657
658 #[test]
659 fn basename_handles_unix_and_windows_paths() {
660 assert_eq!(basename("/a/b/c"), "c");
661 assert_eq!(basename("/a/b/c/"), "c");
662 assert_eq!(basename(r"C:\a\b\c"), "c");
663 assert_eq!(basename(r"C:\a\b\c\"), "c");
664 }
665
666 #[test]
667 fn shorten_respects_display_width_cjk() {
668 let s = "你好世界";
669 let out = shorten(s, 5);
670 assert_eq!(out, "你好…");
671 assert_eq!(UnicodeWidthStr::width(out.as_str()), 5);
672 }
673
674 #[test]
675 fn shorten_middle_keeps_both_ends() {
676 let s = "abcdef";
677 assert_eq!(shorten_middle(s, 5), "ab…ef");
678 }
679}