Hostityourself/server/templates/app_detail.html
Claude f4aa6972e1
feat: shared Postgres with per-app schemas
One Postgres 16 instance runs in the infra stack (docker-compose).
Each app can be given its own isolated schema with a dedicated,
scoped Postgres user via the new Database card on the app detail page.

What was added:

infra/
  docker-compose.yml  — postgres:16-alpine service + hiy-pg-data
                        volume; POSTGRES_URL injected into server
  .env.example        — POSTGRES_PASSWORD entry

server/
  Cargo.toml          — sqlx postgres feature
  src/db.rs           — databases table (SQLite) migration
  src/models.rs       — Database model
  src/main.rs         — PgPool (lazy) added to AppState;
                        /api/apps/:id/database routes registered
  src/routes/mod.rs   — databases module
  src/routes/databases.rs — GET / POST / DELETE handlers:
      provision  — creates schema + scoped PG user, sets search_path,
                   injects DATABASE_URL env var
      deprovision — DROP OWNED BY + DROP ROLE + DROP SCHEMA CASCADE,
                   removes SQLite record
  src/routes/ui.rs    — app_detail queries databases table, renders
                        db_card based on provisioning state
  templates/app_detail.html — {{db_card}} placeholder +
                              provisionDb / deprovisionDb JS

Apps connect via:
  postgres://hiy-<app>:<pw>@postgres:5432/hiy
search_path is set on the role so no URL option is needed.

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
2026-03-24 13:16:39 +00:00

158 lines
6.2 KiB
HTML

<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>
&nbsp;·&nbsp; branch <code>{{branch}}</code>
&nbsp;·&nbsp; port <code>{{port}}</code>
&nbsp;·&nbsp; <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>
{{db_card}}
<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 provisionDb() {
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'POST'});
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}
async function deprovisionDb() {
if (!confirm('Drop the database and all its data for ' + APP_ID + '?\nThis cannot be undone.')) return;
const r = await fetch('/api/apps/' + APP_ID + '/database', {method: 'DELETE'});
if (r.ok) window.location.reload();
else alert('Error: ' + await r.text());
}
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>