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#"{status}"#) } fn page(title: &str, body: &str) -> String { format!( r#" {title} — HostItYourself {body}"#, 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#"{state}"#) } 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) -> Result, 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 = 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#" {name} {repo} {branch} {c_badge} {d_badge} "#, 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#"

System

{load:.2}
{ram_used} / {ram_total} MB
{ram_pct}% used
{disk_used:.1} / {disk_total:.1} GB
{disk_pct}% used

Add App

Apps

{rows}
NameRepoBranchContainerLast DeployActions
"#, 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, Path(app_id): Path, ) -> 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#" {sha} {badge} {by} {time} "#, 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#" {key} •••••••• "#, key = e.key, )); } let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); let body = format!( r#"

{repo}  ·  branch {branch}  ·  port {port}  ·  {name}.{host}

Deploy History

{deploy_rows}
SHAStatusTriggered ByTime

Environment Variables

{env_rows}
KeyValue

GitHub Webhook

Configure GitHub → Settings → Webhooks → Add webhook:

Payload URLhttp(s)://YOUR_DOMAIN/webhook/{app_id}
Content typeapplication/json
Secret{secret}
EventsJust the push event
"#, 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, ) -> Result, 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 = results.into_iter() .map(|(id, deploy, container)| { (id, serde_json::json!({ "deploy": deploy, "container": container })) }) .collect(); Ok(Json(serde_json::Value::Object(map))) }