Implement batched server parity and observability
This commit is contained in:
550
src/app/view.rs
550
src/app/view.rs
@@ -1,5 +1,6 @@
|
||||
use super::{
|
||||
ActiveDownload, App, Message, ModelDownload, ModelOperation, chat_scroll_id, models_path,
|
||||
ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation,
|
||||
chat_scroll_id, models_path,
|
||||
};
|
||||
use crate::database::{ProjectWithSessions, Session};
|
||||
use crate::model::{
|
||||
@@ -12,6 +13,7 @@ use iced::widget::{
|
||||
pick_list, progress_bar, row, scrollable, stack, svg, text, text_input,
|
||||
};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme, window};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
|
||||
const ICON_FOLDER: &[u8] = include_bytes!("../../assets/icons/folder.svg");
|
||||
@@ -191,6 +193,43 @@ impl App {
|
||||
}
|
||||
|
||||
fn detail(&self) -> Element<'_, Message> {
|
||||
let chat_active = self.detail_tab == DetailTab::Chat;
|
||||
let stats_active = self.detail_tab == DetailTab::Stats;
|
||||
let tabs = container(
|
||||
row![
|
||||
button(text("Chat").size(13))
|
||||
.width(92)
|
||||
.padding([7, 18])
|
||||
.on_press(Message::ShowChat)
|
||||
.style(move |theme, status| segmented_button_style(theme, status, chat_active)),
|
||||
button(text("Stats").size(13))
|
||||
.width(92)
|
||||
.padding([7, 18])
|
||||
.on_press(Message::ShowStats)
|
||||
.style(move |theme, status| segmented_button_style(
|
||||
theme,
|
||||
status,
|
||||
stats_active
|
||||
)),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(3)
|
||||
.style(segmented_control_style);
|
||||
let body = match self.detail_tab {
|
||||
DetailTab::Chat => self.chat_detail(),
|
||||
DetailTab::Stats => self.stats_dashboard(),
|
||||
};
|
||||
column![
|
||||
container(tabs).center_x(Length::Fill).padding([12, 0]),
|
||||
body
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn chat_detail(&self) -> Element<'_, Message> {
|
||||
let Some(item) = self.selected_project() else {
|
||||
let open_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Open project…"),]
|
||||
.spacing(8)
|
||||
@@ -396,6 +435,373 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
|
||||
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 {
|
||||
"Read · active"
|
||||
} else {
|
||||
"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 {
|
||||
"Write · active"
|
||||
} else {
|
||||
"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 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 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 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,
|
||||
kv_io,
|
||||
requests,
|
||||
row![model, runtime].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()
|
||||
}
|
||||
|
||||
fn preferences_panel(&self) -> Element<'_, Message> {
|
||||
let dspark_toggle: Option<fn(bool) -> Message> = self
|
||||
.preference_draft
|
||||
@@ -1218,6 +1624,101 @@ fn action_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Mes
|
||||
button(content).padding([8, 14]).style(action_button_style)
|
||||
}
|
||||
|
||||
fn metric_card(label: &'static str, value: String, detail: String) -> Element<'static, Message> {
|
||||
container(
|
||||
column![
|
||||
text(label).size(10).color(muted_text()),
|
||||
text(value).size(20),
|
||||
text(detail).size(11).color(muted_text()),
|
||||
]
|
||||
.spacing(5),
|
||||
)
|
||||
.padding(14)
|
||||
.width(Length::FillPortion(1))
|
||||
.style(preference_group_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn metric_row(label: &'static str, value: impl ToString) -> Element<'static, Message> {
|
||||
row![
|
||||
text(label).size(12).color(muted_text()),
|
||||
Space::with_width(Length::Fill),
|
||||
text(value.to_string()).size(12),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn stats_panel<'a>(title: &'static str, content: Element<'a, Message>) -> Element<'a, Message> {
|
||||
container(column![text(title).size(10).color(muted_text()), content].spacing(11))
|
||||
.padding(14)
|
||||
.width(Length::FillPortion(1))
|
||||
.style(preference_group_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn mini_chart(
|
||||
history: &VecDeque<MetricsPoint>,
|
||||
value: fn(&MetricsPoint) -> f32,
|
||||
color: Color,
|
||||
) -> Element<'static, Message> {
|
||||
let values = history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(120)
|
||||
.map(value)
|
||||
.collect::<Vec<_>>();
|
||||
let maximum = values.iter().copied().fold(1.0_f32, f32::max);
|
||||
let mut bars = row![].spacing(1).height(54).align_y(Alignment::End);
|
||||
for value in values.into_iter().rev() {
|
||||
let height = if value > 0.0 {
|
||||
(value / maximum * 52.0).max(2.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
bars = bars.push(
|
||||
container(Space::new(Length::Fill, height))
|
||||
.width(Length::FillPortion(1))
|
||||
.style(move |_| chart_bar_style(color)),
|
||||
);
|
||||
}
|
||||
if history.is_empty() {
|
||||
bars = bars.push(
|
||||
container(text("Waiting for samples…").size(11).color(muted_text()))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
);
|
||||
}
|
||||
container(bars)
|
||||
.height(58)
|
||||
.width(Length::Fill)
|
||||
.padding([3, 0])
|
||||
.into()
|
||||
}
|
||||
|
||||
fn format_count(value: u64) -> String {
|
||||
if value >= 1_000_000 {
|
||||
format!("{:.1}M", value as f64 / 1_000_000.0)
|
||||
} else if value >= 1_000 {
|
||||
format!("{:.1}K", value as f64 / 1_000.0)
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_milliseconds(milliseconds: u64) -> String {
|
||||
if milliseconds >= 1_000 {
|
||||
format!("{:.2}s", milliseconds as f64 / 1_000.0)
|
||||
} else {
|
||||
format!("{milliseconds}ms")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_rate(bytes_per_second: f32) -> String {
|
||||
format!("{}/s", format_bytes(bytes_per_second.max(0.0) as u64))
|
||||
}
|
||||
|
||||
fn danger_button<'a>(content: impl Into<Element<'a, Message>>) -> Button<'a, Message> {
|
||||
button(content).padding([8, 14]).style(danger_button_style)
|
||||
}
|
||||
@@ -1250,6 +1751,53 @@ fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||
style
|
||||
}
|
||||
|
||||
fn segmented_control_style(_: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(24, 24, 26))),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(57, 57, 60),
|
||||
width: 1.0,
|
||||
radius: 18.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn segmented_button_style(_: &Theme, status: button::Status, selected: bool) -> button::Style {
|
||||
let background = if selected {
|
||||
Some(Background::Color(Color::from_rgb8(55, 55, 58)))
|
||||
} else if status == button::Status::Hovered {
|
||||
Some(Background::Color(Color::from_rgb8(39, 39, 42)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
button::Style {
|
||||
background,
|
||||
text_color: if selected { Color::WHITE } else { muted_text() },
|
||||
border: Border {
|
||||
radius: 14.0.into(),
|
||||
..Border::default()
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge_style(color: Color) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(color.scale_alpha(0.12))),
|
||||
border: Border {
|
||||
color: color.scale_alpha(0.45),
|
||||
width: 1.0,
|
||||
radius: 12.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_bar_style(color: Color) -> container::Style {
|
||||
container::Style::default().background(color.scale_alpha(0.82))
|
||||
}
|
||||
|
||||
fn overview_style(_: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(31, 31, 33))),
|
||||
|
||||
Reference in New Issue
Block a user