4a4233059d
The v0.68.0 row wrapped itself in a <div>, but the artifacts <form> IS the CSS grid (display:grid, no inner container). The stray </div> closed the surrounding card from inside the form and the new <div> was never closed — it swallowed the submit button and ran to </form>, so the row rendered outside the card and Save landed inline. Reported by the operator on first use. The field still submitted (it stayed inside the form), so this was layout damage rather than data loss, but the unbalanced markup put every section below it in the wrong container. Fixed as plain grid cells (grid-column: 2/4), no nested elements. There was no render assertion on this form at all, which is why a hand-edit broke it silently. The new test asserts the field is inside the form, the button has not escaped, the form contains ZERO divs, whole-page div balance holds, and the sections after it survive. Red-proofed against the broken shape.
278 lines
19 KiB
HTML
278 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Configuration — Felhom Hub</title>
|
|
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
|
|
</head>
|
|
<body>
|
|
{{template "icon_sprite"}}
|
|
<div class="container">
|
|
<header>
|
|
<h1>Felhom <span>Hub</span></h1>
|
|
<nav class="nav-links">
|
|
<a href="/" class="nav-link">Dashboard</a>
|
|
<a href="/configs" class="nav-link">Customers</a>
|
|
<a href="/apps" class="nav-link">Apps</a>
|
|
<a href="/hosts" class="nav-link">Hosts</a>
|
|
<a href="/offsite" class="nav-link">Offsite</a>
|
|
<a href="/configuration" class="nav-link active">Configuration</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<h2 style="margin-bottom: 1rem;">Configuration</h2>
|
|
|
|
{{if eq .Flash "assets_refreshed"}}
|
|
<div class="flash flash-success">Assets refreshed successfully from image seed.</div>
|
|
{{end}}
|
|
{{if eq .Flash "assets_error"}}
|
|
<div class="flash flash-error">Asset refresh failed. Check server logs for details.</div>
|
|
{{end}}
|
|
{{if eq .Flash "assets_not_configured"}}
|
|
<div class="flash flash-error">Asset manager is not configured.</div>
|
|
{{end}}
|
|
{{if eq .Flash "floor_set"}}
|
|
<div class="flash flash-success">Controller-version floor saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "floor_invalid"}}
|
|
<div class="flash flash-error">Invalid version — use X.Y.Z (or blank to clear).</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifacts_set"}}
|
|
<div class="flash flash-success">Artifact manifest saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_ver_invalid"}}
|
|
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_sha_invalid"}}
|
|
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_changed"}}
|
|
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_current_wrong"}}
|
|
<div class="flash flash-error">Current password is incorrect — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_too_short"}}
|
|
<div class="flash flash-error">New password is too short (minimum 8 characters) — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_too_long"}}
|
|
<div class="flash flash-error">New password is too long (maximum 72 characters) — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_mismatch"}}
|
|
<div class="flash flash-error">New password and confirmation don't match — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_unchanged"}}
|
|
<div class="flash flash-error">New password is the same as the current one — nothing changed.</div>
|
|
{{end}}
|
|
|
|
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
|
|
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
|
|
publish-train 0.81/0.113 incident). Saving acts IMMEDIATELY, so it is behind a
|
|
type-to-confirm dialog that states the live below-floor blast radius first. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Managed updates — global floor</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The minimum controller version every box auto-updates to (unless a per-customer override is set).
|
|
<strong>Saving takes effect immediately</strong> — boxes below the floor update on their next
|
|
report, no customer action. Blank = no global floor. This setting is independent of the Day-0
|
|
artifact manifest below.
|
|
</p>
|
|
<!-- Effective-floor-and-source line: makes the DB-override-vs-env-fallback precedence
|
|
permanently visible (the 9-minute-skew incident's root cause). -->
|
|
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
Effective floor:
|
|
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
|
|
{{if eq .FloorRes.Source "db"}}
|
|
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
|
|
{{else if eq .FloorRes.Source "env"}}
|
|
<span style="color: #cbd5e1;">— source: <strong>env fallback (DEFAULT_MIN_CONTROLLER_VERSION)</strong>; no DB override set</span>
|
|
{{else}}
|
|
<span class="text-muted">— no floor from either source</span>
|
|
{{end}}
|
|
</p>
|
|
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
{{.CSRFField}}
|
|
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor…</button>
|
|
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
|
|
</form>
|
|
<div id="global-floor-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #7c3f00; background: #241a0a; border-radius: 6px; max-width: 44em;">
|
|
<p id="global-floor-impact" style="margin: 0 0 0.5rem; font-size: 0.9em;">…</p>
|
|
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: #cbd5e1;">Type the version again to confirm (or <code>CLEAR</code> to remove the DB override):</p>
|
|
<input type="text" id="global-floor-confirm-input" placeholder="retype the version…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button class="btn btn-sm" type="button" onclick="submitGlobalFloor()">Confirm & apply</button>
|
|
<button class="btn btn-sm btn-ghost" type="button" onclick="document.getElementById('global-floor-confirm').style.display='none';">Cancel</button>
|
|
<p id="global-floor-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: #f87171;"></p>
|
|
</div>
|
|
<script>
|
|
function confirmGlobalFloor() {
|
|
var v = document.getElementById('global-floor-input').value.trim();
|
|
var box = document.getElementById('global-floor-confirm');
|
|
var impact = document.getElementById('global-floor-impact');
|
|
document.getElementById('global-floor-confirm-input').value = '';
|
|
document.getElementById('global-floor-confirm-err').textContent = '';
|
|
box.style.display = 'block';
|
|
if (v === '') {
|
|
impact.textContent = 'This will CLEAR the DB floor override (the box falls back to the env default). Type CLEAR to confirm.';
|
|
return;
|
|
}
|
|
impact.textContent = 'Checking blast radius…';
|
|
fetch('/configuration/global-floor/impact?v=' + encodeURIComponent(v))
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
if (!d.valid) { impact.textContent = 'Invalid version — use X.Y.Z.'; return; }
|
|
impact.textContent = 'Saving the minimum version v' + d.version +
|
|
' takes effect immediately — currently ' + d.below +
|
|
' box(es) are below this version and would update on their next report.';
|
|
})
|
|
.catch(function(){ impact.textContent = 'Could not compute the blast radius; proceed with caution.'; });
|
|
}
|
|
function submitGlobalFloor() {
|
|
var v = document.getElementById('global-floor-input').value.trim();
|
|
var typed = document.getElementById('global-floor-confirm-input').value.trim();
|
|
var err = document.getElementById('global-floor-confirm-err');
|
|
var expected = (v === '') ? 'CLEAR' : v;
|
|
if (typed !== expected) { err.textContent = 'Confirmation does not match (' + expected + ').'; return; }
|
|
document.getElementById('global-floor-form').submit();
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
|
|
checksum TRUST ROOT — the host-bootstrap script verifies Gitea-fetched artifacts against
|
|
these sha256s before installing them. Record the version + sha256 printed by
|
|
publish-agent.sh / build-golden.sh. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Day-0 artifacts — agent & golden</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
|
|
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
|
|
root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
|
|
copy). Choose <em>— none —</em> to clear an artifact.
|
|
</p>
|
|
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
|
|
{{.CSRFField}}
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
|
|
{{if .AgentChoices}}
|
|
<select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
|
|
<option value="" data-sha="">— none —</option>
|
|
{{range .AgentChoices}}
|
|
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{else}}
|
|
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
|
|
{{end}}
|
|
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
|
|
{{if .GoldenChoices}}
|
|
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
|
|
<option value="" data-sha="">— none —</option>
|
|
{{range .GoldenChoices}}
|
|
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{else}}
|
|
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
|
|
{{end}}
|
|
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
|
|
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
|
|
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">PBS wrapper</label>
|
|
<input type="text" name="wrapper_sha256" value="{{.Artifacts.WrapperSHA256}}" placeholder="64-hex sha256 (blank = not vouched)" style="grid-column: 2 / 4; padding: 0.3em 0.5em; font-family: monospace;">
|
|
<span></span>
|
|
<span style="grid-column: 2 / 4; font-size: 0.8em; color: #94a6bf;">sha256 of <code>configs/felhom-pbs-apply</code> (R-50b). Unlike the agent and golden this root-owned wrapper is installed from <code>raw/branch/main</code> — unversioned and unpinned. Recording it here does not fix the channel; it makes host drift <em>visible</em>: agents report the installed file's hash and a mismatch is surfaced on the host page.</span>
|
|
<span></span><span></span>
|
|
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
|
|
</form>
|
|
<script>
|
|
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
|
|
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
|
|
function syncArtifactSha(kind) {
|
|
var sel = document.getElementById(kind + '_version');
|
|
var sha = document.getElementById(kind + '_sha256');
|
|
if (!sel || !sha) return;
|
|
var opt = sel.options[sel.selectedIndex];
|
|
sha.value = (opt && opt.getAttribute('data-sha')) || '';
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- Operator login password (v0.54.0). Changing it here writes a hub_settings DB override that
|
|
WINS over the hub.yaml ConfigMap seed (auth.password_hash); the ConfigMap stays the
|
|
break-glass fallback (blank the DB row / edit the manifest to reset a lost password).
|
|
Requires the current password. Existing sessions are intentionally kept valid. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Login password</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The password for signing in to this hub UI. <strong>Changing it takes effect immediately</strong>
|
|
for the next sign-in — your current session stays logged in. Enter your current password to confirm.
|
|
If you ever lose it, the deployment ConfigMap (<code>auth.password_hash</code>) remains the reset path.
|
|
</p>
|
|
<form method="POST" action="/configuration/password" style="display: grid; grid-template-columns: auto 20em; gap: 0.5rem; align-items: center; max-width: 40em;"
|
|
onsubmit="return felhomCheckNewPw(this);">
|
|
{{.CSRFField}}
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Current password</label>
|
|
<input type="password" name="current_password" autocomplete="current-password" required style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">New password</label>
|
|
<input type="password" id="new_password" name="new_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Confirm new password</label>
|
|
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
|
<span></span>
|
|
<span>
|
|
<button class="btn btn-sm" type="submit">Change password</button>
|
|
<span id="pw-client-err" style="margin-left: 0.6em; font-size: 0.8em; color: #f87171;"></span>
|
|
</span>
|
|
</form>
|
|
<script>
|
|
// Client-side pre-check only (the server re-validates authoritatively): catch the
|
|
// mismatch before a round-trip so the operator sees it inline.
|
|
function felhomCheckNewPw(form) {
|
|
var a = form.new_password.value;
|
|
var b = form.confirm_password.value;
|
|
var err = document.getElementById('pw-client-err');
|
|
err.textContent = '';
|
|
if (a.length < 8) { err.textContent = 'New password must be at least 8 characters.'; return false; }
|
|
if (a !== b) { err.textContent = 'New password and confirmation do not match.'; return false; }
|
|
return true;
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- Assets section -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Assets</h3>
|
|
<p class="text-muted" style="margin-bottom: 1rem;">
|
|
App logos and screenshots served to controllers. Assets are seeded from the Docker image
|
|
and synced to controllers daily via the asset manifest API.
|
|
</p>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Files in manifest</span>
|
|
<span class="value">{{.AssetCount}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Manifest generated</span>
|
|
<span class="value" style="font-family: var(--font-mono); font-size: 0.85em;">{{if .AssetLastSync}}{{.AssetLastSync}}{{else}}—{{end}}</span>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="/configuration" style="margin-top: 1rem;">
|
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
|
<input type="hidden" name="action" value="refresh_assets">
|
|
<button type="submit" class="btn" onclick="this.disabled=true;this.textContent='Refreshing…';this.form.submit();">Refresh Assets from Image</button>
|
|
</form>
|
|
<p class="text-muted" style="margin-top: 0.75rem; font-size: 0.8rem;">
|
|
Re-reads the baked-in asset seed directory and updates changed files.
|
|
Controllers will pick up changes on their next daily sync or manual trigger.
|
|
</p>
|
|
</section>
|
|
|
|
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
|
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|