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 ──────────────────────────────────────────
|
// ── 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)
|
||||||
|
|
|
||||||
|
|
@ -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,14 +186,16 @@ 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>
|
||||||
|
|
@ -109,13 +205,36 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
|
||||||
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>☕ HostItYourself</h1><span class="muted">{n} app(s)</span></nav>
|
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">
|
<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)))
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue