Hostityourself/server/templates/app_detail.html
Claude eb9a500987
feat: per-app public/private visibility toggle
Apps default to private (require login). Marking an app public bypasses
the forward_auth check so anyone can access it without logging in.

Changes:
- db.rs: is_public INTEGER NOT NULL DEFAULT 0 column (idempotent)
- models.rs: is_public: i64 on App; is_public: Option<bool> on UpdateApp
- Cargo.toml: add reqwest for Caddy admin API calls from Rust
- routes/apps.rs: PATCH is_public → save flag + immediately push updated
  Caddy route (no redeploy needed); caddy_route() builds correct JSON for
  both public (plain reverse_proxy) and private (forward_auth) cases
- builder.rs: pass IS_PUBLIC env var to build.sh
- build.sh: use IS_PUBLIC to select route type on deploy
- ui.rs + app_detail.html: private/public badge + toggle button in subtitle

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
2026-03-26 08:55:58 +00:00

211 lines
8.4 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>
&nbsp;·&nbsp; {{visibility_badge}}
<button style="font-size:0.78rem;padding:2px 10px;margin-left:4px" onclick="toggleVisibility()">{{visibility_toggle_label}}</button>
</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>Git Authentication</h2>
<p class="muted" style="margin-bottom:12px;font-size:0.9rem">
Required for private repos. Store a Personal Access Token (GitHub: <em>repo</em> scope,
GitLab: <em>read_repository</em>) so deploys can clone without interactive prompts.
Only HTTPS repo URLs are supported; SSH URLs use the server's own key pair.
</p>
<p style="margin-bottom:12px">{{git_token_status}}</p>
<div class="row">
<div style="flex:1">
<label>Personal Access Token</label>
<input id="git-token-input" type="password" placeholder="ghp_…">
</div>
<div style="align-self:flex-end;display:flex;gap:8px">
<button class="primary" onclick="saveGitToken()">Save</button>
{{git_token_clear_btn}}
</div>
</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 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());
}
const IS_PUBLIC = {{is_public_js}};
async function toggleVisibility() {
const r = await fetch('/api/apps/' + APP_ID, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_public: !IS_PUBLIC}),
});
if (r.ok) window.location.reload();
else alert('Error updating visibility: ' + await r.text());
}
async function saveGitToken() {
const tok = document.getElementById('git-token-input').value;
if (!tok) { alert('Enter a token first'); return; }
const r = await fetch('/api/apps/' + APP_ID, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({git_token: tok}),
});
if (r.ok) window.location.reload();
else alert('Error saving token: ' + await r.text());
}
async function clearGitToken() {
if (!confirm('Remove the stored git token for ' + APP_ID + '?')) return;
const r = await fetch('/api/apps/' + APP_ID, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({git_token: ''}),
});
if (r.ok) window.location.reload();
else alert('Error clearing token: ' + 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>