diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs
index 356c40f..6e5cdb7 100644
--- a/server/src/routes/ui.rs
+++ b/server/src/routes/ui.rs
@@ -12,49 +12,14 @@ use crate::{
AppState,
};
-// ── Shared styles ──────────────────────────────────────────────────────────────
+// ── Templates (compiled into the binary) ──────────────────────────────────────
-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}
-"#;
+const CSS: &str = include_str!("../../templates/styles.css");
+const INDEX_TMPL: &str = include_str!("../../templates/index.html");
+const APP_DETAIL_TMPL: &str = include_str!("../../templates/app_detail.html");
+const USERS_TMPL: &str = include_str!("../../templates/users.html");
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
fn badge(status: &str) -> String {
let cls = match status {
@@ -66,19 +31,6 @@ fn badge(status: &str) -> String {
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",
@@ -89,6 +41,18 @@ fn container_badge(state: &str) -> String {
format!(r#"{state}"#)
}
+fn page(title: &str, body: &str) -> String {
+ format!(
+ r#"
+
+ {title} — HostItYourself
+
+ {body}"#,
+ )
+}
+
+// ── System stats ──────────────────────────────────────────────────────────────
+
struct SysStats {
load_1m: f32,
ram_used_mb: u64,
@@ -165,7 +129,6 @@ pub async fn index(State(s): State) -> Result, StatusCode
.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) }
@@ -214,131 +177,16 @@ pub async fn index(State(s): State) -> Result, StatusCode
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
-
- | 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,
- );
+ let body = INDEX_TMPL
+ .replace("{{n}}", &apps.len().to_string())
+ .replace("{{rows}}", &rows)
+ .replace("{{load}}", &format!("{:.2}", stats.load_1m))
+ .replace("{{ram_used}}", &stats.ram_used_mb.to_string())
+ .replace("{{ram_total}}", &stats.ram_total_mb.to_string())
+ .replace("{{ram_pct}}", &ram_pct.to_string())
+ .replace("{{disk_used}}", &format!("{:.1}", stats.disk_used_gb))
+ .replace("{{disk_total}}", &format!("{:.1}", stats.disk_total_gb))
+ .replace("{{disk_pct}}", &stats.disk_pct.to_string());
Ok(Html(page("Dashboard", &body)))
}
@@ -413,163 +261,17 @@ pub async fn app_detail(
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
-
- | SHA | Status | Triggered By | Time | |
- {deploy_rows}
-
-
-
-
-
-
Environment Variables
-
-
- | Key | Value | |
- {env_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,
- c_badge = container_badge(&container_state),
- );
+ let body = APP_DETAIL_TMPL
+ .replace("{{name}}", &app.name)
+ .replace("{{repo}}", &app.repo_url)
+ .replace("{{branch}}", &app.branch)
+ .replace("{{port}}", &app.port.to_string())
+ .replace("{{host}}", &host)
+ .replace("{{app_id}}", &app.id)
+ .replace("{{secret}}", &app.webhook_secret)
+ .replace("{{deploy_rows}}", &deploy_rows)
+ .replace("{{env_rows}}", &env_rows)
+ .replace("{{c_badge}}", &container_badge(&container_state));
Html(page(&app.name, &body)).into_response()
}
@@ -612,10 +314,9 @@ pub async fn status_json(
Ok(Json(serde_json::Value::Object(map)))
}
-// ── Users management page ────────────────────────────────────────────────────
+// ── Users management page ─────────────────────────────────────────────────────
pub async fn users_page(State(state): State) -> impl IntoResponse {
- // Fetch all apps for the grant dropdowns.
let apps: Vec<(String, String)> =
sqlx::query_as("SELECT id, name FROM apps ORDER BY name")
.fetch_all(&state.db)
@@ -628,254 +329,7 @@ pub async fn users_page(State(state): State) -> impl IntoResponse {
.collect::>()
.join("");
- let body = format!(
- r#"
-
-
-
-
-
- "#
- );
+ let body = USERS_TMPL.replace("{{app_opts}}", &app_opts);
Html(page("Users", &body))
}
diff --git a/server/templates/app_detail.html b/server/templates/app_detail.html
new file mode 100644
index 0000000..bd8473b
--- /dev/null
+++ b/server/templates/app_detail.html
@@ -0,0 +1,145 @@
+
+
+ {{repo}}
+ · branch {{branch}}
+ · port {{port}}
+ · {{name}}.{{host}}
+
+
+
+
Deploy History
+
+ | SHA | Status | Triggered By | Time | |
+ {{deploy_rows}}
+
+
+
+
+
+
Environment Variables
+
+
+ | Key | Value | |
+ {{env_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 |
+
+
+
+
diff --git a/server/templates/index.html b/server/templates/index.html
new file mode 100644
index 0000000..dcf86a6
--- /dev/null
+++ b/server/templates/index.html
@@ -0,0 +1,114 @@
+
+
+
+
System
+
+
+
+
{{load}}
+
+
+
+
{{ram_used}} / {{ram_total}} MB
+
{{ram_pct}}% used
+
+
+
+
{{disk_used}} / {{disk_total}} GB
+
{{disk_pct}}% used
+
+
+
+
+
+
Add App
+
+
+
+
+
+
+
Apps
+
+ | Name | Repo | Branch | Container | Last Deploy | Actions |
+ {{rows}}
+
+
+
+
diff --git a/server/templates/styles.css b/server/templates/styles.css
new file mode 100644
index 0000000..86b628b
--- /dev/null
+++ b/server/templates/styles.css
@@ -0,0 +1,39 @@
+*{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}
diff --git a/server/templates/users.html b/server/templates/users.html
new file mode 100644
index 0000000..40f6174
--- /dev/null
+++ b/server/templates/users.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+