Files
DS4Server/src/app/view/stats.rs
2026-07-26 17:58:05 +02:00

659 lines
25 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 iced::widget::column;
impl App {
pub(super) fn stats_dashboard(&self) -> Element<'_, Message> {
let stats = &self.metrics_snapshot;
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::with_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 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::with_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::with_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::with_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::with_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::with_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::with_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::with_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::with_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",
_ => "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_experts)
),
metric_row(
"Preloaded",
format!("{} experts", stats.ssd_preloaded_experts)
),
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)).height(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,
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)
.max_width(960)
.center_x(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(Length::Fill, Length::Fill))
.width(Length::FillPortion(portion(bytes, capacity)))
.style(move |_| chart_bar_style(color)),
);
legend = legend.push(
row![
container(Space::new(9, 9)).style(move |_| chart_bar_style(color)),
text(label).size(12).color(muted_text()),
Space::with_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(Length::Fill, 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::with_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::with_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::with_width(Length::Fill),
action_button(text("Clear transient cache").size(12))
.on_press(Message::ClearTransientCache),
]
.spacing(10)
.align_y(Alignment::Center),
]
.spacing(9)
.into()
}
}
/// 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)
}