use anyhow::Result;
use bytesize::ByteSize;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::prelude::*;
use ratatui::widgets::*;
use std::io::stdout;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::cli;
use crate::config::Config;
use crate::daemon;
use crate::events::{self, BuildEvent, EventResult, EventTailer};
#[derive(Debug, Clone, Copy, PartialEq)]
enum Tab {
Build,
Projects,
Store,
Transfer,
Passthrough,
}
fn tab_needs_entries(tab: Tab) -> bool {
matches!(tab, Tab::Store)
}
#[derive(Debug, Clone, Copy)]
enum SortMode {
Size,
Hits,
Age,
Name,
}
impl SortMode {
fn label(&self) -> &str {
match self {
SortMode::Size => "size",
SortMode::Hits => "hits",
SortMode::Age => "age",
SortMode::Name => "name",
}
}
fn next(&self) -> Self {
match self {
SortMode::Size => SortMode::Hits,
SortMode::Hits => SortMode::Age,
SortMode::Age => SortMode::Name,
SortMode::Name => SortMode::Size,
}
}
}
type StatsSnapshot = cli::StatsSnapshot;
fn shorten_home(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rest) = path.strip_prefix(&home)
{
return format!("~/{}", rest.display());
}
path.display().to_string()
}
fn fetch_stats(config: &Config, include_entries: bool, sort_by: &str) -> StatsSnapshot {
cli::fetch_stats_snapshot(config, include_entries, sort_by, Some(24))
}
struct ProjectScanData {
project_targets: Vec<cli::TargetEntry>,
link_stats: cli::LinkStats,
scanning: bool,
scanned: bool,
}
impl Default for ProjectScanData {
fn default() -> Self {
Self {
project_targets: Vec::new(),
link_stats: cli::LinkStats {
store_bytes: 0,
linked_refs: 0,
saved_bytes: 0,
},
scanning: false,
scanned: false,
}
}
}
struct AppState {
config: Config,
active_tab: Tab,
tailer: EventTailer,
events: Vec<BuildEvent>,
scroll_offset: usize,
filter: String,
filter_active: bool,
sort_mode: SortMode,
store_scroll: usize,
stats_snapshot: StatsSnapshot,
stats_loaded: bool,
last_stats_fetch: Instant,
project_scan: Arc<Mutex<ProjectScanData>>,
last_project_refresh: Instant,
project_scroll: usize,
transfer_scroll: usize,
passthrough_scroll: usize,
prev_bytes_uploaded: u64,
prev_bytes_downloaded: u64,
upload_speed_bps: f64,
download_speed_bps: f64,
rustc_version_slot: Arc<Mutex<Option<String>>>,
stats_result_slot: Arc<Mutex<Option<StatsSnapshot>>>,
stats_fetch_in_flight: bool,
stats_fetch_requested_entries: bool,
should_quit: bool,
rustc_version: String,
wrapper_status: String,
service_installed: bool,
}
const PROJECT_REFRESH_INTERVAL: Duration = Duration::from_secs(10);
const SNAPSHOT_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
pub fn run_monitor(config: &Config, since_hours: Option<u64>) -> Result<()> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let tailer = if since_hours.is_some() {
EventTailer::from_start(config.event_log_path())
} else {
EventTailer::new(config.event_log_path())
};
let initial_events = if let Some(hours) = since_hours {
let since = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
events::read_events_since(&config.event_log_path(), since).unwrap_or_default()
} else {
Vec::new()
};
let rustc_version_slot: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
{
let slot = Arc::clone(&rustc_version_slot);
std::thread::spawn(move || {
let ver = std::process::Command::new("rustc")
.arg("--version")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
if let Ok(mut s) = slot.lock() {
*s = Some(ver);
}
});
}
let project_scan = Arc::new(Mutex::new(ProjectScanData::default()));
let stats_snapshot = StatsSnapshot::default();
let stats_result_slot: Arc<Mutex<Option<StatsSnapshot>>> = Arc::new(Mutex::new(None));
let service_installed = crate::service::service_file_path()
.map(|p| p.exists())
.unwrap_or(false);
let mut state = AppState {
config: config.clone(),
active_tab: Tab::Build,
tailer,
events: initial_events,
scroll_offset: 0,
filter: String::new(),
filter_active: false,
sort_mode: SortMode::Size,
store_scroll: 0,
stats_snapshot,
stats_loaded: false,
last_stats_fetch: Instant::now() - SNAPSHOT_REFRESH_INTERVAL, project_scan,
last_project_refresh: Instant::now(),
project_scroll: 0,
transfer_scroll: 0,
passthrough_scroll: 0,
prev_bytes_uploaded: 0,
prev_bytes_downloaded: 0,
upload_speed_bps: 0.0,
download_speed_bps: 0.0,
rustc_version_slot: Arc::clone(&rustc_version_slot),
stats_result_slot: Arc::clone(&stats_result_slot),
stats_fetch_in_flight: false,
stats_fetch_requested_entries: false,
should_quit: false,
rustc_version: "\u{2026}".to_string(), wrapper_status: crate::wrapper_config::wrapper_status_line(),
service_installed,
};
loop {
if let Ok(new_events) = state.tailer.poll() {
state.events.extend(new_events);
}
if let Ok(mut slot) = state.rustc_version_slot.lock()
&& let Some(ver) = slot.take()
{
state.rustc_version = ver;
}
if let Ok(mut slot) = state.stats_result_slot.lock()
&& let Some(new_snap) = slot.take()
{
let previous_entries = if state.stats_fetch_requested_entries {
Vec::new()
} else {
std::mem::take(&mut state.stats_snapshot.entries)
};
let mut new_snap = new_snap;
if !state.stats_fetch_requested_entries {
new_snap.entries = previous_entries;
}
let old_up = state.stats_snapshot.bytes_uploaded;
let old_down = state.stats_snapshot.bytes_downloaded;
let interval = SNAPSHOT_REFRESH_INTERVAL.as_secs_f64();
state.upload_speed_bps =
(new_snap.bytes_uploaded.saturating_sub(old_up)) as f64 / interval;
state.download_speed_bps =
(new_snap.bytes_downloaded.saturating_sub(old_down)) as f64 / interval;
state.prev_bytes_uploaded = new_snap.bytes_uploaded;
state.prev_bytes_downloaded = new_snap.bytes_downloaded;
state.stats_snapshot = new_snap;
state.stats_loaded = true;
state.stats_fetch_in_flight = false;
}
if !state.stats_fetch_in_flight
&& state.last_stats_fetch.elapsed() >= SNAPSHOT_REFRESH_INTERVAL
{
state.stats_fetch_in_flight = true;
state.last_stats_fetch = Instant::now();
let cfg = state.config.clone();
let sort = state.sort_mode.label().to_string();
let include_entries = tab_needs_entries(state.active_tab);
state.stats_fetch_requested_entries = include_entries;
let slot = Arc::clone(&state.stats_result_slot);
std::thread::spawn(move || {
let snap = fetch_stats(&cfg, include_entries, &sort);
if let Ok(mut s) = slot.lock() {
*s = Some(snap);
}
});
}
if state.active_tab == Tab::Projects
&& state.last_project_refresh.elapsed() >= PROJECT_REFRESH_INTERVAL
{
let is_scanning = state
.project_scan
.lock()
.map(|s| s.scanning)
.unwrap_or(false);
if !is_scanning {
spawn_project_scan(Arc::clone(&state.project_scan), state.config.store_dir());
state.last_project_refresh = Instant::now();
}
}
terminal.draw(|frame| draw_ui(frame, &state))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
handle_key(&mut state, key.code);
}
if state.should_quit {
break;
}
}
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
fn spawn_project_scan(stats: Arc<Mutex<ProjectScanData>>, store_dir: std::path::PathBuf) {
if let Ok(mut s) = stats.lock() {
s.scanning = true;
for t in s.project_targets.iter_mut() {
t.stale = true;
}
}
std::thread::spawn(move || {
let link = cli::compute_link_stats(&store_dir);
if let Ok(mut s) = stats.lock() {
s.link_stats = link;
}
let root = std::env::current_dir().unwrap_or_default();
let mut all_targets = Vec::new();
cli::find_target_dirs(&root, &mut all_targets);
for target in all_targets {
if let Ok(mut s) = stats.lock() {
if let Some(existing) = s.project_targets.iter_mut().find(|e| e.path == target.path)
{
*existing = target;
} else {
s.project_targets.push(target);
}
s.project_targets
.sort_by_key(|entry| std::cmp::Reverse(entry.size));
}
}
if let Ok(mut s) = stats.lock() {
s.project_targets.retain(|t| !t.stale);
s.scanning = false;
s.scanned = true;
}
});
}
fn handle_key(state: &mut AppState, key: KeyCode) {
if state.filter_active {
match key {
KeyCode::Esc | KeyCode::Enter => state.filter_active = false,
KeyCode::Backspace => {
state.filter.pop();
}
KeyCode::Char(c) => state.filter.push(c),
_ => {}
}
return;
}
match key {
KeyCode::Char('q') | KeyCode::Esc => state.should_quit = true,
KeyCode::Char('1') => state.active_tab = Tab::Build,
KeyCode::Char('2') => {
state.active_tab = Tab::Projects;
state.last_project_refresh = Instant::now() - PROJECT_REFRESH_INTERVAL;
}
KeyCode::Char('3') => {
state.active_tab = Tab::Store;
state.last_stats_fetch = Instant::now() - SNAPSHOT_REFRESH_INTERVAL;
}
KeyCode::Char('4') => state.active_tab = Tab::Transfer,
KeyCode::Char('5') => state.active_tab = Tab::Passthrough,
KeyCode::BackTab | KeyCode::Tab => match state.active_tab {
Tab::Build => {
state.active_tab = Tab::Projects;
state.last_project_refresh = Instant::now() - PROJECT_REFRESH_INTERVAL;
}
Tab::Projects => {
state.active_tab = Tab::Store;
state.last_stats_fetch = Instant::now() - SNAPSHOT_REFRESH_INTERVAL;
}
Tab::Store => state.active_tab = Tab::Transfer,
Tab::Transfer => state.active_tab = Tab::Passthrough,
Tab::Passthrough => state.active_tab = Tab::Build,
},
KeyCode::Up => match state.active_tab {
Tab::Build => state.scroll_offset = state.scroll_offset.saturating_sub(1),
Tab::Projects => state.project_scroll = state.project_scroll.saturating_sub(1),
Tab::Store => state.store_scroll = state.store_scroll.saturating_sub(1),
Tab::Transfer => state.transfer_scroll = state.transfer_scroll.saturating_sub(1),
Tab::Passthrough => {
state.passthrough_scroll = state.passthrough_scroll.saturating_sub(1)
}
},
KeyCode::Down => match state.active_tab {
Tab::Build => state.scroll_offset += 1,
Tab::Projects => state.project_scroll += 1,
Tab::Store => state.store_scroll += 1,
Tab::Transfer => state.transfer_scroll += 1,
Tab::Passthrough => state.passthrough_scroll += 1,
},
KeyCode::Char('f') if state.active_tab == Tab::Build => {
state.filter_active = true;
}
KeyCode::Char('c') if state.active_tab == Tab::Build => {
state.events.clear();
}
KeyCode::Char('s') if state.active_tab == Tab::Store => {
state.sort_mode = state.sort_mode.next();
state.last_stats_fetch = Instant::now() - SNAPSHOT_REFRESH_INTERVAL;
}
KeyCode::Char('f') if state.active_tab == Tab::Store => {
state.filter_active = true;
}
KeyCode::Char('f') if state.active_tab == Tab::Passthrough => {
state.filter_active = true;
}
KeyCode::Char('r') if state.active_tab == Tab::Projects => {
state.last_project_refresh = Instant::now() - PROJECT_REFRESH_INTERVAL;
}
_ => {}
}
}
fn draw_ui(frame: &mut Frame, state: &AppState) {
let area = frame.area();
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), ])
.split(area);
draw_tab_bar(frame, state, chunks[0]);
match state.active_tab {
Tab::Build => draw_build_tab(frame, state, chunks[1]),
Tab::Projects => draw_projects_tab(frame, state, chunks[1]),
Tab::Store => draw_store_tab(frame, state, chunks[1]),
Tab::Transfer => draw_transfer_tab(frame, state, chunks[1]),
Tab::Passthrough => draw_passthrough_tab(frame, state, chunks[1]),
}
}
fn draw_tab_bar(frame: &mut Frame, state: &AppState, area: Rect) {
let style_for = |tab: Tab| -> Style {
if state.active_tab == tab {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
};
let tabs = Line::from(vec![
Span::styled(" [1] Build ", style_for(Tab::Build)),
Span::raw(" "),
Span::styled("[2] Projects", style_for(Tab::Projects)),
Span::raw(" "),
Span::styled("[3] Store ", style_for(Tab::Store)),
Span::raw(" "),
Span::styled("[4] Transfer ", style_for(Tab::Transfer)),
Span::raw(" "),
Span::styled("[5] Passthrough ", style_for(Tab::Passthrough)),
]);
frame.render_widget(Paragraph::new(tabs), area);
}
fn draw_build_tab(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(9), Constraint::Min(8), Constraint::Length(5), Constraint::Length(1), ])
.split(area);
draw_stats_bar(frame, state, chunks[0]);
draw_live_build(frame, state, chunks[1]);
draw_sparkline(frame, state, chunks[2]);
draw_build_help(frame, state, chunks[3]);
}
fn draw_stats_bar(frame: &mut Frame, state: &AppState, area: Rect) {
let snap = &state.stats_snapshot;
let daemon_tag = if !state.stats_loaded {
" (loading)"
} else {
match (snap.daemon_connected, state.service_installed) {
(true, true) => "",
(true, false) => " (no service)",
(false, true) => " (daemon offline)",
(false, false) => " (daemon offline, no service)",
}
};
let block = Block::bordered().title(format!(" kache monitor{daemon_tag} "));
let total = snap.event_stats.local_hits
+ snap.event_stats.prefetch_hits
+ snap.event_stats.remote_hits
+ snap.event_stats.misses;
let (local_pct, remote_pct, miss_pct) = if total > 0 {
(
((snap.event_stats.local_hits + snap.event_stats.prefetch_hits) as f64 / total as f64)
* 100.0,
(snap.event_stats.remote_hits as f64 / total as f64) * 100.0,
(snap.event_stats.misses as f64 / total as f64) * 100.0,
)
} else {
(0.0, 0.0, 0.0)
};
let store_pct = if snap.max_size > 0 {
(snap.total_size as f64 / snap.max_size as f64) * 100.0
} else {
0.0
};
let remote_status = if state.config.remote.is_some() {
"configured"
} else {
"not configured"
};
let wrapper_status = &state.wrapper_status;
let kache_version = crate::VERSION;
let daemon_info = if !state.stats_loaded {
"daemon: checking".to_string()
} else if snap.daemon_connected && !snap.daemon_version.is_empty() {
let epoch = snap.daemon_build_epoch;
let my_epoch = crate::daemon::build_epoch();
if epoch == my_epoch {
format!("daemon: v{} (epoch {epoch})", snap.daemon_version)
} else {
format!(
"daemon: v{} (epoch {epoch}) \u{2190} MISMATCH, auto-restart pending",
snap.daemon_version
)
}
} else {
"daemon: offline".to_string()
};
let my_epoch = crate::daemon::build_epoch();
let dedup_line = {
let blob_savings = state.stats_snapshot.blob_stats.as_ref();
let scan_part = if let Ok(scan_stats) = state.project_scan.lock() {
let ls = &scan_stats.link_stats;
let dedup_status = if !state.stats_loaded || scan_stats.scanning {
"calculating"
} else if scan_stats.scanned {
"idle"
} else {
"not scanned"
};
if ls.saved_bytes > 0 {
format!(
"{} via {} hardlinks Scan: {dedup_status}",
ByteSize(ls.saved_bytes),
ls.linked_refs,
)
} else {
format!("no active hardlinks Scan: {dedup_status}")
}
} else {
"n/a".to_string()
};
if let Some(bs) = blob_savings {
let pct = if bs.total_logical_size > 0 {
bs.savings as f64 / bs.total_logical_size as f64 * 100.0
} else {
0.0
};
format!(
" Dedup: {} saved ({:.1}%) Blobs: {} physical Hardlinks: {scan_part}",
ByteSize(bs.savings),
pct,
ByteSize(bs.total_blob_size),
)
} else if state.stats_loaded {
format!(" Dedup: {scan_part}")
} else {
" Dedup: calculating...".to_string()
}
};
let transfer_line = if !state.stats_loaded {
" Transfer: calculating...".to_string()
} else if snap.daemon_connected {
format!(
" Transfer: ↑ {} uploading ↓ {} downloading",
snap.pending_uploads, snap.active_downloads,
)
} else {
" Transfer: n/a (daemon offline)".to_string()
};
let hit_line = if !state.stats_loaded {
format!(" Hit rate: calculating... Remote: {remote_status}")
} else {
let count_hit_rate = crate::cli::count_hit_rate(&snap.event_stats);
let weighted_hit_rate = crate::cli::compile_weighted_hit_rate(&snap.event_stats);
let miss_time_share = if snap.event_stats.total_elapsed_ms > 0 {
Some(
(snap.event_stats.miss_elapsed_ms as f64
/ snap.event_stats.total_elapsed_ms as f64)
* 100.0,
)
} else {
None
};
match (weighted_hit_rate, miss_time_share) {
(Some(weighted), Some(miss_share)) => format!(
" Hit rate: {count_hit_rate:.0}% count | {weighted:.0}% weighted | {miss_share:.0}% miss-time Remote: {remote_status}",
),
(Some(weighted), None) => format!(
" Hit rate: {count_hit_rate:.0}% count | {weighted:.0}% weighted Remote: {remote_status}",
),
_ => format!(
" Hit rate: {local_pct:.0}% local | {remote_pct:.0}% remote | {miss_pct:.0}% miss Remote: {remote_status}",
),
}
};
let store_line = if state.stats_loaded {
Line::from(format!(
" Store: {} / {} [{:>5.1}%] {} entries",
ByteSize(snap.total_size),
ByteSize(snap.max_size),
store_pct,
snap.entry_count,
))
} else {
Line::from(" Store: calculating...")
};
let text = vec![
store_line,
Line::from(hit_line),
Line::from(dedup_line),
Line::from(transfer_line),
Line::from(format!(" {wrapper_status} {}", state.rustc_version)),
Line::from(format!(
" kache v{kache_version} (epoch {my_epoch}) {daemon_info} Cache: {}",
shorten_home(&state.config.cache_dir)
)),
];
let paragraph = Paragraph::new(text).block(block);
frame.render_widget(paragraph, area);
}
fn draw_live_build(frame: &mut Frame, state: &AppState, area: Rect) {
let border_style = Style::default().fg(Color::Cyan);
let block = Block::bordered()
.title(" Live Build ")
.border_style(border_style);
let filtered_events: Vec<&BuildEvent> = state
.events
.iter()
.filter(|e| {
if state.filter.is_empty() {
true
} else {
e.crate_name.contains(&state.filter)
}
})
.collect();
let max_visible = (area.height as usize).saturating_sub(2);
let start = if filtered_events.len() > max_visible + state.scroll_offset {
filtered_events.len() - max_visible - state.scroll_offset
} else {
0
};
let visible: Vec<Line> = filtered_events
.iter()
.skip(start)
.take(max_visible)
.map(|event| {
let (icon, style) = match event.result {
EventResult::LocalHit => ("✓", Style::default().fg(Color::Green)),
EventResult::PrefetchHit => ("⇣", Style::default().fg(Color::Cyan)),
EventResult::RemoteHit => ("↓", Style::default().fg(Color::Blue)),
EventResult::Miss => ("✗", Style::default().fg(Color::Yellow)),
EventResult::Error => ("!", Style::default().fg(Color::Red)),
EventResult::Passthrough => ("→", Style::default().fg(Color::Magenta)),
EventResult::Skipped => ("→", Style::default().fg(Color::DarkGray)),
};
let elapsed = if event.elapsed_ms > 1000 {
format!("{:.1}s", event.elapsed_ms as f64 / 1000.0)
} else {
format!("{}ms", event.elapsed_ms)
};
Line::from(vec![
Span::styled(format!(" {icon} "), style),
Span::raw(format!("{:<24}", event.crate_name)),
Span::styled(format!("{:<14}", event.result), style),
Span::raw(format!("{:>8} ", elapsed)),
Span::raw(format!("{:>10}", ByteSize(event.size))),
])
})
.collect();
let paragraph = Paragraph::new(visible).block(block);
frame.render_widget(paragraph, area);
}
fn draw_sparkline(frame: &mut Frame, state: &AppState, area: Rect) {
let block = Block::bordered().title(" Hit Rate (recent) ");
let width = area.width as usize;
let bucket_count = width.saturating_sub(4);
if state.events.is_empty() || bucket_count == 0 {
frame.render_widget(Paragraph::new(" No data yet").block(block), area);
return;
}
let events_per_bucket = (state.events.len() / bucket_count).max(1);
let mut data: Vec<u64> = Vec::new();
for chunk in state.events.chunks(events_per_bucket) {
let hits = chunk
.iter()
.filter(|e| {
matches!(
e.result,
EventResult::LocalHit | EventResult::PrefetchHit | EventResult::RemoteHit
)
})
.count();
let total = chunk.len();
let rate = if total > 0 {
(hits as f64 / total as f64 * 8.0) as u64
} else {
0
};
data.push(rate);
}
while data.len() < bucket_count {
data.push(0);
}
let sparkline = Sparkline::default()
.block(block)
.data(&data[..bucket_count.min(data.len())])
.max(8)
.style(Style::default().fg(Color::Green));
frame.render_widget(sparkline, area);
}
fn draw_build_help(frame: &mut Frame, state: &AppState, area: Rect) {
let help = if state.filter_active {
format!(" filter: {}_ (Esc to close)", state.filter)
} else {
" q: quit f: filter ↑↓: scroll Tab: next c: clear 1-5: tabs".to_string()
};
let paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray));
frame.render_widget(paragraph, area);
}
fn draw_store_tab(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Min(5), Constraint::Length(1), ])
.split(area);
draw_store_table(frame, state, chunks[0]);
draw_store_help(frame, state, chunks[1]);
}
fn draw_store_table(frame: &mut Frame, state: &AppState, area: Rect) {
let dedup_info = if let Some(bs) = state.stats_snapshot.blob_stats.as_ref() {
if bs.total_blobs > 0 {
let pct = if bs.total_logical_size > 0 {
bs.savings as f64 / bs.total_logical_size as f64 * 100.0
} else {
0.0
};
format!(
" | dedup: {} physical, {:.1}% saved",
ByteSize(bs.total_blob_size),
pct,
)
} else {
String::new()
}
} else {
String::new()
};
let title = format!(
" Cached Crates — {} entries, {} (sort: {}){dedup_info} ",
state.stats_snapshot.entry_count,
ByteSize(state.stats_snapshot.total_size),
state.sort_mode.label()
);
let block = Block::bordered()
.title(title)
.border_style(Style::default().fg(Color::Cyan));
let entries = &state.stats_snapshot.entries;
let mut content_hash_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for entry in entries {
if let Some(ch) = &entry.content_hash {
*content_hash_counts.entry(ch.as_str()).or_insert(0) += 1;
}
}
let header = Row::new(vec![
"Key", "Crate", "Type", "Profile", "Size", "Hits", "Dup", "Created", "Accessed",
])
.style(Style::default().add_modifier(Modifier::BOLD))
.bottom_margin(0);
let filtered: Vec<&daemon::StatsEntry> = entries
.iter()
.filter(|e| {
state.filter.is_empty()
|| e.crate_name.contains(&state.filter)
|| e.cache_key.contains(&state.filter)
})
.collect();
let visible_rows = (area.height as usize).saturating_sub(3); let skip = state
.store_scroll
.min(filtered.len().saturating_sub(visible_rows));
let rows: Vec<Row> = filtered
.iter()
.skip(skip)
.take(visible_rows)
.map(|entry| {
let key_short = if entry.cache_key.len() > 12 {
&entry.cache_key[..12]
} else {
&entry.cache_key
};
let crate_type = if entry.crate_type.is_empty() {
"-"
} else {
&entry.crate_type
};
let profile = if entry.profile.is_empty() {
"-"
} else {
&entry.profile
};
let dup = if let Some(ch) = &entry.content_hash {
let count = content_hash_counts.get(ch.as_str()).copied().unwrap_or(1);
if count > 1 {
format!("{count}x")
} else {
String::new()
}
} else {
String::new()
};
Row::new(vec![
Cell::from(key_short.to_string()),
Cell::from(entry.crate_name.clone()),
Cell::from(crate_type.to_string()),
Cell::from(profile.to_string()),
Cell::from(ByteSize(entry.size).to_string()),
Cell::from(entry.hit_count.to_string()),
Cell::from(dup).style(Style::default().fg(Color::Yellow)),
Cell::from(
entry
.created_at
.get(..10)
.unwrap_or(&entry.created_at)
.to_string(),
),
Cell::from(
entry
.last_accessed
.get(..10)
.unwrap_or(&entry.last_accessed)
.to_string(),
),
])
})
.collect();
let widths = [
Constraint::Length(13), Constraint::Min(18), Constraint::Length(10), Constraint::Length(10), Constraint::Length(10), Constraint::Length(6), Constraint::Length(5), Constraint::Length(12), Constraint::Length(12), ];
let table = Table::new(rows, widths).header(header).block(block);
frame.render_widget(table, area);
}
fn draw_store_help(frame: &mut Frame, state: &AppState, area: Rect) {
let help = if state.filter_active {
format!(" filter: {}_ (Esc to close)", state.filter)
} else {
" q: quit s: sort f: filter ↑↓: scroll Tab: next 1-5: tabs".to_string()
};
let paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray));
frame.render_widget(paragraph, area);
}
fn draw_projects_tab(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(9), Constraint::Min(5), Constraint::Length(3), Constraint::Length(1), ])
.split(area);
draw_projects_overview(frame, state, chunks[0]);
draw_projects_table(frame, state, chunks[1]);
draw_projects_totals(frame, state, chunks[2]);
draw_projects_help(frame, chunks[3]);
}
fn draw_projects_overview(frame: &mut Frame, state: &AppState, area: Rect) {
let scan_stats = state.project_scan.lock().unwrap();
let scanning = scan_stats.scanning;
let snap = &state.stats_snapshot;
let daemon_tag = match (snap.daemon_connected, state.service_installed) {
(true, true) => "",
(true, false) => " (no service)",
(false, true) => " (daemon offline)",
(false, false) => " (daemon offline, no service)",
};
let scan_tag = if scanning { " (scanning...)" } else { "" };
let title = format!(" kache projects{daemon_tag}{scan_tag}");
let block = Block::bordered().title(title);
let store_pct = if snap.max_size > 0 {
(snap.total_size as f64 / snap.max_size as f64) * 100.0
} else {
0.0
};
let es = &snap.event_stats;
let hit_rate = crate::cli::count_hit_rate(es);
let weighted_hit_rate = crate::cli::compile_weighted_hit_rate(es);
let time_saved = if es.hit_compile_time_ms > 0 {
crate::cli::format_duration_ms(es.hit_compile_time_ms)
} else {
"n/a".to_string()
};
let ls = &scan_stats.link_stats;
let dedup_ratio = if ls.store_bytes > 0 {
format!(
"{:.1}x",
(ls.store_bytes + ls.saved_bytes) as f64 / ls.store_bytes as f64
)
} else {
"n/a".to_string()
};
let wrapper_status = crate::wrapper_config::wrapper_status_line();
let remote_status = if let Some(remote) = &state.config.remote {
format!("S3: {}", remote.bucket)
} else {
"not configured".to_string()
};
let kache_version = crate::VERSION;
let my_epoch = crate::daemon::build_epoch();
let daemon_info = if snap.daemon_connected && !snap.daemon_version.is_empty() {
let epoch = snap.daemon_build_epoch;
if epoch == my_epoch {
format!("daemon: v{} (epoch {epoch})", snap.daemon_version)
} else {
format!(
"daemon: v{} (epoch {epoch}) \u{2190} MISMATCH, auto-restart pending",
snap.daemon_version
)
}
} else {
"daemon: offline".to_string()
};
let transfer_spans = if snap.daemon_connected {
vec![
Span::styled(" Transfer: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("↑ {}", snap.pending_uploads),
if snap.pending_uploads > 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default()
},
),
Span::raw(" uploading "),
Span::styled(
format!("↓ {}", snap.active_downloads),
if snap.active_downloads > 0 {
Style::default().fg(Color::Blue)
} else {
Style::default()
},
),
Span::raw(" downloading"),
]
} else {
vec![
Span::styled(" Transfer: ", Style::default().fg(Color::Cyan)),
Span::styled("n/a", Style::default().fg(Color::DarkGray)),
]
};
let text = vec![
Line::from(vec![
Span::styled(" Store: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{} / {} [{:.1}%]",
ByteSize(snap.total_size),
ByteSize(snap.max_size),
store_pct
)),
Span::raw(format!(" {} entries", snap.entry_count)),
]),
Line::from(vec![
Span::styled(" Hit rate: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{hit_rate:.0}% count{} (24h: {} hits, {} misses)",
weighted_hit_rate
.map(|v| format!(" | {v:.0}% weighted"))
.unwrap_or_default(),
es.local_hits + es.prefetch_hits + es.remote_hits,
es.misses
)),
Span::raw(format!(" Time saved: {time_saved}")),
]),
Line::from(vec![
Span::styled(" Dedup: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{dedup_ratio} Hardlinks: {} files saving {}",
ls.linked_refs,
ByteSize(ls.saved_bytes)
)),
]),
Line::from(transfer_spans),
Line::from(vec![
Span::styled(" Remote: ", Style::default().fg(Color::Cyan)),
Span::raw(format!("{remote_status} {wrapper_status}")),
]),
Line::from(format!(
" kache v{kache_version} (epoch {my_epoch}) {daemon_info} {}",
state.rustc_version
)),
];
let paragraph = Paragraph::new(text).block(block);
frame.render_widget(paragraph, area);
}
fn draw_projects_table(frame: &mut Frame, state: &AppState, area: Rect) {
let stats = state.project_scan.lock().unwrap();
let block = Block::bordered()
.title(" Projects ")
.border_style(Style::default().fg(Color::Cyan));
if stats.project_targets.is_empty() {
let msg = if stats.scanning {
" Scanning..."
} else {
" No target/ directories found."
};
frame.render_widget(Paragraph::new(msg).block(block), area);
return;
}
let root = std::env::current_dir().unwrap_or_default();
let header = Row::new(vec![
"Path", "Size", "Cached", "Incr", "Build", "Deps", "Bin", "Fprint", "Profile",
])
.style(Style::default().add_modifier(Modifier::BOLD));
let fmt = |v: u64| -> String {
if v > 0 {
format!("{:>8}", ByteSize(v))
} else {
String::new()
}
};
let rows: Vec<Row> = stats
.project_targets
.iter()
.map(|t| {
let rel = t.path.strip_prefix(&root).unwrap_or(&t.path);
let path_label = if t.stale {
format!("~ {}", rel.display())
} else {
format!("{}", rel.display())
};
let profile_str = if t.profiles.is_empty() {
String::new()
} else {
format!("[{}]", t.profiles.join(", "))
};
let b = &t.breakdown;
Row::new(vec![
Cell::from(path_label),
Cell::from(format!("{:>8}", ByteSize(t.size))),
Cell::from(format!("{:>8}", ByteSize(t.cached_bytes))),
Cell::from(fmt(b.incremental)),
Cell::from(fmt(b.build_scripts)),
Cell::from(fmt(b.deps_local)),
Cell::from(fmt(b.binaries)),
Cell::from(fmt(b.fingerprints)),
Cell::from(profile_str),
])
})
.collect();
let widths = [
Constraint::Min(20), Constraint::Length(9), Constraint::Length(9), Constraint::Length(9), Constraint::Length(9), Constraint::Length(9), Constraint::Length(9), Constraint::Length(9), Constraint::Length(14), ];
let visible_rows = (area.height as usize).saturating_sub(3); let skip = state
.project_scroll
.min(rows.len().saturating_sub(visible_rows));
let table = Table::new(rows.into_iter().skip(skip).collect::<Vec<_>>(), widths)
.header(header)
.block(block);
frame.render_widget(table, area);
}
fn draw_projects_totals(frame: &mut Frame, state: &AppState, area: Rect) {
let stats = state.project_scan.lock().unwrap();
if stats.project_targets.is_empty() {
frame.render_widget(Block::bordered().title(" Total "), area);
return;
}
let mut total_size = 0u64;
let mut total_cached = 0u64;
let mut total_incr = 0u64;
let mut total_build = 0u64;
let mut total_deps = 0u64;
let mut total_bin = 0u64;
let mut total_fprint = 0u64;
for t in &stats.project_targets {
total_size += t.size;
total_cached += t.cached_bytes;
total_incr += t.breakdown.incremental;
total_build += t.breakdown.build_scripts;
total_deps += t.breakdown.deps_local;
total_bin += t.breakdown.binaries;
total_fprint += t.breakdown.fingerprints;
}
let n = stats.project_targets.len();
let title = format!(" Total ({n} project{}) ", if n == 1 { "" } else { "s" });
let fmt = |v: u64| -> Span {
if v > 0 {
Span::raw(format!("{} ", ByteSize(v)))
} else {
Span::styled("- ", Style::default().fg(Color::DarkGray))
}
};
let line = Line::from(vec![
Span::styled(" Size: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("{}", ByteSize(total_size)),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Cached: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("{}", ByteSize(total_cached)),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Incr: ", Style::default().fg(Color::DarkGray)),
fmt(total_incr),
Span::styled("Build: ", Style::default().fg(Color::DarkGray)),
fmt(total_build),
Span::styled("Deps: ", Style::default().fg(Color::DarkGray)),
fmt(total_deps),
Span::styled("Bin: ", Style::default().fg(Color::DarkGray)),
fmt(total_bin),
Span::styled("Fprint: ", Style::default().fg(Color::DarkGray)),
fmt(total_fprint),
]);
let block = Block::bordered().title(title);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn draw_projects_help(frame: &mut Frame, area: Rect) {
let help = " q: quit r: refresh ↑↓: scroll Tab: next 1-5: tabs";
let paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray));
frame.render_widget(paragraph, area);
}
fn draw_transfer_tab(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Length(9), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
draw_transfer_pending(frame, state, chunks[0]);
draw_transfer_activity(frame, state, chunks[1]);
draw_recent_transfers(frame, state, chunks[2]);
draw_transfer_help(frame, chunks[3]);
}
fn draw_transfer_pending(frame: &mut Frame, state: &AppState, area: Rect) {
let snap = &state.stats_snapshot;
let pending = snap.pending_uploads;
let color = if pending == 0 {
Color::Green
} else if pending < 100 {
Color::Yellow
} else {
Color::Red
};
let label = format!(" {} pending uploads", pending);
let paragraph = Paragraph::new(Span::styled(label, Style::default().fg(color)))
.block(Block::bordered().title(" Upload Queue "));
frame.render_widget(paragraph, area);
}
fn format_speed(bps: f64) -> String {
if bps >= 1_000_000.0 {
format!("{:.1} MB/s", bps / 1_000_000.0)
} else if bps >= 1_000.0 {
format!("{:.0} KB/s", bps / 1_000.0)
} else if bps > 0.0 {
format!("{:.0} B/s", bps)
} else {
"0 B/s".to_string()
}
}
fn draw_transfer_activity(frame: &mut Frame, state: &AppState, area: Rect) {
let snap = &state.stats_snapshot;
let block = Block::bordered()
.title(" Transfer Activity ")
.border_style(Style::default().fg(Color::Cyan));
let s3_slots = format!(
"{} / {}",
snap.s3_concurrency_used, snap.s3_concurrency_total
);
let up_speed = format_speed(state.upload_speed_bps);
let down_speed = format_speed(state.download_speed_bps);
let text = vec![
Line::from(vec![
Span::styled(" Active: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("↑ {} uploading", snap.pending_uploads),
if snap.pending_uploads > 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default()
},
),
Span::raw(" "),
Span::styled(
format!("↓ {} downloading", snap.active_downloads),
if snap.active_downloads > 0 {
Style::default().fg(Color::Blue)
} else {
Style::default()
},
),
Span::raw(format!(" S3 slots: {s3_slots}")),
]),
Line::from(vec![
Span::styled(" Speed: ", Style::default().fg(Color::Cyan)),
Span::styled(format!("↑ {up_speed}"), Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled(format!("↓ {down_speed}"), Style::default().fg(Color::Blue)),
]),
Line::from(vec![
Span::styled(" Uploads: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("{} ok", snap.uploads_completed),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled(
format!("{} failed", snap.uploads_failed),
if snap.uploads_failed > 0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::DarkGray)
},
),
Span::raw(" "),
Span::styled(
format!("{} skipped", snap.uploads_skipped),
Style::default().fg(Color::DarkGray),
),
Span::raw(format!(" total: {}", ByteSize(snap.bytes_uploaded))),
]),
Line::from(vec![
Span::styled(" Downloads: ", Style::default().fg(Color::Cyan)),
Span::styled(
format!("{} ok", snap.downloads_completed),
Style::default().fg(Color::Green),
),
Span::raw(" "),
Span::styled(
format!("{} failed", snap.downloads_failed),
if snap.downloads_failed > 0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::DarkGray)
},
),
Span::raw(format!(" total: {}", ByteSize(snap.bytes_downloaded))),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Daemon: ", Style::default().fg(Color::Cyan)),
if snap.daemon_connected {
Span::styled("connected", Style::default().fg(Color::Green))
} else {
Span::styled("offline", Style::default().fg(Color::Red))
},
]),
];
let paragraph = Paragraph::new(text).block(block);
frame.render_widget(paragraph, area);
}
fn draw_recent_transfers(frame: &mut Frame, state: &AppState, area: Rect) {
let transfers = &state.stats_snapshot.recent_transfers;
let block = Block::bordered()
.title(format!(" Recent Transfers ({}) ", transfers.len()))
.border_style(Style::default().fg(Color::Cyan));
if transfers.is_empty() {
let msg = if !state.stats_snapshot.daemon_connected {
" Daemon offline — no transfer data available"
} else {
" No transfers yet"
};
frame.render_widget(Paragraph::new(msg).block(block), area);
return;
}
let header = Row::new(vec!["Dir", "Crate", "Size", "Time", "Status"])
.style(Style::default().add_modifier(Modifier::BOLD));
let visible_rows = (area.height as usize).saturating_sub(3);
let skip = state
.transfer_scroll
.min(transfers.len().saturating_sub(visible_rows));
let rows: Vec<Row> = transfers
.iter()
.rev()
.skip(skip)
.take(visible_rows)
.map(|evt| {
let (arrow, dir_style) = match evt.direction {
daemon::TransferDirection::Upload => ("↑", Style::default().fg(Color::Yellow)),
daemon::TransferDirection::Download => ("↓", Style::default().fg(Color::Blue)),
};
let elapsed = if evt.elapsed_ms > 1000 {
format!("{:.1}s", evt.elapsed_ms as f64 / 1000.0)
} else {
format!("{}ms", evt.elapsed_ms)
};
let (status, status_style) = if evt.ok {
("ok", Style::default().fg(Color::Green))
} else {
("FAIL", Style::default().fg(Color::Red))
};
Row::new(vec![
Cell::from(arrow).style(dir_style),
Cell::from(evt.crate_name.clone()),
Cell::from(ByteSize(evt.compressed_bytes).to_string()),
Cell::from(elapsed),
Cell::from(status).style(status_style),
])
})
.collect();
let widths = [
Constraint::Length(3), Constraint::Min(20), Constraint::Length(10), Constraint::Length(8), Constraint::Length(6), ];
let table = Table::new(rows, widths).header(header).block(block);
frame.render_widget(table, area);
}
fn draw_transfer_help(frame: &mut Frame, area: Rect) {
let help = " q: quit ↑↓: scroll Tab: next 1-5: tabs";
let paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray));
frame.render_widget(paragraph, area);
}
fn draw_passthrough_tab(frame: &mut Frame, state: &AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Min(5), Constraint::Length(1), ])
.split(area);
draw_passthrough_table(frame, state, chunks[0]);
draw_passthrough_help(frame, state, chunks[1]);
}
fn draw_passthrough_table(frame: &mut Frame, state: &AppState, area: Rect) {
let events: Vec<&BuildEvent> = state
.events
.iter()
.filter(|event| matches!(event.result, EventResult::Passthrough))
.filter(|event| {
state.filter.is_empty()
|| event.crate_name.contains(&state.filter)
|| event.passthrough_reason.contains(&state.filter)
})
.collect();
let block = Block::bordered()
.title(format!(" Passthroughs ({}) ", events.len()))
.border_style(Style::default().fg(Color::Cyan));
if events.is_empty() {
let msg = if state.filter.is_empty() {
" No passthroughs yet"
} else {
" No passthroughs match the filter"
};
frame.render_widget(Paragraph::new(msg).block(block), area);
return;
}
let header = Row::new(vec!["Time", "Crate", "Route", "Exit", "Reason"])
.style(Style::default().add_modifier(Modifier::BOLD));
let visible_rows = (area.height as usize).saturating_sub(3);
let skip = state
.passthrough_scroll
.min(events.len().saturating_sub(visible_rows));
let rows: Vec<Row> = events
.iter()
.rev()
.skip(skip)
.take(visible_rows)
.map(|event| {
let exit = event
.exit_code
.map(|code| code.to_string())
.unwrap_or_default();
let route = if event.fallback { "fallback" } else { "direct" };
let reason = if event.passthrough_reason.is_empty() {
"unknown"
} else {
&event.passthrough_reason
};
let exit_style = match event.exit_code {
Some(0) => Style::default().fg(Color::Green),
Some(_) => Style::default().fg(Color::Red),
None => Style::default().fg(Color::DarkGray),
};
Row::new(vec![
Cell::from(event.ts.format("%H:%M:%S").to_string()),
Cell::from(event.crate_name.clone()),
Cell::from(route).style(Style::default().fg(Color::Magenta)),
Cell::from(exit).style(exit_style),
Cell::from(reason.to_string()),
])
})
.collect();
let widths = [
Constraint::Length(9),
Constraint::Min(18),
Constraint::Length(10),
Constraint::Length(6),
Constraint::Percentage(45),
];
let table = Table::new(rows, widths).header(header).block(block);
frame.render_widget(table, area);
}
fn draw_passthrough_help(frame: &mut Frame, state: &AppState, area: Rect) {
let help = if state.filter_active {
format!(" filter: {}_ (Esc to close)", state.filter)
} else {
" q: quit f: filter ↑↓: scroll Tab: next 1-5: tabs".to_string()
};
let paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray));
frame.render_widget(paragraph, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tab_needs_entries_only_for_store() {
assert!(!tab_needs_entries(Tab::Build));
assert!(!tab_needs_entries(Tab::Projects));
assert!(tab_needs_entries(Tab::Store));
assert!(!tab_needs_entries(Tab::Transfer));
assert!(!tab_needs_entries(Tab::Passthrough));
}
}