refactor: extract HTML/CSS/JS from ui.rs into template files
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
This commit is contained in:
parent
84b5b2d028
commit
c113b098e1
5 changed files with 586 additions and 588 deletions
|
|
@ -12,49 +12,14 @@ use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Shared styles ──────────────────────────────────────────────────────────────
|
// ── Templates (compiled into the binary) ──────────────────────────────────────
|
||||||
|
|
||||||
const CSS: &str = r#"
|
const CSS: &str = include_str!("../../templates/styles.css");
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
const INDEX_TMPL: &str = include_str!("../../templates/index.html");
|
||||||
body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:32px 24px;max-width:1100px;margin:0 auto}
|
const APP_DETAIL_TMPL: &str = include_str!("../../templates/app_detail.html");
|
||||||
h1{color:#a78bfa;font-size:1.6rem;margin-bottom:4px}
|
const USERS_TMPL: &str = include_str!("../../templates/users.html");
|
||||||
h2{color:#818cf8;font-size:1.1rem;margin-bottom:16px}
|
|
||||||
a{color:#818cf8;text-decoration:none}
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
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 {
|
fn badge(status: &str) -> String {
|
||||||
let cls = match status {
|
let cls = match status {
|
||||||
|
|
@ -66,19 +31,6 @@ fn badge(status: &str) -> String {
|
||||||
format!(r#"<span class="badge {cls}">{status}</span>"#)
|
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 {
|
fn container_badge(state: &str) -> String {
|
||||||
let cls = match state {
|
let cls = match state {
|
||||||
"running" => "badge-success",
|
"running" => "badge-success",
|
||||||
|
|
@ -89,6 +41,18 @@ fn container_badge(state: &str) -> String {
|
||||||
format!(r#"<span class="badge {cls}">{state}</span>"#)
|
format!(r#"<span class="badge {cls}">{state}</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>"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System stats ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
struct SysStats {
|
struct SysStats {
|
||||||
load_1m: f32,
|
load_1m: f32,
|
||||||
ram_used_mb: u64,
|
ram_used_mb: u64,
|
||||||
|
|
@ -165,7 +129,6 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
// Fetch system stats and container statuses concurrently.
|
|
||||||
let container_futs: Vec<_> = apps.iter().map(|a| {
|
let container_futs: Vec<_> = apps.iter().map(|a| {
|
||||||
let id = a.id.clone();
|
let id = a.id.clone();
|
||||||
async move { (id.clone(), get_container_status(&id).await) }
|
async move { (id.clone(), get_container_status(&id).await) }
|
||||||
|
|
@ -214,131 +177,16 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, StatusCode
|
||||||
|
|
||||||
let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 };
|
let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 };
|
||||||
|
|
||||||
let body = format!(
|
let body = INDEX_TMPL
|
||||||
r#"<nav><h1>☕ HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/admin/users" class="muted" style="font-size:0.82rem">users</a><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav>
|
.replace("{{n}}", &apps.len().to_string())
|
||||||
|
.replace("{{rows}}", &rows)
|
||||||
<div class="card" style="padding:16px 24px">
|
.replace("{{load}}", &format!("{:.2}", stats.load_1m))
|
||||||
<h2 style="margin-bottom:14px">System</h2>
|
.replace("{{ram_used}}", &stats.ram_used_mb.to_string())
|
||||||
<div class="grid3">
|
.replace("{{ram_total}}", &stats.ram_total_mb.to_string())
|
||||||
<div>
|
.replace("{{ram_pct}}", &ram_pct.to_string())
|
||||||
<label>CPU Load (1 min)</label>
|
.replace("{{disk_used}}", &format!("{:.1}", stats.disk_used_gb))
|
||||||
<div class="stat-big">{load:.2}</div>
|
.replace("{{disk_total}}", &format!("{:.1}", stats.disk_total_gb))
|
||||||
</div>
|
.replace("{{disk_pct}}", &stats.disk_pct.to_string());
|
||||||
<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 <span style="font-weight:normal;opacity:.6">(optional)</span></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.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) {{
|
|
||||||
// Immediately show building; the 5 s poller will advance to the final status.
|
|
||||||
const row = document.querySelector(`tr[data-id="${{id}}"]`);
|
|
||||||
if (row) {{
|
|
||||||
const db = row.querySelector('[data-deploy-badge]');
|
|
||||||
if (db) db.innerHTML = deployBadgeHtml('building');
|
|
||||||
}}
|
|
||||||
}} 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();
|
|
||||||
}}
|
|
||||||
async function stopApp(id) {{
|
|
||||||
if (!confirm('Stop ' + id + '?')) return;
|
|
||||||
const r = await fetch('/api/apps/' + id + '/stop', {{method: 'POST'}});
|
|
||||||
if (!r.ok) alert('Error stopping app');
|
|
||||||
}}
|
|
||||||
async function restartApp(id) {{
|
|
||||||
if (!confirm('Restart ' + id + '?')) return;
|
|
||||||
const r = await fetch('/api/apps/' + id + '/restart', {{method: 'POST'}});
|
|
||||||
if (!r.ok) alert('Error restarting app');
|
|
||||||
}}
|
|
||||||
|
|
||||||
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)))
|
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 host = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
||||||
|
|
||||||
let body = format!(
|
let body = APP_DETAIL_TMPL
|
||||||
r#"<nav>
|
.replace("{{name}}", &app.name)
|
||||||
<h1><a href="/" style="color:inherit">HIY</a> / {name}</h1>
|
.replace("{{repo}}", &app.repo_url)
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
.replace("{{branch}}", &app.branch)
|
||||||
{c_badge}
|
.replace("{{port}}", &app.port.to_string())
|
||||||
<button class="primary" onclick="deploy()">Deploy Now</button>
|
.replace("{{host}}", &host)
|
||||||
<button onclick="stopApp()">Stop</button>
|
.replace("{{app_id}}", &app.id)
|
||||||
<button onclick="restartApp()">Restart</button>
|
.replace("{{secret}}", &app.webhook_secret)
|
||||||
<a href="/admin/users" class="muted" style="font-size:0.82rem;padding:0 4px">users</a>
|
.replace("{{deploy_rows}}", &deploy_rows)
|
||||||
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
|
.replace("{{env_rows}}", &env_rows)
|
||||||
</div>
|
.replace("{{c_badge}}", &container_badge(&container_state));
|
||||||
</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}';
|
|
||||||
|
|
||||||
// Deploy logs are collapsed by default; only open when triggered.
|
|
||||||
|
|
||||||
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();
|
|
||||||
}}
|
|
||||||
async function stopApp() {{
|
|
||||||
if (!confirm('Stop ' + APP_ID + '?')) return;
|
|
||||||
const r = await fetch('/api/apps/' + APP_ID + '/stop', {{method:'POST'}});
|
|
||||||
if (!r.ok) alert('Error stopping app');
|
|
||||||
}}
|
|
||||||
async function restartApp() {{
|
|
||||||
if (!confirm('Restart ' + APP_ID + '?')) return;
|
|
||||||
const r = await fetch('/api/apps/' + APP_ID + '/restart', {{method:'POST'}});
|
|
||||||
if (!r.ok) alert('Error restarting app');
|
|
||||||
}}
|
|
||||||
</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,
|
|
||||||
c_badge = container_badge(&container_state),
|
|
||||||
);
|
|
||||||
|
|
||||||
Html(page(&app.name, &body)).into_response()
|
Html(page(&app.name, &body)).into_response()
|
||||||
}
|
}
|
||||||
|
|
@ -612,10 +314,9 @@ pub async fn status_json(
|
||||||
Ok(Json(serde_json::Value::Object(map)))
|
Ok(Json(serde_json::Value::Object(map)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users management page ────────────────────────────────────────────────────
|
// ── Users management page ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
// Fetch all apps for the grant dropdowns.
|
|
||||||
let apps: Vec<(String, String)> =
|
let apps: Vec<(String, String)> =
|
||||||
sqlx::query_as("SELECT id, name FROM apps ORDER BY name")
|
sqlx::query_as("SELECT id, name FROM apps ORDER BY name")
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
|
|
@ -628,254 +329,7 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
let body = format!(
|
let body = USERS_TMPL.replace("{{app_opts}}", &app_opts);
|
||||||
r#"<nav>
|
|
||||||
<h1><a href="/" style="color:inherit">HIY</a> / Users</h1>
|
|
||||||
<span style="display:flex;gap:16px;align-items:center">
|
|
||||||
<a href="/" class="muted" style="font-size:0.82rem">dashboard</a>
|
|
||||||
<a href="/logout" class="muted" style="font-size:0.82rem">logout</a>
|
|
||||||
</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 style="margin-bottom:16px">Add user</h2>
|
|
||||||
<div class="row" id="add-user-form">
|
|
||||||
<input type="text" id="new-username" placeholder="username" style="max-width:200px">
|
|
||||||
<input type="password" id="new-password" placeholder="password" style="max-width:200px">
|
|
||||||
<label style="display:flex;align-items:center;gap:6px;margin:0;color:#e2e8f0">
|
|
||||||
<input type="checkbox" id="new-is-admin"> Admin
|
|
||||||
</label>
|
|
||||||
<button class="primary" onclick="addUser()">Add user</button>
|
|
||||||
</div>
|
|
||||||
<p id="add-user-error" style="color:#f87171;margin-top:8px;display:none"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 style="margin-bottom:16px">Users</h2>
|
|
||||||
<div id="users-list"><p class="muted">Loading…</p></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const APP_OPTS = `{app_opts}`;
|
|
||||||
|
|
||||||
async function load() {{
|
|
||||||
const res = await fetch('/api/users');
|
|
||||||
const users = await res.json();
|
|
||||||
const el = document.getElementById('users-list');
|
|
||||||
if (!users.length) {{ el.innerHTML = '<p class="muted">No users yet.</p>'; return; }}
|
|
||||||
el.innerHTML = users.map(u => `
|
|
||||||
<div class="card" style="margin-bottom:12px;padding:16px">
|
|
||||||
<div class="row" style="justify-content:space-between;margin-bottom:10px">
|
|
||||||
<div>
|
|
||||||
<strong>${{u.username}}</strong>
|
|
||||||
${{u.is_admin ? '<span class="badge badge-success" style="margin-left:6px">admin</span>' : ''}}
|
|
||||||
<span class="muted" style="margin-left:10px;font-size:0.8rem">${{u.created_at.slice(0,10)}}</span>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
|
||||||
<button onclick="toggleAdmin('${{u.id}}','${{u.username}}',${{u.is_admin}})">
|
|
||||||
${{u.is_admin ? 'Remove admin' : 'Make admin'}}
|
|
||||||
</button>
|
|
||||||
<button onclick="changePassword('${{u.id}}','${{u.username}}')">Change password</button>
|
|
||||||
<button class="danger" onclick="deleteUser('${{u.id}}','${{u.username}}')">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="muted" style="font-size:0.78rem">App access:</span>
|
|
||||||
${{u.apps.length ? u.apps.map(a => `
|
|
||||||
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
|
||||||
${{a}}
|
|
||||||
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px" onclick="revokeApp('${{u.id}}','${{a}}')">✕</a>
|
|
||||||
</span>`).join('') : '<span class="muted" style="font-size:0.82rem"> none</span>'}}
|
|
||||||
${{APP_OPTS ? `
|
|
||||||
<span style="margin-left:8px">
|
|
||||||
<select id="grant-${{u.id}}" style="background:#0f172a;color:#e2e8f0;border:1px solid #334155;
|
|
||||||
padding:3px 8px;border-radius:6px;font-family:monospace;font-size:0.82rem">
|
|
||||||
<option value="">+ grant app…</option>
|
|
||||||
${{APP_OPTS}}
|
|
||||||
</select>
|
|
||||||
<button onclick="grantApp('${{u.id}}')" style="margin-left:4px">Grant</button>
|
|
||||||
</span>` : ''}}
|
|
||||||
</div>
|
|
||||||
<div id="ssh-keys-${{u.id}}" style="margin-top:12px">
|
|
||||||
<span class="muted" style="font-size:0.78rem">SSH keys:</span>
|
|
||||||
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top:8px;gap:6px">
|
|
||||||
<input type="text" id="key-label-${{u.id}}" placeholder="label (e.g. laptop)"
|
|
||||||
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
|
||||||
<input type="text" id="key-value-${{u.id}}" placeholder="ssh-ed25519 AAAA…"
|
|
||||||
style="flex:1;font-size:0.82rem;padding:4px 8px">
|
|
||||||
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
|
|
||||||
</div>
|
|
||||||
<div id="api-keys-${{u.id}}" style="margin-top:12px">
|
|
||||||
<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span>
|
|
||||||
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top:8px;gap:6px">
|
|
||||||
<input type="text" id="apikey-label-${{u.id}}" placeholder="label (e.g. laptop)"
|
|
||||||
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
|
||||||
<button onclick="generateApiKey('${{u.id}}')" style="font-size:0.82rem">Generate key</button>
|
|
||||||
</div>
|
|
||||||
<div id="apikey-reveal-${{u.id}}" style="display:none;margin-top:8px;padding:10px;
|
|
||||||
background:#0f172a;border:1px solid #334155;border-radius:6px">
|
|
||||||
<div style="font-size:0.78rem;color:#94a3b8;margin-bottom:4px">
|
|
||||||
Copy this key now — it will not be shown again.
|
|
||||||
</div>
|
|
||||||
<code id="apikey-value-${{u.id}}" style="font-size:0.82rem;color:#a3e635;word-break:break-all"></code>
|
|
||||||
<br><br>
|
|
||||||
<div style="font-size:0.78rem;color:#94a3b8">Use it to push:</div>
|
|
||||||
<code style="font-size:0.78rem;color:#e2e8f0">
|
|
||||||
git remote add hiy http://hiy:<span id="apikey-hint-${{u.id}}"></span>@<span class="domain-hint"></span>/git/YOUR_APP<br>
|
|
||||||
git push hiy main
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>`).join('');
|
|
||||||
document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host);
|
|
||||||
// Load SSH and API keys for each user asynchronously.
|
|
||||||
for (const u of users) {{ loadSshKeys(u.id); loadApiKeys(u.id); }}
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function addUser() {{
|
|
||||||
const username = document.getElementById('new-username').value.trim();
|
|
||||||
const password = document.getElementById('new-password').value;
|
|
||||||
const is_admin = document.getElementById('new-is-admin').checked;
|
|
||||||
const err = document.getElementById('add-user-error');
|
|
||||||
if (!username || !password) {{ err.textContent = 'Username and password required.'; err.style.display=''; return; }}
|
|
||||||
const res = await fetch('/api/users', {{
|
|
||||||
method:'POST', headers:{{'Content-Type':'application/json'}},
|
|
||||||
body: JSON.stringify({{username, password, is_admin}})
|
|
||||||
}});
|
|
||||||
if (res.ok) {{
|
|
||||||
document.getElementById('new-username').value='';
|
|
||||||
document.getElementById('new-password').value='';
|
|
||||||
document.getElementById('new-is-admin').checked=false;
|
|
||||||
err.style.display='none';
|
|
||||||
load();
|
|
||||||
}} else {{
|
|
||||||
const d = await res.json();
|
|
||||||
err.textContent = d.error || 'Error creating user.';
|
|
||||||
err.style.display = '';
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function deleteUser(id, name) {{
|
|
||||||
if (!confirm('Delete user ' + name + '?')) return;
|
|
||||||
await fetch('/api/users/' + id, {{method:'DELETE'}});
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function toggleAdmin(id, name, currentlyAdmin) {{
|
|
||||||
await fetch('/api/users/' + id, {{
|
|
||||||
method:'PUT', headers:{{'Content-Type':'application/json'}},
|
|
||||||
body: JSON.stringify({{is_admin: !currentlyAdmin}})
|
|
||||||
}});
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function changePassword(id, name) {{
|
|
||||||
const pw = prompt('New password for ' + name + ':');
|
|
||||||
if (!pw) return;
|
|
||||||
await fetch('/api/users/' + id, {{
|
|
||||||
method:'PUT', headers:{{'Content-Type':'application/json'}},
|
|
||||||
body: JSON.stringify({{password: pw}})
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function grantApp(userId) {{
|
|
||||||
const sel = document.getElementById('grant-' + userId);
|
|
||||||
const appId = sel.value;
|
|
||||||
if (!appId) return;
|
|
||||||
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'POST'}});
|
|
||||||
sel.value = '';
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function revokeApp(userId, appId) {{
|
|
||||||
await fetch('/api/users/' + userId + '/apps/' + appId, {{method:'DELETE'}});
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function loadApiKeys(userId) {{
|
|
||||||
const res = await fetch('/api/users/' + userId + '/api-keys');
|
|
||||||
const keys = await res.json();
|
|
||||||
const el = document.getElementById('api-keys-' + userId);
|
|
||||||
if (!el) return;
|
|
||||||
if (!keys.length) {{
|
|
||||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
|
||||||
}} else {{
|
|
||||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> ' +
|
|
||||||
keys.map(k => `
|
|
||||||
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
|
||||||
${{k.label}}
|
|
||||||
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
|
||||||
onclick="revokeApiKey('${{k.id}}','${{userId}}')">✕</a>
|
|
||||||
</span>`).join('');
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function generateApiKey(userId) {{
|
|
||||||
const label = document.getElementById('apikey-label-' + userId).value.trim();
|
|
||||||
if (!label) {{ alert('Enter a label first.'); return; }}
|
|
||||||
const res = await fetch('/api/users/' + userId + '/api-keys', {{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {{'Content-Type': 'application/json'}},
|
|
||||||
body: JSON.stringify({{label}})
|
|
||||||
}});
|
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('apikey-label-' + userId).value = '';
|
|
||||||
document.getElementById('apikey-value-' + userId).textContent = data.key;
|
|
||||||
document.getElementById('apikey-hint-' + userId).textContent = data.key;
|
|
||||||
document.getElementById('apikey-reveal-' + userId).style.display = '';
|
|
||||||
loadApiKeys(userId);
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function revokeApiKey(keyId, userId) {{
|
|
||||||
await fetch('/api/api-keys/' + keyId, {{method: 'DELETE'}});
|
|
||||||
document.getElementById('apikey-reveal-' + userId).style.display = 'none';
|
|
||||||
loadApiKeys(userId);
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function loadSshKeys(userId) {{
|
|
||||||
const res = await fetch('/api/users/' + userId + '/ssh-keys');
|
|
||||||
const keys = await res.json();
|
|
||||||
const el = document.getElementById('ssh-keys-' + userId);
|
|
||||||
if (!el) return;
|
|
||||||
if (!keys.length) {{
|
|
||||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
|
||||||
}} else {{
|
|
||||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> ' +
|
|
||||||
keys.map(k => `
|
|
||||||
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
|
||||||
${{k.label}}
|
|
||||||
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
|
||||||
onclick="removeSshKey('${{k.id}}','${{userId}}')">✕</a>
|
|
||||||
</span>`).join('');
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function addSshKey(userId) {{
|
|
||||||
const label = document.getElementById('key-label-' + userId).value.trim();
|
|
||||||
const publicKey = document.getElementById('key-value-' + userId).value.trim();
|
|
||||||
if (!label || !publicKey) return;
|
|
||||||
await fetch('/api/users/' + userId + '/ssh-keys', {{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {{'Content-Type': 'application/json'}},
|
|
||||||
body: JSON.stringify({{label, public_key: publicKey}})
|
|
||||||
}});
|
|
||||||
document.getElementById('key-label-' + userId).value = '';
|
|
||||||
document.getElementById('key-value-' + userId).value = '';
|
|
||||||
loadSshKeys(userId);
|
|
||||||
}}
|
|
||||||
|
|
||||||
async function removeSshKey(keyId, userId) {{
|
|
||||||
await fetch('/api/ssh-keys/' + keyId, {{method: 'DELETE'}});
|
|
||||||
loadSshKeys(userId);
|
|
||||||
}}
|
|
||||||
|
|
||||||
load();
|
|
||||||
</script>"#
|
|
||||||
);
|
|
||||||
|
|
||||||
Html(page("Users", &body))
|
Html(page("Users", &body))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
145
server/templates/app_detail.html
Normal file
145
server/templates/app_detail.html
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<nav>
|
||||||
|
<h1><a href="/" style="color:inherit">HIY</a> / {{name}}</h1>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
{{c_badge}}
|
||||||
|
<button class="primary" onclick="deploy()">Deploy Now</button>
|
||||||
|
<button onclick="stopApp()">Stop</button>
|
||||||
|
<button onclick="restartApp()">Restart</button>
|
||||||
|
<a href="/admin/users" class="muted" style="font-size:0.82rem;padding:0 4px">users</a>
|
||||||
|
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
|
||||||
|
</div>
|
||||||
|
</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}}';
|
||||||
|
|
||||||
|
// Deploy logs are collapsed by default; only open when triggered.
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
async function stopApp() {
|
||||||
|
if (!confirm('Stop ' + APP_ID + '?')) return;
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});
|
||||||
|
if (!r.ok) alert('Error stopping app');
|
||||||
|
}
|
||||||
|
async function restartApp() {
|
||||||
|
if (!confirm('Restart ' + APP_ID + '?')) return;
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID + '/restart', {method:'POST'});
|
||||||
|
if (!r.ok) alert('Error restarting app');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
114
server/templates/index.html
Normal file
114
server/templates/index.html
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<nav><h1>☕ HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{{n}} app(s)</span><a href="/admin/users" class="muted" style="font-size:0.82rem">users</a><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></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}}</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}} / {{disk_total}} 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 <span style="font-weight:normal;opacity:.6">(optional)</span></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.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) {
|
||||||
|
// Immediately show building; the 5 s poller will advance to the final status.
|
||||||
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
||||||
|
if (row) {
|
||||||
|
const db = row.querySelector('[data-deploy-badge]');
|
||||||
|
if (db) db.innerHTML = deployBadgeHtml('building');
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
async function stopApp(id) {
|
||||||
|
if (!confirm('Stop ' + id + '?')) return;
|
||||||
|
const r = await fetch('/api/apps/' + id + '/stop', {method: 'POST'});
|
||||||
|
if (!r.ok) alert('Error stopping app');
|
||||||
|
}
|
||||||
|
async function restartApp(id) {
|
||||||
|
if (!confirm('Restart ' + id + '?')) return;
|
||||||
|
const r = await fetch('/api/apps/' + id + '/restart', {method: 'POST'});
|
||||||
|
if (!r.ok) alert('Error restarting app');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
39
server/templates/styles.css
Normal file
39
server/templates/styles.css
Normal file
|
|
@ -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}
|
||||||
246
server/templates/users.html
Normal file
246
server/templates/users.html
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
<nav>
|
||||||
|
<h1><a href="/" style="color:inherit">HIY</a> / Users</h1>
|
||||||
|
<span style="display:flex;gap:16px;align-items:center">
|
||||||
|
<a href="/" class="muted" style="font-size:0.82rem">dashboard</a>
|
||||||
|
<a href="/logout" class="muted" style="font-size:0.82rem">logout</a>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px">Add user</h2>
|
||||||
|
<div class="row" id="add-user-form">
|
||||||
|
<input type="text" id="new-username" placeholder="username" style="max-width:200px">
|
||||||
|
<input type="password" id="new-password" placeholder="password" style="max-width:200px">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;margin:0;color:#e2e8f0">
|
||||||
|
<input type="checkbox" id="new-is-admin"> Admin
|
||||||
|
</label>
|
||||||
|
<button class="primary" onclick="addUser()">Add user</button>
|
||||||
|
</div>
|
||||||
|
<p id="add-user-error" style="color:#f87171;margin-top:8px;display:none"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px">Users</h2>
|
||||||
|
<div id="users-list"><p class="muted">Loading…</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const APP_OPTS = `{{app_opts}}`;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
const users = await res.json();
|
||||||
|
const el = document.getElementById('users-list');
|
||||||
|
if (!users.length) { el.innerHTML = '<p class="muted">No users yet.</p>'; return; }
|
||||||
|
el.innerHTML = users.map(u => `
|
||||||
|
<div class="card" style="margin-bottom:12px;padding:16px">
|
||||||
|
<div class="row" style="justify-content:space-between;margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
<strong>${u.username}</strong>
|
||||||
|
${u.is_admin ? '<span class="badge badge-success" style="margin-left:6px">admin</span>' : ''}
|
||||||
|
<span class="muted" style="margin-left:10px;font-size:0.8rem">${u.created_at.slice(0,10)}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<button onclick="toggleAdmin('${u.id}','${u.username}',${u.is_admin})">
|
||||||
|
${u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||||
|
</button>
|
||||||
|
<button onclick="changePassword('${u.id}','${u.username}')">Change password</button>
|
||||||
|
<button class="danger" onclick="deleteUser('${u.id}','${u.username}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="muted" style="font-size:0.78rem">App access:</span>
|
||||||
|
${u.apps.length ? u.apps.map(a => `
|
||||||
|
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||||
|
${a}
|
||||||
|
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px" onclick="revokeApp('${u.id}','${a}')">✕</a>
|
||||||
|
</span>`).join('') : '<span class="muted" style="font-size:0.82rem"> none</span>'}
|
||||||
|
${APP_OPTS ? `
|
||||||
|
<span style="margin-left:8px">
|
||||||
|
<select id="grant-${u.id}" style="background:#0f172a;color:#e2e8f0;border:1px solid #334155;
|
||||||
|
padding:3px 8px;border-radius:6px;font-family:monospace;font-size:0.82rem">
|
||||||
|
<option value="">+ grant app…</option>
|
||||||
|
${APP_OPTS}
|
||||||
|
</select>
|
||||||
|
<button onclick="grantApp('${u.id}')" style="margin-left:4px">Grant</button>
|
||||||
|
</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div id="ssh-keys-${u.id}" style="margin-top:12px">
|
||||||
|
<span class="muted" style="font-size:0.78rem">SSH keys:</span>
|
||||||
|
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px;gap:6px">
|
||||||
|
<input type="text" id="key-label-${u.id}" placeholder="label (e.g. laptop)"
|
||||||
|
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
||||||
|
<input type="text" id="key-value-${u.id}" placeholder="ssh-ed25519 AAAA…"
|
||||||
|
style="flex:1;font-size:0.82rem;padding:4px 8px">
|
||||||
|
<button onclick="addSshKey('${u.id}')" style="font-size:0.82rem">Add key</button>
|
||||||
|
</div>
|
||||||
|
<div id="api-keys-${u.id}" style="margin-top:12px">
|
||||||
|
<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span>
|
||||||
|
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px;gap:6px">
|
||||||
|
<input type="text" id="apikey-label-${u.id}" placeholder="label (e.g. laptop)"
|
||||||
|
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
||||||
|
<button onclick="generateApiKey('${u.id}')" style="font-size:0.82rem">Generate key</button>
|
||||||
|
</div>
|
||||||
|
<div id="apikey-reveal-${u.id}" style="display:none;margin-top:8px;padding:10px;
|
||||||
|
background:#0f172a;border:1px solid #334155;border-radius:6px">
|
||||||
|
<div style="font-size:0.78rem;color:#94a3b8;margin-bottom:4px">
|
||||||
|
Copy this key now — it will not be shown again.
|
||||||
|
</div>
|
||||||
|
<code id="apikey-value-${u.id}" style="font-size:0.82rem;color:#a3e635;word-break:break-all"></code>
|
||||||
|
<br><br>
|
||||||
|
<div style="font-size:0.78rem;color:#94a3b8">Use it to push:</div>
|
||||||
|
<code style="font-size:0.78rem;color:#e2e8f0">
|
||||||
|
git remote add hiy http://hiy:<span id="apikey-hint-${u.id}"></span>@<span class="domain-hint"></span>/git/YOUR_APP<br>
|
||||||
|
git push hiy main
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host);
|
||||||
|
// Load SSH and API keys for each user asynchronously.
|
||||||
|
for (const u of users) { loadSshKeys(u.id); loadApiKeys(u.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUser() {
|
||||||
|
const username = document.getElementById('new-username').value.trim();
|
||||||
|
const password = document.getElementById('new-password').value;
|
||||||
|
const is_admin = document.getElementById('new-is-admin').checked;
|
||||||
|
const err = document.getElementById('add-user-error');
|
||||||
|
if (!username || !password) { err.textContent = 'Username and password required.'; err.style.display=''; return; }
|
||||||
|
const res = await fetch('/api/users', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({username, password, is_admin})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('new-username').value='';
|
||||||
|
document.getElementById('new-password').value='';
|
||||||
|
document.getElementById('new-is-admin').checked=false;
|
||||||
|
err.style.display='none';
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
const d = await res.json();
|
||||||
|
err.textContent = d.error || 'Error creating user.';
|
||||||
|
err.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id, name) {
|
||||||
|
if (!confirm('Delete user ' + name + '?')) return;
|
||||||
|
await fetch('/api/users/' + id, {method:'DELETE'});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAdmin(id, name, currentlyAdmin) {
|
||||||
|
await fetch('/api/users/' + id, {
|
||||||
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({is_admin: !currentlyAdmin})
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(id, name) {
|
||||||
|
const pw = prompt('New password for ' + name + ':');
|
||||||
|
if (!pw) return;
|
||||||
|
await fetch('/api/users/' + id, {
|
||||||
|
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({password: pw})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantApp(userId) {
|
||||||
|
const sel = document.getElementById('grant-' + userId);
|
||||||
|
const appId = sel.value;
|
||||||
|
if (!appId) return;
|
||||||
|
await fetch('/api/users/' + userId + '/apps/' + appId, {method:'POST'});
|
||||||
|
sel.value = '';
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeApp(userId, appId) {
|
||||||
|
await fetch('/api/users/' + userId + '/apps/' + appId, {method:'DELETE'});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApiKeys(userId) {
|
||||||
|
const res = await fetch('/api/users/' + userId + '/api-keys');
|
||||||
|
const keys = await res.json();
|
||||||
|
const el = document.getElementById('api-keys-' + userId);
|
||||||
|
if (!el) return;
|
||||||
|
if (!keys.length) {
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> ' +
|
||||||
|
keys.map(k => `
|
||||||
|
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||||
|
${k.label}
|
||||||
|
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
||||||
|
onclick="revokeApiKey('${k.id}','${userId}')">✕</a>
|
||||||
|
</span>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateApiKey(userId) {
|
||||||
|
const label = document.getElementById('apikey-label-' + userId).value.trim();
|
||||||
|
if (!label) { alert('Enter a label first.'); return; }
|
||||||
|
const res = await fetch('/api/users/' + userId + '/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({label})
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('apikey-label-' + userId).value = '';
|
||||||
|
document.getElementById('apikey-value-' + userId).textContent = data.key;
|
||||||
|
document.getElementById('apikey-hint-' + userId).textContent = data.key;
|
||||||
|
document.getElementById('apikey-reveal-' + userId).style.display = '';
|
||||||
|
loadApiKeys(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeApiKey(keyId, userId) {
|
||||||
|
await fetch('/api/api-keys/' + keyId, {method: 'DELETE'});
|
||||||
|
document.getElementById('apikey-reveal-' + userId).style.display = 'none';
|
||||||
|
loadApiKeys(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSshKeys(userId) {
|
||||||
|
const res = await fetch('/api/users/' + userId + '/ssh-keys');
|
||||||
|
const keys = await res.json();
|
||||||
|
const el = document.getElementById('ssh-keys-' + userId);
|
||||||
|
if (!el) return;
|
||||||
|
if (!keys.length) {
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> ' +
|
||||||
|
keys.map(k => `
|
||||||
|
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||||
|
${k.label}
|
||||||
|
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
||||||
|
onclick="removeSshKey('${k.id}','${userId}')">✕</a>
|
||||||
|
</span>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSshKey(userId) {
|
||||||
|
const label = document.getElementById('key-label-' + userId).value.trim();
|
||||||
|
const publicKey = document.getElementById('key-value-' + userId).value.trim();
|
||||||
|
if (!label || !publicKey) return;
|
||||||
|
await fetch('/api/users/' + userId + '/ssh-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({label, public_key: publicKey})
|
||||||
|
});
|
||||||
|
document.getElementById('key-label-' + userId).value = '';
|
||||||
|
document.getElementById('key-value-' + userId).value = '';
|
||||||
|
loadSshKeys(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSshKey(keyId, userId) {
|
||||||
|
await fetch('/api/ssh-keys/' + keyId, {method: 'DELETE'});
|
||||||
|
loadSshKeys(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
Loading…
Add table
Reference in a new issue