Files
DS4Server/src/app/view/stats.rs
2026-08-30 13:25:45 +02:00

881 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<DSMLtool_calls><DSMLinvoke name=\"read\"><DSMLparameter name=\"path\" string=\"true\">src/main.rs</DSMLparameter></DSMLinvoke><DSMLinvoke name=\"list\"><DSMLparameter name=\"path\" string=\"true\">src</DSMLparameter></DSMLinvoke></DSMLtool_calls>",
);
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,
}
);
}
}