d62cc08b2e
gates / gates (push) Successful in 6m44s
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once, stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it. - Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only: secrets shown/minted, deletes/resets, password, tokens. - R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test. - Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open. - 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS. - website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push. Not deployed — waits for the operator's yes. Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
392 lines
28 KiB
HTML
392 lines
28 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="/system" class="nav-link">System</a>
|
|
<a href="/offsite" class="nav-link">Offsite</a>
|
|
<a href="/configuration" class="nav-link active">Configuration</a>
|
|
</nav>
|
|
</header>
|
|
{{template "mail_hold_banner"}}
|
|
|
|
<h2 style="margin-bottom: 1rem;">Configuration</h2>
|
|
|
|
{{if mailHeld}}
|
|
<form method="POST" action="/configuration/mail-hold/release" class="mail-hold-release">
|
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
|
<button type="submit" class="btn">Release the mail hold</button>
|
|
<span class="text-muted">Removes the marker. The hub sends e-mail again from the next mail on; mail held until now is not re-sent.</span>
|
|
</form>
|
|
{{end}}
|
|
{{if eq .Flash "mail_hold_released"}}
|
|
<div class="flash flash-success">Mail hold released — the hub sends e-mail again. Mail held until now was not re-sent.</div>
|
|
{{end}}
|
|
{{if eq .Flash "mail_hold_release_failed"}}
|
|
<div class="flash flash-error">The mail hold could not be released — the marker is still in place. Check the hub log.</div>
|
|
{{end}}
|
|
{{if eq .Flash "mail_hold_not_held"}}
|
|
<div class="flash flash-success">The mail hold was not on — nothing to release.</div>
|
|
{{end}}
|
|
{{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 "floor_needs_min_agent"}}
|
|
<div class="flash flash-error">This floor is above the vouched golden — declare its MinAgent (from the release's CHANGELOG header). Nothing was saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "floor_min_agent_invalid"}}
|
|
<div class="flash flash-error">Invalid MinAgent — use X.Y.Z. Nothing was saved.</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"><strong>Refused: a checksum you typed is not valid.</strong> A sha256 is 64 characters, 0-9 and a-f. Manifest unchanged. Copy the checksum again and save.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_version_missing"}}
|
|
<div class="flash flash-error"><strong>Refused: the registry has no such version.</strong> Gitea answered "not found" for that version, so there is no checksum to record. Manifest unchanged. Check the version number, or publish that version first, then vouch it here again.</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<version>/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<version> <released-commit> && git push origin v<version></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 <version></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;">
|
|
<input type="text" name="min_agent" value="{{.FloorDeclaredMinAgent}}" placeholder="MinAgent from the release header" title="Required when the floor is above the vouched golden{{if .FloorGoldenVersion}} (v{{.FloorGoldenVersion}}){{end}} — read it from the release's CHANGELOG header (R-472)" style="padding: 0.3em 0.5em; width: 14em;">
|
|
<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>
|
|
|
|
<!-- Access tokens (v0.152.0, `05` §17): scoped, expiring stand-ins for the operator password, for scripts.
|
|
Managed only from a browser session — ShowTokens is false when a token reads this page. -->
|
|
{{if .ShowTokens}}
|
|
<section class="card" id="tokens">
|
|
<h3 style="margin-top: 0;">Access tokens</h3>
|
|
<p class="text-muted">
|
|
A token lets a script use the hub without your password. Tick what it may do; it can do nothing else.
|
|
<strong>Never by any token:</strong> showing a box's emergency root password or a household's passphrase,
|
|
deleting or resetting a customer or host, changing the password, creating or revoking tokens.
|
|
Write includes read. The hub keeps only a scrambled form of each token, and logs every use by name.
|
|
</p>
|
|
{{if eq .Flash "token_revoked"}}<div class="flash flash-success">Token revoked. It stops working now.</div>{{end}}
|
|
{{if eq .Flash "token_name_invalid"}}<div class="flash flash-error">The token needs a name (at most 64 characters). Nothing was created.</div>{{end}}
|
|
{{if eq .Flash "token_no_scope"}}<div class="flash flash-error">Tick at least one permission. Nothing was created.</div>{{end}}
|
|
{{if eq .Flash "token_scope_invalid"}}<div class="flash flash-error">Unknown permission. Nothing was created.</div>{{end}}
|
|
{{if eq .Flash "token_days_invalid"}}<div class="flash flash-error">Expiry must be 1 to 365 days. Nothing was created.</div>{{end}}
|
|
{{if eq .Flash "token_create_failed"}}<div class="flash flash-error">The token was not saved. Nothing was created.</div>{{end}}
|
|
{{if eq .Flash "token_revoke_failed"}}<div class="flash flash-error">The token was not revoked.</div>{{end}}
|
|
{{with .CreatedToken}}
|
|
<div class="flash flash-warn" id="token-created">
|
|
<strong>Token {{.Name}} created. Copy it now — it is shown only this once.</strong><br>
|
|
<code id="token-plain">{{.Plain}}</code>
|
|
<button type="button" class="btn btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('token-plain').textContent)">Copy</button>
|
|
</div>
|
|
{{end}}
|
|
{{if .Tokens}}
|
|
<table class="data-table">
|
|
<thead><tr><th>Name</th><th>Permissions</th><th>Expires</th><th>Last used</th><th>From</th><th>State</th><th></th></tr></thead>
|
|
<tbody>
|
|
{{range .Tokens}}
|
|
<tr>
|
|
<td>{{.Name}}</td>
|
|
<td><code>{{.Scopes}}</code></td>
|
|
<td>{{.Expires}}</td>
|
|
<td>{{.LastUsed}}</td>
|
|
<td>{{.LastUsedFrom}}</td>
|
|
<td>{{if .Active}}<span class="badge badge-ok">{{.State}}</span>{{else}}<span class="badge badge-neutral">{{.State}}</span>{{end}}</td>
|
|
<td>{{if .Active}}<form method="POST" action="/configuration/tokens/{{.ID}}/revoke">{{$.CSRFField}}<button class="btn btn-sm" type="submit" data-confirm="Revoke the token {{.Name}}? Scripts using it stop working at once.">Revoke</button></form>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}
|
|
<p class="text-muted">No tokens yet.</p>
|
|
{{end}}
|
|
<form method="POST" action="/configuration/tokens" id="token-create-form">
|
|
{{.CSRFField}}
|
|
<h4>New token</h4>
|
|
<label>Name <input type="text" name="name" maxlength="64" required placeholder="cc-releases"></label>
|
|
<table class="data-table">
|
|
<thead><tr><th>Group</th><th>Read</th><th>Write</th></tr></thead>
|
|
<tbody>
|
|
{{range .TokenGroups}}
|
|
<tr>
|
|
<td>{{.Label}}</td>
|
|
<td>{{if .Read}}<label><input type="checkbox" name="scope" value="{{.Key}}:read"> <span class="text-muted">{{.ReadHelp}}</span></label>{{end}}</td>
|
|
<td>{{if .Write}}<label><input type="checkbox" name="scope" value="{{.Key}}:write"> <span class="text-muted">{{.WriteHelp}}</span></label>{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
<label>Expires after
|
|
<select name="days">
|
|
{{range .TokenDays}}<option value="{{.}}"{{if eq . $.TokenDefaultDays}} selected{{end}}>{{.}} days</option>{{end}}
|
|
</select>
|
|
</label>
|
|
<button class="btn btn-sm" type="submit">Create token</button>
|
|
</form>
|
|
</section>
|
|
{{end}}
|
|
|
|
<!-- 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>
|