659 lines
25 KiB
Rust
659 lines
25 KiB
Rust
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)
|
||
}
|