use super::*; use crate::app::generation; use iced::widget::column; impl App { pub(super) fn stats_dashboard(&self) -> Element<'_, Message> { let stats = &self.metrics_snapshot; let session = SessionStats::from_messages(&self.conversation, self.config.model); let context_fraction = if stats.context_limit == 0 { 0.0 } else { stats.context_used.min(stats.context_limit) as f32 / stats.context_limit as f32 }; let cache_fraction = if stats.last_prompt_tokens == 0 { 0.0 } else { stats.last_cached_tokens as f32 / stats.last_prompt_tokens as f32 }; let cache_hit_fraction = if stats.kv_lookups == 0 { 0.0 } else { stats.kv_hits as f32 / stats.kv_lookups as f32 }; let endpoint = if stats.server_listening { format!("Listening · 127.0.0.1:{}", stats.server_port) } else { "Stopped".to_owned() }; let phase_color = match stats.phase { crate::metrics::RuntimePhase::Generating => Color::from_rgb8(84, 170, 255), crate::metrics::RuntimePhase::Prefilling | crate::metrics::RuntimePhase::Loading => { Color::from_rgb8(240, 180, 70) } crate::metrics::RuntimePhase::Ready => Color::from_rgb8(72, 176, 112), crate::metrics::RuntimePhase::Failed => Color::from_rgb8(220, 80, 86), crate::metrics::RuntimePhase::Unloaded => muted_text(), }; let heading = container( row![ column![ text("Runtime observability").size(24), text(format!( "{} · {} · uptime {}", stats.model, stats.source.label(), format_duration(stats.uptime_seconds as f64) )) .size(12) .color(muted_text()), ] .spacing(5), Space::new().width(Length::Fill), container(text(stats.phase.label()).size(12).color(phase_color)) .padding([7, 11]) .style(move |_| status_badge_style(phase_color)), ] .align_y(Alignment::Center), ) .padding(16) .style(overview_style); let headline = column![ row![ metric_card( "DECODE", format!("{:.1} tok/s", stats.decode_tokens_per_second), format!( "{} completion tokens total", format_count(stats.completion_tokens) ), ), metric_card( "PREFILL", format!("{:.1} tok/s", stats.prefill_tokens_per_second), format!("{} prompt tokens total", format_count(stats.prompt_tokens)), ), ] .spacing(10), row![ metric_card( "CONTEXT", format!( "{} / {}", format_count(u64::from(stats.context_used)), format_count(u64::from(stats.context_limit)) ), format!("{:.0}% occupied", context_fraction * 100.0), ), metric_card( "WORK", format!( "{} active · {} queued", stats.http_active, stats.queue_depth ), format!("{} runtime requests", format_count(stats.runtime_requests)), ), ] .spacing(10), ] .spacing(10); let session_title = self .selected_session .and_then(|id| self.session_title(id)) .unwrap_or_else(|| "No session selected".to_owned()); let session_cache_fraction = if session.input_tokens == 0 { 0.0 } else { session.cached_tokens as f64 / session.input_tokens as f64 }; let current_session = stats_panel( "CURRENT SESSION", column![ metric_row("Session", session_title), metric_row( "Messages", format!( "{} · {} user · {} assistant · {} tool", format_count(session.messages), format_count(session.user_messages), format_count(session.assistant_messages), format_count(session.tool_messages), ), ), metric_row("Tool calls", format_count(session.tool_calls)), metric_row("Context rebuilds", format_count(session.context_rebuilds)), metric_row("Completed turns", format_count(session.completed_turns),), metric_row( "Generation time", format_duration(session.duration_ms as f64 / 1_000.0), ), metric_row("Input tokens", format_count(session.input_tokens)), metric_row( "Cached tokens", format!( "{} · {:.1}% reused", format_count(session.cached_tokens), session_cache_fraction * 100.0, ), ), metric_row("Output tokens", format_count(session.output_tokens)), ] .spacing(9) .into(), ); let throughput = stats_panel( "MODEL ACTIVITY · LAST 24 SECONDS", column![ mini_chart( &self.metrics_history, |point| point.decode_tokens_per_second, Color::from_rgb8(84, 170, 255), ), row![ text("Decode") .size(12) .color(Color::from_rgb8(84, 170, 255)), Space::new().width(Length::Fill), text(format!("{:.1} tok/s", stats.decode_tokens_per_second)) .size(12) .color(muted_text()), ], mini_chart( &self.metrics_history, |point| point.prefill_tokens_per_second, Color::from_rgb8(157, 119, 255), ), row![ text("Prefill") .size(12) .color(Color::from_rgb8(157, 119, 255)), Space::new().width(Length::Fill), text(format!("{:.1} tok/s", stats.prefill_tokens_per_second)) .size(12) .color(muted_text()), ], ] .spacing(7) .into(), ); let requests = stats_panel( "SERVER REQUEST RATE · LAST 24 SECONDS", column![ mini_chart( &self.metrics_history, |point| point.http_requests_per_second, Color::from_rgb8(72, 176, 112), ), row![ text(format!("{} requests", format_count(stats.http_requests))).size(12), Space::new().width(Length::Fill), text(format!( "{} errors · {} streaming", stats.http_errors, stats.http_streaming_requests )) .size(12) .color(muted_text()), ], ] .spacing(7) .into(), ); let latest = self.metrics_history.back().copied().unwrap_or_default(); let kv_io = stats_panel( "KV CHECKPOINT I/O · LAST 24 SECONDS", column![ mini_chart( &self.metrics_history, |point| point.kv_read_bytes_per_second, Color::from_rgb8(67, 194, 203), ), row![ text(if stats.kv_read_active { "Disk read · active" } else { "Disk read" }) .size(12) .color(Color::from_rgb8(67, 194, 203)), Space::new().width(Length::Fill), text(format_rate(latest.kv_read_bytes_per_second)) .size(12) .color(muted_text()), ], mini_chart( &self.metrics_history, |point| point.kv_write_bytes_per_second, Color::from_rgb8(240, 180, 70), ), row![ text(if stats.kv_write_active { "Disk write · active" } else { "Disk write" }) .size(12) .color(Color::from_rgb8(240, 180, 70)), Space::new().width(Length::Fill), text(format_rate(latest.kv_write_bytes_per_second)) .size(12) .color(muted_text()), ], ] .spacing(7) .into(), ); let ssd_activity = stats_panel( "SSD STREAMING ACTIVITY · LAST 24 SECONDS", column![ mini_chart( &self.metrics_history, |point| point.ssd_requests_per_second, Color::from_rgb8(240, 180, 70), ), row![ text("Selected loads") .size(12) .color(Color::from_rgb8(240, 180, 70)), Space::new().width(Length::Fill), text(format!("{:.1}/s", latest.ssd_requests_per_second)) .size(12) .color(muted_text()), ], mini_chart( &self.metrics_history, |point| point.ssd_bytes_per_second, Color::from_rgb8(67, 194, 203), ), row![ text("Requested expert data") .size(12) .color(Color::from_rgb8(67, 194, 203)), Space::new().width(Length::Fill), text(format_rate(latest.ssd_bytes_per_second)) .size(12) .color(muted_text()), ], mini_chart( &self.metrics_history, |point| point.ssd_wait_ms_per_second, Color::from_rgb8(220, 80, 86), ), row![ text("Inference wait") .size(12) .color(Color::from_rgb8(220, 80, 86)), Space::new().width(Length::Fill), text(format!("{:.0} ms/s", latest.ssd_wait_ms_per_second)) .size(12) .color(muted_text()), ], ] .spacing(7) .into(), ); let model = stats_panel( "MODEL CORE", column![ metric_row("State", stats.phase.label()), metric_row("Loaded model", stats.model), metric_row("Mapped weights", format_bytes(stats.model_bytes)), metric_row("Tensors", format_count(stats.tensor_count)), metric_row("Vocabulary", format_count(stats.vocabulary_size)), metric_row("Last load", format_milliseconds(stats.model_load_ms)), metric_row( "Lifecycle", format!( "{} loads · {} unloads", stats.model_loads, stats.model_unloads ), ), ] .spacing(9) .into(), ); let runtime = stats_panel( "GENERATION", column![ metric_row("Last runtime", format_milliseconds(stats.last_runtime_ms)), metric_row( "Average runtime", format_milliseconds(stats.average_runtime_ms) ), metric_row("Last prompt", format_count(stats.last_prompt_tokens)), metric_row("Last reused", format_count(stats.last_cached_tokens)), metric_row( "Last completion", format_count(stats.last_completion_tokens) ), metric_row("Cached tokens total", format_count(stats.cached_tokens)), metric_row( "Cache reuse", format!("{:.0}% of last prompt", cache_fraction * 100.0), ), metric_row( "Results", format!( "{} completed · {} failed", stats.completed_requests, stats.failed_requests ), ), ] .spacing(9) .into(), ); let acceptance = if stats.drafted_tokens == 0 { 0.0 } else { stats.accepted_draft_tokens as f64 / stats.drafted_tokens as f64 }; let target_passes = stats .speculative_cycles .saturating_add(stats.verifier_passes); let effective_speedup = if target_passes == 0 { 1.0 } else { stats .speculative_cycles .saturating_add(stats.accepted_draft_tokens) as f64 / target_passes as f64 }; let speculative = stats_panel( "SPECULATIVE DECODING", column![ metric_row( "Mode", match stats.speculative_mode { 1 => "Legacy MTP", 2 => "DSpark", 3 => "GLM MTP", _ => "Off", }, ), metric_row("Cycles", format_count(stats.speculative_cycles)), metric_row("Drafted", format_count(stats.drafted_tokens)), metric_row("Accepted", format_count(stats.accepted_draft_tokens)), metric_row("Acceptance", format!("{:.1}%", acceptance * 100.0)), metric_row( "Target verifier passes", format_count(stats.verifier_passes) ), metric_row("Verifier wall time", format_milliseconds(stats.verifier_ms)), metric_row( "Effective target-pass speedup", format!("{effective_speedup:.2}×") ), ] .spacing(9) .into(), ); let ssd = stats_panel( "SSD EXPERT STREAMING", column![ metric_row("State", if stats.ssd_enabled { "Enabled" } else { "Off" }), metric_row("Resident weights", format_bytes(stats.ssd_resident_bytes)), metric_row("Expert cache", format_bytes(stats.ssd_cache_bytes)), metric_row( "Cache capacity", format!( "{} / {} experts", stats.ssd_cache_entries, stats.ssd_cache_experts ) ), metric_row( "Preloaded", format!( "{} / {} experts", stats.ssd_cache_entries.min(stats.ssd_preloaded_experts), stats.ssd_preloaded_experts ) ), metric_row( "Cache hits / misses", format!("{} / {}", stats.ssd_cache_hits, stats.ssd_cache_misses) ), metric_row( "Hit rate", format!( "{:.1}%", if stats.ssd_cache_hits + stats.ssd_cache_misses == 0 { 0.0 } else { 100.0 * stats.ssd_cache_hits as f64 / (stats.ssd_cache_hits + stats.ssd_cache_misses) as f64 } ) ), metric_row( "Evictions / wraps", format!("{} / {}", stats.ssd_cache_evictions, stats.ssd_cache_wraps) ), metric_row( "Buffers allocated / reused", format!("{} / {}", stats.ssd_buffer_allocs, stats.ssd_buffer_reuses) ), metric_row( "Direct SSD reads", format!( "{} · {}", format_bytes(stats.ssd_pread_bytes), format_milliseconds(stats.ssd_pread_ms) ) ), metric_row( "VM advice", format!( "{} evicted · {} prefetched", format_bytes(stats.ssd_evict_advise_bytes), format_bytes(stats.ssd_willneed_advise_bytes) ) ), metric_row( "Selected-load requests", format_count(stats.ssd_selected_requests) ), metric_row( "Requested expert bytes", format_bytes(stats.ssd_requested_bytes) ), metric_row("Selected-load wait", format_milliseconds(stats.ssd_wait_ms)), metric_row( "Average load wait", format_milliseconds( stats .ssd_wait_ms .checked_div(stats.ssd_selected_requests) .unwrap_or(0) ) ), ] .spacing(9) .into(), ); let cache = stats_panel( "KV CACHE", column![ metric_row( "Total", format!( "{} · {} files", format_bytes(stats.kv_bytes), stats.kv_files ) ), metric_row( "Local sessions", format!( "{} · {} files", format_bytes(stats.local_kv_bytes), stats.local_kv_files ), ), metric_row( "HTTP transient", format!( "{} · {} files", format_bytes(stats.http_kv_bytes), stats.http_kv_files ), ), metric_row("Checkpoint writes", format_count(stats.checkpoint_writes)), metric_row( "Exact hits", format!( "{} · {} memory / {} disk", stats.kv_hits, stats.kv_memory_hits, stats.kv_disk_hits ), ), metric_row( "Misses", format!("{} · {} invalid", stats.kv_misses, stats.kv_invalid), ), metric_row("Lookups", format_count(stats.kv_lookups)), metric_row( "Exact hit rate", format!("{:.1}%", cache_hit_fraction * 100.0) ), metric_row("Prefix hits", format_count(stats.kv_prefix_hits)), metric_row( "Reads", format!( "{} · {} · {} errors · last {}", stats.kv_read_operations, format_bytes(stats.kv_read_bytes), stats.kv_read_errors, format_milliseconds(stats.last_kv_read_ms), ), ), metric_row( "Writes", format!( "{} · {} · {} errors · last {}", stats.kv_write_operations, format_bytes(stats.kv_write_bytes), stats.kv_write_errors, format_milliseconds(stats.last_kv_write_ms), ), ), progress_bar(0.0..=1.0, cache_fraction.min(1.0)).girth(4), ] .spacing(9) .into(), ); let disc = stats_panel("KV CACHE DISC USAGE", self.cache_explorer()); let server = stats_panel( "LOCAL SERVER", column![ metric_row("Endpoint", endpoint), metric_row("Active", format_count(u64::from(stats.http_active))), metric_row("Completed", format_count(stats.http_completed)), metric_row("Chat completions", format_count(stats.http_chat_requests)), metric_row("Model queries", format_count(stats.http_model_requests)), metric_row( "Runtime sources", format!( "{} local · {} HTTP", stats.local_requests, stats.endpoint_generations ), ), metric_row("Received", format_bytes(stats.http_bytes_received)), metric_row("Last latency", format_milliseconds(stats.last_http_ms)), metric_row( "Average latency", format_milliseconds(stats.average_http_ms) ), ] .spacing(9) .into(), ); scrollable( container( column![ heading, headline, current_session, throughput, ssd_activity, kv_io, requests, disc, row![model, runtime].spacing(10), row![speculative, ssd].spacing(10), row![cache, server].spacing(10), text("Counters are published by the runtime with relaxed atomics and sampled by the UI every 200 ms.") .size(11) .color(muted_text()), ] .spacing(12), ) .padding(24) .width(Length::Fill), ) .height(Length::Fill) .into() } /// Disc usage of both cache buckets. The bar splits the transient store by /// age — the order the budget evicts in — and carries the session /// checkpoints as one further segment, since those are only freed with /// their session and so compete for disc without ever ageing out. fn cache_explorer(&self) -> Element<'_, Message> { let usage = &self.kv_cache_report; let total = usage.total_bytes(); let capacity = total.max(1); let segments = crate::metrics::CACHE_AGE_BUCKETS .iter() .enumerate() .map(|(index, (label, _))| (*label, usage.age_bytes[index], AGE_COLORS[index])) .chain([("Sessions", usage.session_bytes, SESSION_COLOR)]); let mut bar = row![].height(14).spacing(2); let mut legend = column![].spacing(6); for (label, bytes, color) in segments { if bytes == 0 { continue; } bar = bar.push( container(Space::new().width(Length::Fill).height(Length::Fill)) .width(Length::FillPortion(portion(bytes, capacity))) .style(move |_| chart_bar_style(color)), ); legend = legend.push( row![ container(Space::new().width(9).height(9)) .style(move |_| chart_bar_style(color)), text(label).size(12).color(muted_text()), Space::new().width(Length::Fill), text(format!( "{} · {:.0}%", format_bytes(bytes), bytes as f32 / total.max(1) as f32 * 100.0 )) .size(12), ] .spacing(8) .align_y(Alignment::Center), ); } if total == 0 { bar = bar.push( container(Space::new().width(Length::Fill).height(Length::Fill)) .style(|_| chart_bar_style(muted_text().scale_alpha(0.25))), ); } let mut rows = column![].spacing(7); for entry in &usage.entries { let path = entry.path.clone(); let label = entry .session .and_then(|id| self.session_title(id)) .map_or_else( || entry.name.clone(), |title| format!("{}: {}", entry.name, shorten(&title)), ); rows = rows.push( row![ text(label).size(12), Space::new().width(Length::Fill), text(format!( "{} · {} old", format_bytes(entry.bytes), format_duration(entry.age_seconds as f64) )) .size(12) .color(muted_text()), action_button(text("Discard").size(12)) .on_press(Message::DiscardCacheEntry(path)), ] .spacing(10) .align_y(Alignment::Center), ); } column![ metric_row( "Stored", format!( "{} · {} files", format_bytes(total), usage.session_files + usage.transient_files ), ), container(bar).height(14).width(Length::Fill), legend, metric_row( "Sessions", format!( "{} · {} files · not evicted", format_bytes(usage.session_bytes), usage.session_files ), ), metric_row( "Transient", format!( "{} of {} budget · {} files · {:.0}% full", format_bytes(usage.transient_bytes), format_bytes(usage.budget_bytes), usage.transient_files, usage.transient_bytes as f32 / usage.budget_bytes.max(1) as f32 * 100.0, ), ), Space::new().height(2), text(if usage.entries.is_empty() { "No checkpoints stored yet." } else { "OLDEST CHECKPOINTS" }) .size(10) .color(muted_text()), rows, row![ text("Discarding a checkpoint costs one prefill to rebuild it.") .size(11) .color(muted_text()), Space::new().width(Length::Fill), action_button(text("Clear transient cache").size(12)) .on_press(Message::ClearTransientCache), ] .spacing(10) .align_y(Alignment::Center), ] .spacing(9) .into() } } #[derive(Debug, Default, PartialEq, Eq)] struct SessionStats { messages: u64, user_messages: u64, assistant_messages: u64, tool_messages: u64, tool_calls: u64, context_rebuilds: u64, completed_turns: u64, duration_ms: u64, input_tokens: u64, cached_tokens: u64, output_tokens: u64, } impl SessionStats { fn from_messages(messages: &[generation::ChatMessage], model: ModelChoice) -> Self { let mut summary = Self::default(); for message in messages { if let Some(stats) = message.generation_stats { summary.completed_turns = summary.completed_turns.saturating_add(1); summary.duration_ms = summary.duration_ms.saturating_add(stats.duration_ms); summary.input_tokens = summary .input_tokens .saturating_add(u64::from(stats.input_tokens)); summary.cached_tokens = summary .cached_tokens .saturating_add(u64::from(stats.cached_tokens)); summary.output_tokens = summary .output_tokens .saturating_add(u64::from(stats.output_tokens)); } if message.compaction { summary.context_rebuilds = summary.context_rebuilds.saturating_add(1); } else if !message.system { summary.messages = summary.messages.saturating_add(1); if message.user { summary.user_messages = summary.user_messages.saturating_add(1); } else if message.tool { summary.tool_messages = summary.tool_messages.saturating_add(1); } else { summary.assistant_messages = summary.assistant_messages.saturating_add(1); let calls = crate::agent::parse_tool_calls(model, &message.content) .map(|(_, calls)| calls.len() as u64) .unwrap_or(0); summary.tool_calls = summary.tool_calls.saturating_add(calls); } } } summary } } /// Sessions sit outside the age scale: they are not evicted, only deleted. const SESSION_COLOR: Color = Color::from_rgb(0.85, 0.44, 0.56); /// Age buckets from green (fresh) to grey (past every hit half-life). const AGE_COLORS: [Color; 5] = [ Color::from_rgb(0.28, 0.69, 0.44), Color::from_rgb(0.33, 0.67, 1.0), Color::from_rgb(0.62, 0.47, 1.0), Color::from_rgb(0.94, 0.71, 0.27), Color::from_rgb(0.45, 0.45, 0.48), ]; /// Keeps a long session title from crowding out the size and the action. fn shorten(title: &str) -> String { const LIMIT: usize = 44; if title.chars().count() <= LIMIT { return title.to_owned(); } title.chars().take(LIMIT - 1).chain(['…']).collect() } fn portion(bytes: u64, capacity: u64) -> u16 { ((bytes.saturating_mul(1000) / capacity.max(1)) as u16).max(1) } #[cfg(test)] mod tests { use super::*; fn message(user: bool, tool: bool, system: bool, content: &str) -> generation::ChatMessage { generation::ChatMessage { id: 1, user, tool, system, compaction: false, compaction_tail_start: None, generation_stats: None, reasoning: None, reasoning_complete: true, reasoning_open: false, content: content.to_owned(), model_content: None, tool_approval_reasons: Vec::new(), instruction_metadata: None, markdown: markdown::Content::new(), transcript: text_editor::Content::new(), a2ui_lines_processed: 0, a2ui_errors: Vec::new(), a2ui_replies: Vec::new(), a2ui_open_urls: Vec::new(), } } #[test] fn current_session_summary_uses_persisted_chat_facts() { let mut user = message(true, false, false, "Run it"); user.generation_stats = Some(generation::GenerationStats { duration_ms: 1_250, input_tokens: 2_000, cached_tokens: 1_500, output_tokens: 300, }); let assistant = message( false, false, false, "Checking<|DSML|tool_calls><|DSML|invoke name=\"read\"><|DSML|parameter name=\"path\" string=\"true\">src/main.rs<|DSML|invoke name=\"list\"><|DSML|parameter name=\"path\" string=\"true\">src", ); let tool = message(false, true, false, "Tool result 1\n..."); let mut compaction = message(false, false, true, "summary"); compaction.compaction = true; let system = message(false, false, true, "reminder"); let summary = SessionStats::from_messages( &[user, assistant, tool, compaction, system], ModelChoice::DeepSeekV4Flash, ); assert_eq!( summary, SessionStats { messages: 3, user_messages: 1, assistant_messages: 1, tool_messages: 1, tool_calls: 2, context_rebuilds: 1, completed_turns: 1, duration_ms: 1_250, input_tokens: 2_000, cached_tokens: 1_500, output_tokens: 300, } ); } }