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
158 lines
6.2 KiB
HTML
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>
|
|
· 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>
|
|
|
|
{{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>
|