Skip to content

Commit 18d9129

Browse files
agamaroraclaude
andcommitted
feat: claude panel UX — merge net+status into panel, add ETA to cap, fix 0% flash
- Move network speeds into Claude Usage panel (saves 3 rows) - Replace "via proxy" with P●/C● status dots for proxy and API health - Add ETA to cap based on utilization rate over elapsed window time - Move pace indicator (rising/steady/falling) from 5h line to status line - Fix 0.0% flash by removing is_window_expired zeroing - Normalize utilization display to handle both 0-1 and 0-100 API formats - Doctor mode now launches dashboard after enable/disable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d54bc5f commit 18d9129

4 files changed

Lines changed: 180 additions & 62 deletions

File tree

luna-monitor/crates/luna-monitor/src/app.rs

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ pub struct AppState {
3232
pub proxy_health: Option<ProxyHealth>,
3333
pub lhm_data: Option<LhmData>,
3434
pub disk_active: std::collections::HashMap<String, f64>,
35-
// Pace tracking: last 3 utilization readings
36-
pub pace_history: Vec<f64>,
35+
// Pace tracking: last 3 (epoch_secs, pct) readings
36+
pub pace_history: Vec<(f64, f64)>,
3737
// GPU temp tracking (session max/avg since LHM only gives current)
3838
pub gpu_temp_max: f32,
3939
pub gpu_temp_samples: Vec<f32>,
@@ -85,9 +85,12 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
8585
// Check for new usage data from background
8686
if let Some(ref mut rx) = usage_rx {
8787
while let Ok(data) = rx.try_recv() {
88-
// Track pace
89-
let pct = data.five_hour.utilization * 100.0;
90-
state.pace_history.push(pct);
88+
// Track pace with timestamp (normalize to 0-100)
89+
let u = data.five_hour.utilization;
90+
let pct = if u > 1.0 { u } else { u * 100.0 };
91+
let now = std::time::SystemTime::now()
92+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs_f64();
93+
state.pace_history.push((now, pct));
9194
if state.pace_history.len() > 3 {
9295
state.pace_history.remove(0);
9396
}
@@ -125,9 +128,11 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
125128
if let Some(entry) = state.rate_limit_collector.collect() {
126129
if state.rate_limit_collector.is_fresh() && state.usage.source != "api" {
127130
if let Some(util) = entry.five_h_utilization {
128-
// Track pace from proxy data too
129-
let pct = util * 100.0;
130-
state.pace_history.push(pct);
131+
// Track pace from proxy data too (normalize to 0-100)
132+
let pct = if util > 1.0 { util } else { util * 100.0 };
133+
let now = std::time::SystemTime::now()
134+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs_f64();
135+
state.pace_history.push((now, pct));
131136
if state.pace_history.len() > 3 {
132137
state.pace_history.remove(0);
133138
}
@@ -182,14 +187,14 @@ pub fn run(config: &Config, usage_rx: Option<tokio::sync::mpsc::UnboundedReceive
182187
Ok(())
183188
}
184189

185-
/// Compute pace string from last 3 utilization readings.
186-
fn compute_pace(history: &[f64]) -> &'static str {
190+
/// Compute pace string from last 3 (epoch, pct) readings.
191+
fn compute_pace(history: &[(f64, f64)]) -> &'static str {
187192
if history.len() < 2 {
188193
return "";
189194
}
190-
let last = *history.last().unwrap();
191-
let prev = history[history.len() - 2];
192-
let delta = last - prev;
195+
let (_, pct_last) = history[history.len() - 1];
196+
let (_, pct_prev) = history[history.len() - 2];
197+
let delta = pct_last - pct_prev;
193198
if delta > 2.0 {
194199
"↑ rising"
195200
} else if delta < -2.0 {
@@ -199,15 +204,78 @@ fn compute_pace(history: &[f64]) -> &'static str {
199204
}
200205
}
201206

207+
/// Compute ETA to cap from current utilization and window reset time.
208+
/// Uses: rate = current_pct / elapsed_in_window, then extrapolates to 100%.
209+
fn compute_eta(current_pct: f64, resets_at: &str) -> String {
210+
if current_pct < 0.1 {
211+
return String::new(); // nothing to extrapolate
212+
}
213+
214+
// Parse resets_at to get seconds until reset
215+
let secs_until_reset = parse_reset_secs(resets_at);
216+
if secs_until_reset <= 0.0 {
217+
return String::new();
218+
}
219+
220+
let window_total: f64 = 5.0 * 3600.0; // 5h window in seconds
221+
let elapsed = window_total - secs_until_reset;
222+
if elapsed < 60.0 {
223+
return String::new(); // window just started, too noisy
224+
}
225+
226+
// rate in %/sec based on average consumption so far
227+
let rate = current_pct / elapsed;
228+
let remaining_pct = 100.0 - current_pct;
229+
let eta_secs = remaining_pct / rate;
230+
231+
if eta_secs <= 0.0 {
232+
return "at cap".to_string();
233+
}
234+
235+
let total_min = (eta_secs / 60.0) as u64;
236+
if total_min >= 60 {
237+
format!("ETA ~{}h {}m to cap", total_min / 60, total_min % 60)
238+
} else if total_min > 0 {
239+
format!("ETA ~{}m to cap", total_min)
240+
} else {
241+
"ETA <1m to cap".to_string()
242+
}
243+
}
244+
245+
/// Parse resets_at string into seconds remaining. Returns 0 on failure.
246+
fn parse_reset_secs(resets_at: &str) -> f64 {
247+
if resets_at.is_empty() {
248+
return 0.0;
249+
}
250+
// Try epoch
251+
if let Ok(epoch) = resets_at.parse::<f64>() {
252+
if epoch > 1_000_000_000.0 {
253+
let now = std::time::SystemTime::now()
254+
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs_f64();
255+
return (epoch - now).max(0.0);
256+
}
257+
}
258+
// Try ISO 8601
259+
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(resets_at) {
260+
let remaining = dt.signed_duration_since(chrono::Utc::now());
261+
return remaining.num_seconds().max(0) as f64;
262+
}
263+
// Try UTC without timezone
264+
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(resets_at, "%Y-%m-%dT%H:%M:%SZ") {
265+
let remaining = dt.signed_duration_since(chrono::Utc::now().naive_utc());
266+
return remaining.num_seconds().max(0) as f64;
267+
}
268+
0.0
269+
}
270+
202271
fn render(frame: &mut ratatui::Frame, area: Rect, state: &AppState, config: &Config) {
203272
let mut constraints = Vec::new();
204273
let claude_enabled = config.claude_enabled;
205274

206275
if claude_enabled {
207-
constraints.push(Constraint::Length(8)); // Claude Status
276+
constraints.push(Constraint::Length(8)); // Claude Status (includes net line)
208277
constraints.push(Constraint::Length(4)); // CPU + Temps (side by side, 2 border + 2 content)
209278
constraints.push(Constraint::Length(5)); // Memory + GPU (GPU needs 3 content lines)
210-
constraints.push(Constraint::Length(3)); // Network (compact)
211279
constraints.push(Constraint::Length(6)); // Disks
212280
constraints.push(Constraint::Min(5)); // Processes
213281
} else {
@@ -221,16 +289,24 @@ fn render(frame: &mut ratatui::Frame, area: Rect, state: &AppState, config: &Con
221289
let chunks = Layout::vertical(constraints).split(area);
222290
let mut idx = 0;
223291

224-
// Claude Status
292+
// Claude Status (includes network)
225293
if claude_enabled {
226294
let proxy_running = state.proxy_health.is_some();
295+
let claude_reachable = state.usage.error.is_none() && state.usage.fetched_at.is_some();
296+
let util = state.usage.five_hour.utilization;
297+
let current_pct = if util > 1.0 { util } else { util * 100.0 };
227298
let pace = compute_pace(&state.pace_history);
299+
let eta = compute_eta(current_pct, &state.usage.five_hour.resets_at);
300+
let (rx_now, tx_now, rx_avg, tx_avg, _, _) = state.system.net_speeds();
301+
let net = panels::claude_status::NetSpeeds { rx_now, tx_now, rx_avg, tx_avg };
228302
panels::claude_status::render(
229303
frame, chunks[idx],
230304
&state.usage,
231-
state.proxy_health.as_ref(),
232305
proxy_running,
306+
claude_reachable,
233307
pace,
308+
&eta,
309+
&net,
234310
);
235311
idx += 1;
236312
}
@@ -293,10 +369,12 @@ fn render(frame: &mut ratatui::Frame, area: Rect, state: &AppState, config: &Con
293369
panels::memory::render(frame, mem_gpu_area, mem_used, mem_total, swap_used, swap_total);
294370
}
295371

296-
// Network (compact)
297-
let (rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) = state.system.net_speeds();
298-
panels::network::render(frame, chunks[idx], rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak);
299-
idx += 1;
372+
// Network (standalone only when Claude panel is disabled — otherwise embedded in Claude panel)
373+
if !claude_enabled {
374+
let (rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak) = state.system.net_speeds();
375+
panels::network::render(frame, chunks[idx], rx_now, tx_now, rx_avg, tx_avg, rx_peak, tx_peak);
376+
idx += 1;
377+
}
300378

301379
// Disks
302380
let disk_io = state.system.disk_io(&state.disk_active);

luna-monitor/crates/luna-monitor/src/collectors/claude.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,6 @@ impl ClaudeCollector {
185185
.and_then(|v| v.as_f64()).unwrap_or(0.0);
186186
}
187187

188-
// Detect expired windows
189-
if is_window_expired(&data.five_hour.resets_at) {
190-
data.five_hour.utilization = 0.0;
191-
}
192-
if is_window_expired(&data.seven_day.resets_at) {
193-
data.seven_day.utilization = 0.0;
194-
}
195-
196188
Ok(data)
197189
}
198190

luna-monitor/crates/luna-monitor/src/main.rs

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,12 @@ fn main() {
8383
return;
8484
}
8585

86-
// --doctor
86+
// --doctor (launches dashboard after enable/disable)
8787
if args.doctor {
88-
run_doctor();
89-
return;
88+
if !run_doctor() {
89+
return; // option 3 (reset) — exit
90+
}
91+
// fall through to dashboard
9092
}
9193

9294
// --enable-proxy / --disable-proxy
@@ -409,7 +411,8 @@ fn first_run_prompt(config: &mut Config) {
409411
println!();
410412
}
411413

412-
fn run_doctor() {
414+
/// Returns true if dashboard should launch after, false to exit.
415+
fn run_doctor() -> bool {
413416
let port = paths::DEFAULT_PORT;
414417
let proxy_running = collectors::rate_limit::RateLimitCollector::proxy_health(port).is_some();
415418

@@ -421,9 +424,9 @@ fn run_doctor() {
421424
println!("Current status: proxy not running");
422425
}
423426
println!();
424-
println!("1) Enable proxy (route Claude Code through luna-monitor for live usage %)");
425-
println!("2) Disable proxy (direct Claude Code, system metrics only)");
426-
println!("3) Reset everything (remove all luna-monitor config, restore vanilla Claude Code)");
427+
println!("1) Enable proxy and start dashboard");
428+
println!("2) Disable proxy and start dashboard (system metrics only)");
429+
println!("3) Reset everything (remove all config, exit)");
427430
println!();
428431
print!("Choose [1-3]: ");
429432
use std::io::Write;
@@ -439,27 +442,34 @@ fn run_doctor() {
439442
config.save();
440443
let mut pm = ProxyManager::new(port);
441444
if pm.start_proxy() {
442-
println!("Proxy enabled on port {}", port);
445+
println!("Proxy enabled. Launching dashboard...");
446+
println!();
443447
} else {
444-
println!("Failed to start proxy");
448+
println!("Failed to start proxy. Launching dashboard without proxy...");
449+
println!();
445450
}
451+
true // launch dashboard
446452
}
447453
"2" => {
448454
let mut config = Config::load();
449455
config.proxy_enabled = Some(false);
450456
config.save();
451457
proxy_lifecycle::remove_proxy_setting();
452-
println!("Proxy disabled");
458+
println!("Proxy disabled. Launching dashboard...");
459+
println!();
460+
true // launch dashboard
453461
}
454462
"3" => {
455463
proxy_lifecycle::remove_proxy_setting();
456464
if let Some(dir) = paths::luna_dir() {
457465
let _ = std::fs::remove_dir_all(&dir);
458466
}
459467
println!("Reset complete. All luna-monitor config removed.");
468+
false // exit
460469
}
461470
_ => {
462471
println!("Invalid choice");
472+
false
463473
}
464474
}
465475
}

0 commit comments

Comments
 (0)