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:
parent
b83de1e743
commit
217bafc464
2 changed files with 197 additions and 18 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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#"<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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
.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();
|
||||
for app in &apps {
|
||||
let latest = sqlx::query_as::<_, Deploy>(
|
||||
|
|
@ -92,14 +186,16 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, 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#"<tr>
|
||||
r#"<tr data-id="{id}">
|
||||
<td><a href="/apps/{id}">{name}</a></td>
|
||||
<td class="muted">{repo}</td>
|
||||
<td><code>{branch}</code></td>
|
||||
<td>{badge}</td>
|
||||
<td data-container-badge>{c_badge}</td>
|
||||
<td data-deploy-badge>{d_badge}</td>
|
||||
<td>
|
||||
<button class="primary" onclick="deploy('{id}')">Deploy</button>
|
||||
<button class="danger" onclick="del('{id}')">Delete</button>
|
||||
|
|
@ -109,13 +205,36 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
|
|||
name = app.name,
|
||||
repo = app.repo_url,
|
||||
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!(
|
||||
r#"<nav><h1>☕ 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">
|
||||
<h2>Add App</h2>
|
||||
<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">
|
||||
<h2>Apps</h2>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -165,20 +284,40 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, 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 `<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 () => {{
|
||||
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);
|
||||
</script>"#,
|
||||
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<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)))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue