From 217bafc4641651d94ffe651788ce9b7fb4d316df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 12:20:09 +0000 Subject: [PATCH] feat(control-plane): system overview card, container runtime status, fix auto-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard now shows: - System card at top: CPU 1-min load average, RAM used/total, disk used/total (reads /proc/loadavg, /proc/meminfo, df -k /) - Two status columns in the apps table: - "Container" — actual Docker runtime state (running/exited/restarting/not deployed) via `docker inspect` on each app's hiy-{id} container - "Last Deploy" — build pipeline status (queued/building/success/failed) - Auto-refresh now calls /api/status every 5 s and updates both columns (fixes the previous broken refresh that used app.status which didn't exist) New API endpoint: GET /api/status → {app_id: {deploy, container}} for all apps https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- server/src/main.rs | 2 + server/src/routes/ui.rs | 213 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 197 insertions(+), 18 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index bca16fd..ea694e1 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -60,6 +60,8 @@ async fn main() -> anyhow::Result<()> { // ── Dashboard UI ────────────────────────────────────────── .route("/", get(routes::ui::index)) .route("/apps/:id", get(routes::ui::app_detail)) + // ── Status API (container + deploy combined) ─────────────── + .route("/api/status", get(routes::ui::status_json)) // ── Apps API ────────────────────────────────────────────── .route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps/:id", get(routes::apps::get_one) diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 6b975af..99823bc 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -2,7 +2,10 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, + Json, }; +use futures::future::join_all; +use std::collections::HashMap; use crate::{ models::{App, Deploy, EnvVar}, @@ -49,6 +52,8 @@ const CSS: &str = r#" .muted{color:#64748b;font-size:0.85rem} nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px} .subtitle{color:#64748b;font-size:0.85rem;margin-bottom:20px} + .stat-big{font-size:1.4rem;font-weight:bold;margin-top:4px} + .stat-sub{color:#64748b;font-size:0.82rem;margin-top:2px} "#; fn badge(status: &str) -> String { @@ -74,6 +79,84 @@ fn page(title: &str, body: &str) -> String { ) } +fn container_badge(state: &str) -> String { + let cls = match state { + "running" => "badge-success", + "exited" => "badge-failed", + "restarting" => "badge-building", + _ => "badge-unknown", + }; + format!(r#"{state}"#) +} + +struct SysStats { + load_1m: f32, + ram_used_mb: u64, + ram_total_mb: u64, + disk_used_gb: f64, + disk_total_gb: f64, + disk_pct: u64, +} + +async fn read_sys_stats() -> SysStats { + let load_1m = tokio::fs::read_to_string("/proc/loadavg") + .await + .ok() + .and_then(|s| s.split_whitespace().next().and_then(|v| v.parse().ok())) + .unwrap_or(0.0f32); + + let mut ram_total_kb = 0u64; + let mut ram_avail_kb = 0u64; + if let Ok(content) = tokio::fs::read_to_string("/proc/meminfo").await { + for line in content.lines() { + if line.starts_with("MemTotal:") { + ram_total_kb = line.split_whitespace().nth(1).and_then(|v| v.parse().ok()).unwrap_or(0); + } else if line.starts_with("MemAvailable:") { + ram_avail_kb = line.split_whitespace().nth(1).and_then(|v| v.parse().ok()).unwrap_or(0); + } + } + } + let ram_used_mb = (ram_total_kb.saturating_sub(ram_avail_kb)) / 1024; + let ram_total_mb = ram_total_kb / 1024; + + let mut disk_total_kb = 0u64; + let mut disk_used_kb = 0u64; + if let Ok(out) = tokio::process::Command::new("df") + .args(["-k", "/"]) + .output() + .await + { + let text = String::from_utf8_lossy(&out.stdout); + if let Some(line) = text.lines().nth(1) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() >= 3 { + disk_total_kb = cols[1].parse().unwrap_or(0); + disk_used_kb = cols[2].parse().unwrap_or(0); + } + } + } + let disk_total_gb = disk_total_kb as f64 / 1_048_576.0; + let disk_used_gb = disk_used_kb as f64 / 1_048_576.0; + let disk_pct = if disk_total_kb > 0 { disk_used_kb * 100 / disk_total_kb } else { 0 }; + + SysStats { load_1m, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb, disk_pct } +} + +async fn get_container_status(app_id: &str) -> String { + let name = format!("hiy-{}", app_id); + match tokio::process::Command::new("docker") + .args(["inspect", "--format", "{{.State.Status}}", &name]) + .output() + .await + { + Ok(out) if out.status.success() => { + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { "unknown".to_string() } else { s } + } + _ => "not deployed".to_string(), + } +} + // ── Index ───────────────────────────────────────────────────────────────────── pub async fn index(State(s): State) -> Result, StatusCode> { @@ -82,6 +165,17 @@ pub async fn index(State(s): State) -> Result, StatusCode .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Fetch system stats and container statuses concurrently. + let container_futs: Vec<_> = apps.iter().map(|a| { + let id = a.id.clone(); + async move { (id.clone(), get_container_status(&id).await) } + }).collect(); + let (stats, container_statuses) = tokio::join!( + read_sys_stats(), + join_all(container_futs), + ); + let container_map: HashMap = container_statuses.into_iter().collect(); + let mut rows = String::new(); for app in &apps { let latest = sqlx::query_as::<_, Deploy>( @@ -92,30 +186,55 @@ pub async fn index(State(s): State) -> Result, StatusCode .await .unwrap_or(None); - let status = latest.as_ref().map(|d| d.status.as_str()).unwrap_or("–"); + let deploy_status = latest.as_ref().map(|d| d.status.as_str()).unwrap_or("–"); + let c_status = container_map.get(&app.id).map(|s| s.as_str()).unwrap_or("unknown"); rows.push_str(&format!( - r#" + r#" {name} {repo} {branch} - {badge} + {c_badge} + {d_badge} "#, - id = app.id, - name = app.name, - repo = app.repo_url, - branch = app.branch, - badge = badge(status), + id = app.id, + name = app.name, + repo = app.repo_url, + branch = app.branch, + c_badge = container_badge(c_status), + d_badge = badge(deploy_status), )); } + let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 }; + let body = format!( r#" +
+

System

+
+
+ +
{load:.2}
+
+
+ +
{ram_used} / {ram_total} MB
+
{ram_pct}% used
+
+
+ +
{disk_used:.1} / {disk_total:.1} GB
+
{disk_pct}% used
+
+
+
+

Add App

@@ -132,7 +251,7 @@ pub async fn index(State(s): State) -> Result, StatusCode

Apps

- + {rows}
NameRepoBranchStatusActions
NameRepoBranchContainerLast DeployActions
@@ -165,20 +284,40 @@ pub async fn index(State(s): State) -> Result, StatusCode await fetch('/api/apps/' + id, {{method: 'DELETE'}}); window.location.reload(); }} - // Auto-refresh status every 5 s. + + function deployBadgeHtml(s) {{ + const cls = {{success:'badge-success',failed:'badge-failed',building:'badge-building',queued:'badge-building'}}[s] || 'badge-unknown'; + return `${{s}}`; + }} + function containerBadgeHtml(s) {{ + const cls = {{running:'badge-success',exited:'badge-failed',restarting:'badge-building'}}[s] || 'badge-unknown'; + return `${{s}}`; + }} + + // Auto-refresh container + deploy statuses every 5 s. setInterval(async () => {{ - const r = await fetch('/api/apps'); + const r = await fetch('/api/status'); if (!r.ok) return; - const apps = await r.json(); - // Only update the status badges to avoid disrupting interactions. - apps.forEach(app => {{ - const row = document.querySelector(`tr[data-id="${{app.id}}"]`); - if (row) row.querySelector('.badge').textContent = app.status ?? '–'; + const statuses = await r.json(); + Object.entries(statuses).forEach(([id, s]) => {{ + const row = document.querySelector(`tr[data-id="${{id}}"]`); + if (!row) return; + const cb = row.querySelector('[data-container-badge]'); + const db = row.querySelector('[data-deploy-badge]'); + if (cb) cb.innerHTML = containerBadgeHtml(s.container); + if (db) db.innerHTML = deployBadgeHtml(s.deploy); }}); }}, 5000); "#, - n = apps.len(), - rows = rows, + n = apps.len(), + rows = rows, + load = stats.load_1m, + ram_used = stats.ram_used_mb, + ram_total = stats.ram_total_mb, + ram_pct = ram_pct, + disk_used = stats.disk_used_gb, + disk_total = stats.disk_total_gb, + disk_pct = stats.disk_pct, ); Ok(Html(page("Dashboard", &body))) @@ -401,3 +540,41 @@ pub async fn app_detail( Html(page(&app.name, &body)).into_response() } + +// ── Status API (container + deploy) ─────────────────────────────────────────── + +pub async fn status_json( + State(s): State, +) -> Result, StatusCode> { + let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC") + .fetch_all(&s.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let futs: Vec<_> = apps.iter().map(|a| { + let db = s.db.clone(); + let id = a.id.clone(); + async move { + let deploy = sqlx::query_scalar::<_, String>( + "SELECT status FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 1", + ) + .bind(&id) + .fetch_optional(&db) + .await + .unwrap_or(None) + .unwrap_or_else(|| "–".to_string()); + + let container = get_container_status(&id).await; + (id, deploy, container) + } + }).collect(); + + let results = join_all(futs).await; + let map: serde_json::Map = results.into_iter() + .map(|(id, deploy, container)| { + (id, serde_json::json!({ "deploy": deploy, "container": container })) + }) + .collect(); + + Ok(Json(serde_json::Value::Object(map))) +}