use std::path::PathBuf;
use eframe::egui::{self};
use crate::warehouse::iceberg::{IcebergWarehouse, TablePreview};
use super::facett_theme::{Theme, RED, AMBER};
const SCAN_LIMIT: usize = 50_000;
const TABLE: &str = "mcp_requests";
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
struct ToolStat {
tool: String,
calls: u64,
errors: u64,
avg_ms: f64,
}
pub struct McpTab {
source: Src,
workspace: String,
stats: Option<Result<Vec<ToolStat>, String>>,
total: u64,
capped: bool,
loaded: bool,
theme: Theme,
}
impl McpTab {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root), String::new())
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token }, workspace)
}
fn with(source: Src, workspace: String) -> Self {
Self { source, workspace, stats: None, total: 0, capped: false, loaded: false, theme: Theme::default() }
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub(crate) fn set_workspace(&mut self, workspace: String) {
self.workspace = workspace;
self.stats = None;
self.loaded = false;
}
fn scan(&self) -> Result<TablePreview, String> {
match &self.source {
Src::Local(root) => IcebergWarehouse::open(root)
.and_then(|wh| wh.scan_preview(TABLE, SCAN_LIMIT))
.map_err(|e| format!("{e:#}")),
Src::Remote { endpoint, token } => {
super::remote::scan_table(endpoint, token, TABLE, SCAN_LIMIT as u32, &self.workspace)
.map_err(|e| format!("{e:#}"))
}
}
}
pub(crate) fn total_calls(&self) -> u64 {
self.total
}
pub(crate) fn is_loaded(&self) -> bool {
self.loaded
}
pub(crate) fn ensure_loaded(&mut self) {
if !self.loaded {
self.reload();
}
}
fn reload(&mut self) {
self.loaded = true;
match self.scan() {
Ok(p) => {
self.capped = p.rows.len() >= SCAN_LIMIT;
let (stats, total) = aggregate(&p);
self.total = total;
self.stats = Some(Ok(stats));
}
Err(e) => self.stats = Some(Err(e)),
}
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"loaded": self.loaded,
"total_calls": self.total,
"capped": self.capped,
"rows": match &self.stats {
None => serde_json::Value::Null,
Some(Ok(stats)) => serde_json::json!(stats.iter().map(|s| serde_json::json!({
"tool": s.tool,
"calls": s.calls,
"errors": s.errors,
"avg_ms": s.avg_ms,
})).collect::<Vec<_>>()),
Some(Err(e)) => serde_json::json!({ "error": e }),
},
"palette": self.theme.name,
})
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
ui.horizontal(|ui| {
ui.heading("📞 MCP usage");
if ui.button("⟳ Reload").clicked() {
self.reload();
}
});
ui.label(
"per-tool call counts from the warehouse `mcp_requests` table — recorded by \
nornir-mcp on every tool dispatch (metrics only: tool, ok/err, latency).",
);
ui.separator();
if !self.loaded {
self.reload();
}
match &self.stats {
None => {
ui.spinner();
}
Some(Err(e)) => {
let lower = e.to_lowercase();
if lower.contains("does not exist")
|| lower.contains("not found")
|| lower.contains("nosuch")
{
ui.colored_label(
theme.text_dim,
"no MCP calls recorded yet — the table appears once nornir-mcp \
has served at least one tool call.",
);
} else {
ui.colored_label(RED, e);
}
}
Some(Ok(stats)) => {
if stats.is_empty() {
ui.colored_label(theme.text_dim, "no MCP calls recorded yet.");
return;
}
let errs: u64 = stats.iter().map(|s| s.errors).sum();
ui.label(format!(
"{} total call(s) across {} tool(s) · {} error(s){}",
self.total,
stats.len(),
errs,
if self.capped { format!(" · showing the last {SCAN_LIMIT} rows") } else { String::new() },
));
ui.add_space(6.0);
let max = stats.iter().map(|s| s.calls).max().unwrap_or(1).max(1);
egui::Grid::new("mcp_stats_grid")
.striped(true)
.num_columns(4)
.spacing([18.0, 4.0])
.show(ui, |ui| {
ui.strong("tool");
ui.strong("calls");
ui.strong("errors");
ui.strong("avg ms");
ui.end_row();
for s in stats {
ui.horizontal(|ui| {
let frac = s.calls as f32 / max as f32;
let (rect, _) = ui.allocate_exact_size(
egui::Vec2::new(90.0 * frac + 1.0, 11.0),
egui::Sense::hover(),
);
ui.painter().rect_filled(rect, 2.0, theme.accent);
ui.label(&s.tool);
});
ui.label(s.calls.to_string());
if s.errors > 0 {
ui.colored_label(AMBER, s.errors.to_string());
} else {
ui.label("0");
}
ui.label(format!("{:.1}", s.avg_ms));
ui.end_row();
}
});
}
}
}
}
fn aggregate(p: &TablePreview) -> (Vec<ToolStat>, u64) {
let idx = |name: &str| p.columns.iter().position(|c| c == name);
let (ti, si, li) = (idx("tool"), idx("status"), idx("latency_ms"));
let mut map: std::collections::HashMap<String, (u64, u64, f64)> = std::collections::HashMap::new();
let mut total = 0u64;
for row in &p.rows {
let tool = ti.and_then(|i| row.get(i)).cloned().unwrap_or_else(|| "?".to_string());
let ok = si.and_then(|i| row.get(i)).map(|s| s == "ok").unwrap_or(true);
let lat: f64 = li.and_then(|i| row.get(i)).and_then(|s| s.parse().ok()).unwrap_or(0.0);
let e = map.entry(tool).or_insert((0, 0, 0.0));
e.0 += 1;
if !ok {
e.1 += 1;
}
e.2 += lat;
total += 1;
}
let mut stats: Vec<ToolStat> = map
.into_iter()
.map(|(tool, (calls, errors, lat_sum))| ToolStat {
tool,
calls,
errors,
avg_ms: if calls > 0 { lat_sum / calls as f64 } else { 0.0 },
})
.collect();
stats.sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.tool.cmp(&b.tool)));
(stats, total)
}