Hostityourself/server/src/routes/ui.rs
Claude 217bafc464
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
2026-03-19 12:20:09 +00:00

580 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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},
AppState,
};
// ── Shared styles ──────────────────────────────────────────────────────────────
const CSS: &str = r#"
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:32px 24px;max-width:1100px;margin:0 auto}
h1{color:#a78bfa;font-size:1.6rem;margin-bottom:4px}
h2{color:#818cf8;font-size:1.1rem;margin-bottom:16px}
a{color:#818cf8;text-decoration:none}
a:hover{text-decoration:underline}
.card{background:#1e293b;border-radius:10px;padding:24px;margin-bottom:24px}
table{width:100%;border-collapse:collapse}
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid #0f172a;font-size:0.9rem}
th{color:#64748b;font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em}
tr:last-child td{border-bottom:none}
.badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:0.75rem;font-weight:bold}
.badge-success{background:#14532d;color:#4ade80}
.badge-failed{background:#450a0a;color:#f87171}
.badge-building,.badge-queued{background:#451a03;color:#fb923c}
.badge-unknown{background:#1e293b;color:#64748b}
button,input[type=submit]{background:#334155;color:#e2e8f0;border:1px solid #475569;padding:5px 14px;
border-radius:6px;cursor:pointer;font-family:monospace;font-size:0.9rem}
button:hover{background:#475569}
button.danger{border-color:#7f1d1d;color:#fca5a5}
button.danger:hover{background:#7f1d1d}
button.primary{background:#4c1d95;border-color:#7c3aed;color:#ddd6fe}
button.primary:hover{background:#5b21b6}
input[type=text],input[type=password],input[type=number]{
background:#0f172a;color:#e2e8f0;border:1px solid #334155;padding:6px 10px;
border-radius:6px;font-family:monospace;font-size:0.9rem;width:100%}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.row input{flex:1;min-width:120px}
label{display:block;color:#64748b;font-size:0.78rem;margin-bottom:4px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:0.85rem}
pre{background:#0f172a;padding:16px;border-radius:8px;white-space:pre-wrap;
word-break:break-all;font-size:0.82rem;max-height:420px;overflow-y:auto;line-height:1.5}
.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 {
let cls = match status {
"success" => "badge-success",
"failed" => "badge-failed",
"building"|"queued" => "badge-building",
_ => "badge-unknown",
};
format!(r#"<span class="badge {cls}">{status}</span>"#)
}
fn page(title: &str, body: &str) -> String {
format!(
r#"<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} — HostItYourself</title>
<style>{CSS}</style>
</head><body>{body}</body></html>"#,
title = title,
CSS = CSS,
body = body,
)
}
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> {
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)?;
// 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>(
"SELECT * FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(&app.id)
.fetch_optional(&s.db)
.await
.unwrap_or(None);
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 data-id="{id}">
<td><a href="/apps/{id}">{name}</a></td>
<td class="muted">{repo}</td>
<td><code>{branch}</code></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>
</td>
</tr>"#,
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#"<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">
<h2>Add App</h2>
<div class="grid2" style="margin-bottom:12px">
<div><label>Name (slug)</label><input id="f-name" type="text" placeholder="my-api"></div>
<div><label>GitHub Repo URL</label><input id="f-repo" type="text" placeholder="https://github.com/you/repo.git"></div>
</div>
<div class="grid2" style="margin-bottom:16px">
<div><label>Branch</label><input id="f-branch" type="text" value="main"></div>
<div><label>Container Port</label><input id="f-port" type="number" placeholder="3000"></div>
</div>
<button class="primary" onclick="createApp()">Create App</button>
</div>
<div class="card">
<h2>Apps</h2>
<table>
<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>
<script>
async function createApp() {{
const data = {{
name: document.getElementById('f-name').value.trim(),
repo_url: document.getElementById('f-repo').value.trim(),
branch: document.getElementById('f-branch').value.trim() || 'main',
port: parseInt(document.getElementById('f-port').value),
}};
if (!data.name || !data.repo_url || !data.port) {{ alert('Fill in all fields'); return; }}
const r = await fetch('/api/apps', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify(data),
}});
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}}
async function deploy(id) {{
if (!confirm('Deploy ' + id + ' now?')) return;
const r = await fetch('/api/apps/' + id + '/deploy', {{method: 'POST'}});
if (r.ok) window.location.href = '/apps/' + id;
else alert('Error: ' + await r.text());
}}
async function del(id) {{
if (!confirm('Delete app "' + id + '"? This cannot be undone.')) return;
await fetch('/api/apps/' + id, {{method: 'DELETE'}});
window.location.reload();
}}
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/status');
if (!r.ok) return;
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)))
}
// ── App detail ────────────────────────────────────────────────────────────────
pub async fn app_detail(
State(s): State<AppState>,
Path(app_id): Path<String>,
) -> Response {
let app = match sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?")
.bind(&app_id)
.fetch_optional(&s.db)
.await
{
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Ok(None) => return Redirect::to("/").into_response(),
Ok(Some(a)) => a,
};
let deploys = sqlx::query_as::<_, Deploy>(
"SELECT * FROM deploys WHERE app_id = ? ORDER BY created_at DESC LIMIT 15",
)
.bind(&app_id)
.fetch_all(&s.db)
.await
.unwrap_or_default();
let env_vars = sqlx::query_as::<_, EnvVar>(
"SELECT * FROM env_vars WHERE app_id = ? ORDER BY key",
)
.bind(&app_id)
.fetch_all(&s.db)
.await
.unwrap_or_default();
let latest_deploy_id = deploys.first().map(|d| d.id.as_str()).unwrap_or("");
let mut deploy_rows = String::new();
for d in &deploys {
let sha_short = d.sha.as_deref()
.and_then(|s| s.get(..7))
.unwrap_or("");
let time = d.created_at.get(..19).unwrap_or(&d.created_at);
deploy_rows.push_str(&format!(
r#"<tr>
<td><code>{sha}</code></td>
<td>{badge}</td>
<td class="muted">{by}</td>
<td class="muted">{time}</td>
<td><button onclick="showLog('{id}')">Logs</button></td>
</tr>"#,
sha = sha_short,
badge = badge(&d.status),
by = d.triggered_by,
time = time,
id = d.id,
));
}
let mut env_rows = String::new();
for e in &env_vars {
env_rows.push_str(&format!(
r#"<tr>
<td><code>{key}</code></td>
<td class="muted">••••••••</td>
<td><button class="danger" onclick="removeEnv('{key}')">Remove</button></td>
</tr>"#,
key = e.key,
));
}
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
let body = format!(
r#"<nav>
<h1><a href="/" style="color:inherit">HIY</a> / {name}</h1>
<button class="primary" onclick="deploy()">Deploy Now</button>
</nav>
<p class="subtitle">
<a href="{repo}" target="_blank">{repo}</a>
&nbsp;·&nbsp; branch <code>{branch}</code>
&nbsp;·&nbsp; port <code>{port}</code>
&nbsp;·&nbsp; <a href="http://{name}.{host}" target="_blank">{name}.{host}</a>
</p>
<div class="card">
<h2>Deploy History</h2>
<table>
<thead><tr><th>SHA</th><th>Status</th><th>Triggered By</th><th>Time</th><th></th></tr></thead>
<tbody>{deploy_rows}</tbody>
</table>
<div id="log-panel" style="display:none;margin-top:16px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<h2 id="log-title">Build Log</h2>
<a id="back-btn" href="/" style="display:none">
<button>← Dashboard</button>
</a>
</div>
<pre id="log-out"></pre>
</div>
</div>
<div class="card">
<h2>Environment Variables</h2>
<div class="row" style="margin-bottom:16px">
<div style="flex:1"><label>Key</label><input id="ev-key" type="text" placeholder="DATABASE_URL"></div>
<div style="flex:2"><label>Value</label><input id="ev-val" type="password" placeholder="secret"></div>
<div style="align-self:flex-end"><button class="primary" onclick="setEnv()">Set</button></div>
</div>
<table>
<thead><tr><th>Key</th><th>Value</th><th></th></tr></thead>
<tbody id="env-body">{env_rows}</tbody>
</table>
</div>
<div class="card">
<h2>GitHub Webhook</h2>
<p style="margin-bottom:8px">Configure GitHub → Settings → Webhooks → Add webhook:</p>
<table>
<tr><td style="width:140px">Payload URL</td><td><code>http(s)://YOUR_DOMAIN/webhook/{app_id}</code></td></tr>
<tr><td>Content type</td><td><code>application/json</code></td></tr>
<tr><td>Secret</td><td><code>{secret}</code></td></tr>
<tr><td>Events</td><td>Just the <em>push</em> event</td></tr>
</table>
</div>
<script>
const APP_ID = '{app_id}';
// Auto-open the latest deploy log on page load.
window.addEventListener('DOMContentLoaded', () => {{
const latest = '{latest_deploy_id}';
if (latest) showLog(latest);
}});
async function deploy() {{
const r = await fetch('/api/apps/' + APP_ID + '/deploy', {{method:'POST'}});
if (r.ok) {{ const d = await r.json(); showLog(d.id); }}
else alert('Deploy failed: ' + await r.text());
}}
async function showLog(deployId) {{
const panel = document.getElementById('log-panel');
const out = document.getElementById('log-out');
const title = document.getElementById('log-title');
panel.style.display = 'block';
out.textContent = 'Loading…';
panel.scrollIntoView({{behavior:'smooth'}});
// Fetch current deploy state first.
const r = await fetch('/api/deploys/' + deployId);
if (!r.ok) {{ out.textContent = 'Could not load deploy ' + deployId; return; }}
const deploy = await r.json();
title.textContent = 'Build Log — ' + deploy.status;
title.style.color = deploy.status === 'success' ? '#4ade80'
: deploy.status === 'failed' ? '#f87171' : '#fb923c';
// Already finished — just render the stored log, no SSE needed.
if (deploy.status === 'success' || deploy.status === 'failed') {{
out.textContent = deploy.log || '(no output captured)';
out.scrollTop = out.scrollHeight;
document.getElementById('back-btn').style.display = 'inline-block';
return;
}}
// Still running — stream updates via SSE.
out.textContent = '';
const es = new EventSource('/api/deploys/' + deployId + '/logs');
es.onmessage = e => {{
out.textContent += e.data;
out.scrollTop = out.scrollHeight;
}};
es.addEventListener('done', e => {{
es.close();
title.textContent = 'Build Log — ' + e.data;
title.style.color = e.data === 'success' ? '#4ade80' : '#f87171';
document.getElementById('back-btn').style.display = 'inline-block';
}});
es.onerror = () => {{
es.close();
// Fallback: re-fetch the finished log.
fetch('/api/deploys/' + deployId)
.then(r => r.json())
.then(d => {{
out.textContent = d.log || out.textContent;
title.textContent = 'Build Log — ' + d.status;
}});
}};
}}
async function setEnv() {{
const key = document.getElementById('ev-key').value.trim();
const val = document.getElementById('ev-val').value;
if (!key) {{ alert('Key required'); return; }}
await fetch('/api/apps/' + APP_ID + '/env', {{
method:'POST',
headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{key, value: val}}),
}});
window.location.reload();
}}
async function removeEnv(key) {{
if (!confirm('Remove ' + key + '?')) return;
await fetch('/api/apps/' + APP_ID + '/env/' + key, {{method:'DELETE'}});
window.location.reload();
}}
</script>"#,
name = app.name,
repo = app.repo_url,
branch = app.branch,
port = app.port,
host = host,
app_id = app.id,
secret = app.webhook_secret,
deploy_rows = deploy_rows,
env_rows = env_rows,
latest_deploy_id = latest_deploy_id,
);
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)))
}