Skip to content

Commit ff9c131

Browse files
author
sqlopt
committed
feat(monitor): read-back API + UI for deep vitals (CPU/mem/IO/tempdb/plan-cache)
1 parent c52643a commit ff9c131

7 files changed

Lines changed: 658 additions & 11 deletions

File tree

crates/backend/src/routes.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub fn router() -> Router {
2424
.route("/health/issue/detail", post(crate::health::enrichment::issue_detail))
2525
.route("/dmv", post(dmv))
2626
.route("/monitor/live", post(monitor_live))
27+
.route("/monitor/vitals", post(monitor_vitals))
2728
.route("/explain", post(explain))
2829
.route("/plan/actual", post(plan_actual))
2930
.route("/validate", post(validate))
@@ -148,6 +149,19 @@ async fn monitor_live(Json(req): Json<ConnectReq>) -> impl IntoResponse {
148149
}
149150
}
150151

152+
/// POST /api/monitor/vitals — the most-recent DEEP-VITALS sample of each
153+
/// surface (CPU pressure, memory headroom, per-file I/O latency, tempdb
154+
/// allocation contention, plan-cache health) that the background monitor has
155+
/// persisted for the connected server.
156+
///
157+
/// Read-only: opens the sentinel SQLite store and reads it back — it never
158+
/// touches the live server. If the store doesn't exist yet, or the server has
159+
/// never been monitored, it returns 200 with `has_data: false` (an honest empty
160+
/// state, NOT an error) so the UI can prompt the user to start the monitor.
161+
async fn monitor_vitals(Json(req): Json<ConnectReq>) -> impl IntoResponse {
162+
(StatusCode::OK, Json(sentinel_api::deep_vitals(&req.server))).into_response()
163+
}
164+
151165
async fn databases(Json(req): Json<ConnectReq>) -> impl IntoResponse {
152166
match sqlserver::list_databases(&req).await {
153167
Ok(dbs) => (StatusCode::OK, Json(serde_json::json!({ "databases": dbs }))).into_response(),

crates/backend/src/sentinel_api.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,70 @@ pub fn monitoring_age_secs() -> Option<i64> {
285285
Storage::open(&path).ok().and_then(|s| s.monitoring_age_secs())
286286
}
287287

288+
/// Read the most-recent DEEP-VITALS sample of each surface for `server` out of
289+
/// the sentinel store, shaped for the live UI's "DEEP VITALS" panel.
290+
///
291+
/// Honest empty state, never an error: if the store doesn't exist yet, or the
292+
/// server has never been monitored (no instance row), every surface is `null`
293+
/// (I/O latency `[]`) and `has_data` is `false`. `captured_at` is the newest
294+
/// instant across all surfaces present (epoch millis), or `null` when empty —
295+
/// the UI shows it as "as of …".
296+
pub fn deep_vitals(server: &str) -> serde_json::Value {
297+
let empty = || {
298+
serde_json::json!({
299+
"has_data": false,
300+
"captured_at": serde_json::Value::Null,
301+
"cpu_pressure": serde_json::Value::Null,
302+
"memory_headroom": serde_json::Value::Null,
303+
"io_latency": [],
304+
"tempdb_contention": serde_json::Value::Null,
305+
"plan_cache": serde_json::Value::Null,
306+
})
307+
};
308+
309+
let path = SentinelConfig::default_db_path();
310+
let storage = match Storage::open(&path) {
311+
Ok(s) => s,
312+
Err(_) => return empty(), // store not created yet → not an error
313+
};
314+
let Some(instance_id) = storage.get_instance_id(server) else {
315+
return empty(); // server never monitored → not an error
316+
};
317+
318+
let cpu = storage.latest_cpu_pressure(instance_id);
319+
let mem = storage.latest_memory_headroom(instance_id);
320+
let io = storage.latest_io_latency(instance_id);
321+
let tempdb = storage.latest_tempdb_contention(instance_id);
322+
let plan = storage.latest_plan_cache(instance_id);
323+
324+
// Newest captured_at across whichever surfaces have data (millis).
325+
let captured_at_ms = [
326+
cpu.as_ref().map(|r| r.captured_at.timestamp_millis()),
327+
mem.as_ref().map(|r| r.captured_at.timestamp_millis()),
328+
io.first().map(|r| r.captured_at.timestamp_millis()),
329+
tempdb.as_ref().map(|r| r.captured_at.timestamp_millis()),
330+
plan.as_ref().map(|r| r.captured_at.timestamp_millis()),
331+
]
332+
.into_iter()
333+
.flatten()
334+
.max();
335+
336+
let has_data =
337+
cpu.is_some() || mem.is_some() || !io.is_empty() || tempdb.is_some() || plan.is_some();
338+
339+
// The row structs derive Serialize, so each maps straight to JSON; the field
340+
// names match the storage row structs (snake_case), which the UI consumes.
341+
serde_json::json!({
342+
"has_data": has_data,
343+
"captured_at": captured_at_ms,
344+
"cpu_pressure": cpu,
345+
"memory_headroom": mem,
346+
"io_latency": io,
347+
"tempdb_contention": tempdb,
348+
"plan_cache": plan,
349+
})
350+
}
351+
288352
pub fn build_report(window: TimeRange) -> WeeklyReport {
289353
let path = SentinelConfig::default_db_path();
290354
match Storage::open(&path) {

crates/sentinel/src/poll/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
//! Per-surface pollers. Each submodule exposes a single async `poll_*`
2-
//! function that the scheduler invokes on its cadence. Bodies are stubs
3-
//! today — next session swaps in the real `tiberius` queries.
2+
//! function that the scheduler invokes on its cadence. Each one runs a real
3+
//! read-only `tiberius` query against the live server's DMVs, maps the result
4+
//! into the matching `storage` row struct, and persists it (the cumulative
5+
//! surfaces — waits, index usage, file I/O — diff against the prior snapshot
6+
//! held in `poller_state` to emit per-window deltas). Pollers degrade
7+
//! gracefully: a missing DMV or a lack of VIEW SERVER STATE logs once and skips
8+
//! the tick rather than failing the whole daemon.
49
510
pub mod cpu_pressure;
611
pub mod deadlocks;

0 commit comments

Comments
 (0)