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
580 lines
24 KiB
Rust
580 lines
24 KiB
Rust
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>☕ 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>
|
||
· branch <code>{branch}</code>
|
||
· port <code>{port}</code>
|
||
· <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)))
|
||
}
|