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}
Deploy
Delete
"#,
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#"☕ HostItYourself {n} app(s)
System
CPU Load (1 min)
{load:.2}
Memory
{ram_used} / {ram_total} MB
{ram_pct}% used
Disk (/)
{disk_used:.1} / {disk_total:.1} GB
{disk_pct}% used
Apps
Name Repo Branch Container Last Deploy Actions
{rows}
"#,
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}
Logs
"#,
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}
••••••••
Remove
"#,
key = e.key,
));
}
let host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
let body = format!(
r#"
HIY / {name}
Deploy Now
{repo}
· branch {branch}
· port {port}
· {name}.{host}
Deploy History
SHA Status Triggered By Time
{deploy_rows}
GitHub Webhook
Configure GitHub → Settings → Webhooks → Add webhook:
Payload URL http(s)://YOUR_DOMAIN/webhook/{app_id}
Content type application/json
Secret {secret}
Events Just 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)))
}