roboticus_cli/cli/admin/misc/
local_metrics.rs1pub async fn cmd_metrics(
2 url: &str,
3 kind: &str,
4 hours: Option<i64>,
5 json: bool,
6) -> Result<(), Box<dyn std::error::Error>> {
7 let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
8 let (OK, ACTION, WARN, DETAIL, ERR) = icons();
9 let c = RoboticusClient::new(url)?;
10
11 match kind {
12 "costs" => {
13 let data = c.get("/api/stats/costs").await.map_err(|e| {
14 RoboticusClient::check_connectivity_hint(&*e);
15 e
16 })?;
17 if json {
18 println!("{}", serde_json::to_string_pretty(&data)?);
19 return Ok(());
20 }
21 heading("Inference Costs");
22 let costs = data["costs"].as_array();
23 match costs {
24 Some(arr) if !arr.is_empty() => {
25 let mut suppressed_zero_rows = 0usize;
26 let filtered: Vec<&serde_json::Value> = arr
27 .iter()
28 .filter(|c| {
29 let tin = c["tokens_in"].as_i64().unwrap_or(0);
30 let tout = c["tokens_out"].as_i64().unwrap_or(0);
31 let cost = c["cost"].as_f64().unwrap_or(0.0);
32 let cached = c["cached"].as_bool().unwrap_or(false);
33 let keep = cached || tin != 0 || tout != 0 || cost > 0.0;
34 if !keep {
35 suppressed_zero_rows += 1;
36 }
37 keep
38 })
39 .collect();
40 if filtered.is_empty() {
41 empty_state(
42 "No billable/non-empty inference costs recorded (all recent rows were zero-token/no-cost events)",
43 );
44 if suppressed_zero_rows > 0 {
45 kv("Suppressed Zero Rows", &suppressed_zero_rows.to_string());
46 }
47 return Ok(());
48 }
49
50 let widths = [20, 16, 10, 10, 10, 8];
51 table_header(
52 &[
53 "Model",
54 "Provider",
55 "Tokens In",
56 "Tokens Out",
57 "Cost",
58 "Cached",
59 ],
60 &widths,
61 );
62
63 let mut total_cost = 0.0f64;
64 let mut total_in = 0i64;
65 let mut total_out = 0i64;
66
67 for c in &filtered {
68 let model = truncate_id(c["model"].as_str().unwrap_or(""), 17);
69 let provider = c["provider"].as_str().unwrap_or("").to_string();
70 let tin = c["tokens_in"].as_i64().unwrap_or(0);
71 let tout = c["tokens_out"].as_i64().unwrap_or(0);
72 let cost = c["cost"].as_f64().unwrap_or(0.0);
73 let cached = c["cached"].as_bool().unwrap_or(false);
74
75 total_cost += cost;
76 total_in += tin;
77 total_out += tout;
78
79 table_row(
80 &[
81 format!("{ACCENT}{model}{RESET}"),
82 provider,
83 tin.to_string(),
84 tout.to_string(),
85 format!("${cost:.4}"),
86 if cached {
87 OK.to_string()
88 } else {
89 format!("{DIM}-{RESET}")
90 },
91 ],
92 &widths,
93 );
94 }
95 table_separator(&widths);
96 eprintln!();
97 kv_accent("Total Cost", &format!("${total_cost:.4}"));
98 kv("Total Tokens", &format!("{total_in} in / {total_out} out"));
99 kv("Requests", &filtered.len().to_string());
100 if suppressed_zero_rows > 0 {
101 kv("Suppressed Zero Rows", &suppressed_zero_rows.to_string());
102 }
103 if !filtered.is_empty() {
104 kv(
105 "Avg Cost/Request",
106 &format!("${:.4}", total_cost / filtered.len() as f64),
107 );
108 }
109 }
110 _ => empty_state("No inference costs recorded"),
111 }
112 }
113 "transactions" => {
114 let h = hours.unwrap_or(24);
115 let data = c
116 .get(&format!("/api/stats/transactions?hours={h}"))
117 .await
118 .map_err(|e| {
119 RoboticusClient::check_connectivity_hint(&*e);
120 e
121 })?;
122 if json {
123 println!("{}", serde_json::to_string_pretty(&data)?);
124 return Ok(());
125 }
126 heading(&format!("Transactions (last {h}h)"));
127 let txs = data["transactions"].as_array();
128 match txs {
129 Some(arr) if !arr.is_empty() => {
130 let widths = [14, 12, 12, 20, 22];
131 table_header(&["ID", "Type", "Amount", "Counterparty", "Time"], &widths);
132
133 let mut total = 0.0f64;
134 for t in arr {
135 let id = truncate_id(t["id"].as_str().unwrap_or(""), 11);
136 let tx_type = t["tx_type"].as_str().unwrap_or("").to_string();
137 let amount = t["amount"].as_f64().unwrap_or(0.0);
138 let currency = t["currency"].as_str().unwrap_or("USD");
139 let counter = t["counterparty"].as_str().unwrap_or("-").to_string();
140 let time = t["created_at"]
141 .as_str()
142 .map(|t| if t.len() > 19 { &t[..19] } else { t })
143 .unwrap_or("")
144 .to_string();
145
146 total += amount;
147
148 table_row(
149 &[
150 format!("{MONO}{id}{RESET}"),
151 tx_type,
152 format!("{amount:.2} {currency}"),
153 counter,
154 format!("{DIM}{time}{RESET}"),
155 ],
156 &widths,
157 );
158 }
159 eprintln!();
160 kv_accent("Total", &format!("{total:.2}"));
161 kv("Count", &arr.len().to_string());
162 }
163 _ => empty_state("No transactions in this time window"),
164 }
165 }
166 "cache" => {
167 let data = c.get("/api/stats/cache").await.map_err(|e| {
168 RoboticusClient::check_connectivity_hint(&*e);
169 e
170 })?;
171 if json {
172 println!("{}", serde_json::to_string_pretty(&data)?);
173 return Ok(());
174 }
175 heading("Cache Statistics");
176 let hits = data["hits"].as_u64().unwrap_or(0);
177 let misses = data["misses"].as_u64().unwrap_or(0);
178 let entries = data["entries"].as_u64().unwrap_or(0);
179 let hit_rate = data["hit_rate"].as_f64().unwrap_or(0.0);
180
181 kv_accent("Entries", &entries.to_string());
182 kv("Hits", &hits.to_string());
183 kv("Misses", &misses.to_string());
184
185 let bar_width = 30;
186 let filled = (hit_rate * bar_width as f64 / 100.0) as usize;
187 let empty_part = bar_width - filled;
188 let bar = format!(
189 "{GREEN}{}{DIM}{}{RESET} {:.1}%",
190 "\u{2588}".repeat(filled),
191 "\u{2591}".repeat(empty_part),
192 hit_rate
193 );
194 kv("Hit Rate", &bar);
195 }
196 _ => {
197 return Err(
198 format!("unknown metric kind: {kind}. Use: costs, transactions, cache").into(),
199 );
200 }
201 }
202
203 eprintln!();
204 Ok(())
205}
206