feat(control-plane): system overview card, container runtime status, fix auto-refresh

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
This commit is contained in:
Claude 2026-03-19 12:20:09 +00:00
parent b83de1e743
commit 217bafc464
No known key found for this signature in database
2 changed files with 197 additions and 18 deletions

View file

@ -60,6 +60,8 @@ async fn main() -> anyhow::Result<()> {
// ── Dashboard UI ────────────────────────────────────────── // ── Dashboard UI ──────────────────────────────────────────
.route("/", get(routes::ui::index)) .route("/", get(routes::ui::index))
.route("/apps/:id", get(routes::ui::app_detail)) .route("/apps/:id", get(routes::ui::app_detail))
// ── Status API (container + deploy combined) ───────────────
.route("/api/status", get(routes::ui::status_json))
// ── Apps API ────────────────────────────────────────────── // ── Apps API ──────────────────────────────────────────────
.route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps", get(routes::apps::list).post(routes::apps::create))
.route("/api/apps/:id", get(routes::apps::get_one) .route("/api/apps/:id", get(routes::apps::get_one)

View file

@ -2,7 +2,10 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
Json,
}; };
use futures::future::join_all;
use std::collections::HashMap;
use crate::{ use crate::{
models::{App, Deploy, EnvVar}, models::{App, Deploy, EnvVar},
@ -49,6 +52,8 @@ const CSS: &str = r#"
.muted{color:#64748b;font-size:0.85rem} .muted{color:#64748b;font-size:0.85rem}
nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px} nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:28px}
.subtitle{color:#64748b;font-size:0.85rem;margin-bottom:20px} .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 { 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#"<span class="badge {cls}">{state}</span>"#)
}
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 ───────────────────────────────────────────────────────────────────── // ── Index ─────────────────────────────────────────────────────────────────────
pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode> { pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode> {
@ -82,6 +165,17 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .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<String, String> = container_statuses.into_iter().collect();
let mut rows = String::new(); let mut rows = String::new();
for app in &apps { for app in &apps {
let latest = sqlx::query_as::<_, Deploy>( let latest = sqlx::query_as::<_, Deploy>(
@ -92,30 +186,55 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
.await .await
.unwrap_or(None); .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!( rows.push_str(&format!(
r#"<tr> r#"<tr data-id="{id}">
<td><a href="/apps/{id}">{name}</a></td> <td><a href="/apps/{id}">{name}</a></td>
<td class="muted">{repo}</td> <td class="muted">{repo}</td>
<td><code>{branch}</code></td> <td><code>{branch}</code></td>
<td>{badge}</td> <td data-container-badge>{c_badge}</td>
<td data-deploy-badge>{d_badge}</td>
<td> <td>
<button class="primary" onclick="deploy('{id}')">Deploy</button> <button class="primary" onclick="deploy('{id}')">Deploy</button>
<button class="danger" onclick="del('{id}')">Delete</button> <button class="danger" onclick="del('{id}')">Delete</button>
</td> </td>
</tr>"#, </tr>"#,
id = app.id, id = app.id,
name = app.name, name = app.name,
repo = app.repo_url, repo = app.repo_url,
branch = app.branch, branch = app.branch,
badge = badge(status), 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!( let body = format!(
r#"<nav><h1>&#9749; HostItYourself</h1><span class="muted">{n} app(s)</span></nav> r#"<nav><h1>&#9749; HostItYourself</h1><span class="muted">{n} app(s)</span></nav>
<div class="card" style="padding:16px 24px">
<h2 style="margin-bottom:14px">System</h2>
<div class="grid3">
<div>
<label>CPU Load (1 min)</label>
<div class="stat-big">{load:.2}</div>
</div>
<div>
<label>Memory</label>
<div class="stat-big">{ram_used} / {ram_total} MB</div>
<div class="stat-sub">{ram_pct}% used</div>
</div>
<div>
<label>Disk (/)</label>
<div class="stat-big">{disk_used:.1} / {disk_total:.1} GB</div>
<div class="stat-sub">{disk_pct}% used</div>
</div>
</div>
</div>
<div class="card"> <div class="card">
<h2>Add App</h2> <h2>Add App</h2>
<div class="grid2" style="margin-bottom:12px"> <div class="grid2" style="margin-bottom:12px">
@ -132,7 +251,7 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
<div class="card"> <div class="card">
<h2>Apps</h2> <h2>Apps</h2>
<table> <table>
<thead><tr><th>Name</th><th>Repo</th><th>Branch</th><th>Status</th><th>Actions</th></tr></thead> <thead><tr><th>Name</th><th>Repo</th><th>Branch</th><th>Container</th><th>Last Deploy</th><th>Actions</th></tr></thead>
<tbody id="apps-body">{rows}</tbody> <tbody id="apps-body">{rows}</tbody>
</table> </table>
</div> </div>
@ -165,20 +284,40 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
await fetch('/api/apps/' + id, {{method: 'DELETE'}}); await fetch('/api/apps/' + id, {{method: 'DELETE'}});
window.location.reload(); 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 `<span class="badge ${{cls}}">${{s}}</span>`;
}}
function containerBadgeHtml(s) {{
const cls = {{running:'badge-success',exited:'badge-failed',restarting:'badge-building'}}[s] || 'badge-unknown';
return `<span class="badge ${{cls}}">${{s}}</span>`;
}}
// Auto-refresh container + deploy statuses every 5 s.
setInterval(async () => {{ setInterval(async () => {{
const r = await fetch('/api/apps'); const r = await fetch('/api/status');
if (!r.ok) return; if (!r.ok) return;
const apps = await r.json(); const statuses = await r.json();
// Only update the status badges to avoid disrupting interactions. Object.entries(statuses).forEach(([id, s]) => {{
apps.forEach(app => {{ const row = document.querySelector(`tr[data-id="${{id}}"]`);
const row = document.querySelector(`tr[data-id="${{app.id}}"]`); if (!row) return;
if (row) row.querySelector('.badge').textContent = app.status ?? ''; 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); }}, 5000);
</script>"#, </script>"#,
n = apps.len(), n = apps.len(),
rows = rows, 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))) Ok(Html(page("Dashboard", &body)))
@ -401,3 +540,41 @@ pub async fn app_detail(
Html(page(&app.name, &body)).into_response() Html(page(&app.name, &body)).into_response()
} }
// ── Status API (container + deploy) ───────────────────────────────────────────
pub async fn status_json(
State(s): State<AppState>,
) -> Result<Json<serde_json::Value>, 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<String, serde_json::Value> = results.into_iter()
.map(|(id, deploy, container)| {
(id, serde_json::json!({ "deploy": deploy, "container": container }))
})
.collect();
Ok(Json(serde_json::Value::Object(map)))
}