<!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 "golden_behind_fleet"}}
        <div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</div>
        {{end}}
        {{if eq .Flash "artifact_tag_missing"}}
        <div class="flash flash-error"><strong>Refused: that version has no usable git tag.</strong> The installer fetches an agent's config files from <code>raw/tag/v&lt;version&gt;/configs/</code>, so a version published without its tag makes <em>every</em> fresh install and reinstall fail at step 5 of 8 — as root, on a virgin machine. That is exactly what happened on 2026-08-09. Manifest unchanged. Fix it by pushing the tag for that release: <code>git tag -a v&lt;version&gt; &lt;released-commit&gt; &amp;&amp; git push origin v&lt;version&gt;</code>, then vouch it here again.</div>
        {{end}}
        {{if eq .Flash "artifact_pkg_missing"}}
        <div class="flash flash-error"><strong>Refused: that version's artifact is not downloadable.</strong> The version is tagged but its package is not in the registry, so a box would 404 fetching the binary itself. Manifest unchanged. Publish it — <code>bash scripts/release-agent.sh &lt;version&gt;</code> for the agent, or re-bake and publish the golden — then vouch it here again.</div>
        {{end}}
        {{if eq .Flash "artifact_unverifiable"}}
        <div class="flash flash-error"><strong>Refused: could not verify — this does not mean anything is missing.</strong> The registry did not answer, so the hub cannot tell whether that version is installable. It refuses rather than saving with a warning, because a warning beside a success reads as a success. Manifest unchanged. Check that Gitea is up, then try again. There is deliberately no override: the registry is on your own server, so if it is unreachable the vouch can wait.</div>
        {{end}}
        {{if eq .Flash "artifact_sha_missing"}}
        <div class="flash flash-error"><strong>Refused: no checksum for a chosen version.</strong> The hub records the sha256 it reads from Gitea, and that lookup came back empty — so saving would vouch bytes nobody verified. 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 &amp; 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 &amp; 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>
