From c113b098e132596991aa2c0855bccb1421f70fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 13:03:10 +0000 Subject: [PATCH] refactor: extract HTML/CSS/JS from ui.rs into template files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all inline markup out of ui.rs into server/templates/: styles.css — shared stylesheet index.html — dashboard page app_detail.html — app detail page users.html — users admin page Templates are embedded at compile time via include_str! and rendered with simple str::replace("{{placeholder}}", value) calls. JS/CSS braces no longer need escaping, making the templates editable with normal syntax highlighting. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- server/src/routes/ui.rs | 630 +++---------------------------- server/templates/app_detail.html | 145 +++++++ server/templates/index.html | 114 ++++++ server/templates/styles.css | 39 ++ server/templates/users.html | 246 ++++++++++++ 5 files changed, 586 insertions(+), 588 deletions(-) create mode 100644 server/templates/app_detail.html create mode 100644 server/templates/index.html create mode 100644 server/templates/styles.css create mode 100644 server/templates/users.html 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

- - - {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, - ); + 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

- - - {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, - 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#" - -
-

Add user

-
- - - - -
- -
- -
-

Users

-

Loading…

-
- - "# - ); + 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

+ + + {{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
+
+ + 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

+ + + {{rows}} +
NameRepoBranchContainerLast DeployActions
+
+ + 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 @@ + + +
+

Add user

+
+ + + + +
+ +
+ +
+

Users

+

Loading…

+
+ +