17cc67f7cd
F4: ReissueCredentials — explicit operator recovery for consumed-password dead-ends; resets the labelled resource's password (exactly-1 guard, red-proofed), stores a fresh one-time secret, bumps ConfigVersion. New hetznerapi.ResetBoxPassword for the dedicated path. F2: host-key scan retry-with-backoff (~60s ladder, red-proofed) — first save survives fresh-subaccount DNS lag. F5: config form disables submits + shows an in-flight notice (the re-click bait that caused live F1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
962 lines
48 KiB
HTML
962 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub</title>
|
|
<link rel="stylesheet" href="/style.css">
|
|
<meta name="csrf-token" content="{{.CSRFToken}}">
|
|
<script>function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}</script>
|
|
</head>
|
|
<body>
|
|
{{template "icon_sprite"}}
|
|
<div class="container">
|
|
<header>
|
|
<nav class="nav-links" style="margin-bottom: 0.5rem;">
|
|
<a href="/" class="nav-link">Dashboard</a>
|
|
<a href="/configs" class="nav-link active">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">Configuration</a>
|
|
</nav>
|
|
<a href="/configs" class="back-link">← All Customers</a>
|
|
<h1>
|
|
<span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span>
|
|
{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}
|
|
</h1>
|
|
{{if .HasReports}}
|
|
<p class="subtitle">
|
|
Last report: {{timeAgo .Customer.ReceivedAt}} · Controller {{.Customer.ControllerVersion}}
|
|
<label class="auto-refresh-toggle" title="Auto-refresh every 60s">
|
|
<input type="checkbox" id="autoRefreshToggle">
|
|
<span class="toggle-slider"></span>
|
|
<span class="toggle-label">Auto-refresh</span>
|
|
</label>
|
|
</p>
|
|
{{else}}
|
|
<p class="subtitle">No reports received yet</p>
|
|
{{end}}
|
|
</header>
|
|
|
|
{{if .Flash}}
|
|
<div class="flash flash-success">
|
|
{{if eq .Flash "created"}}Configuration created successfully.
|
|
{{else if eq .Flash "updated"}}Configuration updated.
|
|
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
|
|
{{else if eq .Flash "offsite_reissued"}}Offsite credentials re-issued — a fresh one-time password is staged; the controller picks it up on its next config refresh.
|
|
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
|
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .IsBlocked}}
|
|
<div class="flash flash-blocked">
|
|
This customer is blocked — reports are accepted but not shown on the Dashboard.
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Customer Info -->
|
|
<section class="card">
|
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
|
<h2>Customer Info</h2>
|
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
{{if .HasConfig}}
|
|
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
|
|
{{if .IsBlocked}}
|
|
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-sm">Unblock</button>
|
|
</form>
|
|
{{else}}
|
|
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
|
|
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm">Block</button>
|
|
</form>
|
|
{{end}}
|
|
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
|
|
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
|
</form>
|
|
{{else}}
|
|
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-sm">Create Config</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Customer ID</span>
|
|
<span class="value"><code>{{.CustomerID}}</code></span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Name</span>
|
|
<span class="value">{{if .CustomerName}}{{.CustomerName}}{{else}}—{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Domain</span>
|
|
<span class="value">{{if .Domain}}{{.Domain}}{{else}}—{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Email</span>
|
|
<span class="value">{{if .Email}}{{.Email}}{{else}}—{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Config</span>
|
|
<span class="value">
|
|
{{if .HasConfig}}
|
|
<span class="config-badge config-badge-managed">MANAGED</span>
|
|
{{else}}
|
|
<span class="config-badge config-badge-manual">MANUAL</span>
|
|
{{end}}
|
|
{{if .IsBlocked}}<span class="config-badge config-badge-blocked">BLOCKED</span>{{end}}
|
|
</span>
|
|
</div>
|
|
{{if .HasConfig}}
|
|
<div class="info-item">
|
|
<span class="label">Config Created</span>
|
|
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</section>
|
|
|
|
{{if .HasReports}}
|
|
<!-- System Info -->
|
|
<section class="card">
|
|
<h2>System</h2>
|
|
<div class="info-grid">
|
|
{{with .Report.system}}
|
|
<div class="info-item">
|
|
<span class="label">Hostname</span>
|
|
<span class="value">{{index . "hostname"}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">OS</span>
|
|
<span class="value">{{index . "os"}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Kernel</span>
|
|
<span class="value">{{index . "kernel"}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">CPU</span>
|
|
<span class="value">{{index . "cpu_model"}} ({{index . "cpu_cores"}} cores)</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
<div class="metrics-grid">
|
|
<div class="metric">
|
|
<span class="metric-label">CPU</span>
|
|
<span class="metric-value">{{formatFloat .Customer.CPUPercent}}%</span>
|
|
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.CPUPercent}}%"></div></div>
|
|
</div>
|
|
<div class="metric">
|
|
<span class="metric-label">Memory</span>
|
|
<span class="metric-value">{{formatFloat .Customer.MemoryPercent}}%</span>
|
|
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.MemoryPercent}}%"></div></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Storage -->
|
|
<section class="card">
|
|
<h2>Storage</h2>
|
|
{{with .Report.storage}}
|
|
<div class="metrics-grid">
|
|
{{range .}}
|
|
<div class="metric">
|
|
<span class="metric-label">{{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}}</span>
|
|
<span class="metric-value">{{printf "%.0f" (index . "percent")}}%</span>
|
|
<div class="bar"><div class="bar-fill" style="width: {{printf "%.0f" (index . "percent")}}%"></div></div>
|
|
<span class="metric-detail">{{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- Containers -->
|
|
<section class="card">
|
|
<h2>Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})</h2>
|
|
{{with .Report.containers}}
|
|
{{$list := index . "list"}}
|
|
{{if $list}}
|
|
<table class="container-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>State</th>
|
|
<th>CPU</th>
|
|
<th>Memory</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range $list}}
|
|
<tr>
|
|
<td>{{index . "name"}}</td>
|
|
<td><span class="container-state container-state-{{index . "state"}}">{{index . "state"}}</span></td>
|
|
<td>{{printf "%.1f" (index . "cpu_percent")}}%</td>
|
|
<td>{{printf "%.0f" (index . "memory_mb")}} MB</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- Backup -->
|
|
<section class="card">
|
|
<h2>Backup</h2>
|
|
{{with .Report.backup}}
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Enabled</span>
|
|
<span class="value">{{if index . "enabled"}}Yes{{else}}No{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Snapshots</span>
|
|
<span class="value">{{index . "snapshot_count"}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Repo Size</span>
|
|
<span class="value">{{index . "repo_size_mb"}} MB</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Integrity</span>
|
|
<span class="value">{{if index . "integrity_ok"}}OK{{else}}Unknown{{end}}</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- Geo-restriction -->
|
|
{{if .HasReports}}
|
|
{{with .Report.geo_restriction}}
|
|
<section class="card">
|
|
<h2>Geo-korlátozás</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Állapot</span>
|
|
<span class="value">
|
|
{{if index . "enabled"}}
|
|
<span class="severity-badge severity-critical">Aktív</span>
|
|
{{else}}
|
|
<span class="severity-badge severity-ok">Inaktív</span>
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
{{if index . "enabled"}}
|
|
<div class="info-item">
|
|
<span class="label">Engedélyezett országok</span>
|
|
<span class="value">
|
|
{{$countries := index . "allowed_countries"}}
|
|
{{if $countries}}
|
|
{{range $i, $c := $countries}}{{if $i}}, {{end}}{{$c}}{{end}}
|
|
{{else}}
|
|
—
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
{{end}}
|
|
{{if index . "last_sync"}}
|
|
<div class="info-item">
|
|
<span class="label">Utolsó szinkron</span>
|
|
<span class="value">{{index . "last_sync"}}</span>
|
|
</div>
|
|
{{end}}
|
|
{{if index . "last_sync_error"}}
|
|
<div class="info-item">
|
|
<span class="label">Szinkron hiba</span>
|
|
<span class="value" style="color: var(--crit)">{{index . "last_sync_error"}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{$overrides := index . "app_overrides"}}
|
|
{{if $overrides}}
|
|
<h3 style="margin-top: 1rem; font-size: 0.95rem;">Alkalmazás felülírások</h3>
|
|
<table class="data-table" style="margin-top: 0.5rem;">
|
|
<thead><tr><th>Alkalmazás</th><th>Engedélyezett országok</th></tr></thead>
|
|
<tbody>
|
|
{{range $app, $override := $overrides}}
|
|
<tr>
|
|
<td>{{$app}}</td>
|
|
<td>
|
|
{{$ac := index $override "allowed_countries"}}
|
|
{{if $ac}}{{range $i, $c := $ac}}{{if $i}}, {{end}}{{$c}}{{end}}{{else}}—{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
{{if index . "enabled"}}
|
|
<div style="margin-top: 1rem;">
|
|
<button class="btn btn-danger" id="btn-geo-disable" onclick="disableGeo('{{$.Customer.CustomerID}}')">Összes geo-korlátozás eltávolítása</button>
|
|
<span id="geo-msg" style="display:none; margin-left: 0.75rem;"></span>
|
|
</div>
|
|
{{end}}
|
|
</section>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
|
|
<!-- Health -->
|
|
<section class="card">
|
|
<h2>Health</h2>
|
|
{{if eq .OverallStatus "disabled"}}
|
|
<p class="health-status health-status-disabled">Reporting has been disabled on this node</p>
|
|
<p class="hint">Enable it in the controller's <code>controller.yaml</code>: <code>hub.enabled: true</code></p>
|
|
{{else if eq .OverallStatus "blocked"}}
|
|
<p class="health-status health-status-disabled">Customer is blocked</p>
|
|
{{else}}
|
|
{{with .Report.health}}
|
|
<p class="health-status health-status-{{index . "status"}}">
|
|
Status: {{index . "status"}}
|
|
</p>
|
|
{{$issues := index . "issues"}}
|
|
{{if $issues}}
|
|
<h3>Issues</h3>
|
|
<ul class="issue-list">
|
|
{{range $issues}}
|
|
<li class="issue">{{.}}</li>
|
|
{{end}}
|
|
</ul>
|
|
{{end}}
|
|
{{$warnings := index . "warnings"}}
|
|
{{if $warnings}}
|
|
<h3>Warnings</h3>
|
|
<ul class="warning-list">
|
|
{{range $warnings}}
|
|
<li class="warning">{{.}}</li>
|
|
{{end}}
|
|
</ul>
|
|
{{end}}
|
|
{{end}}
|
|
{{end}}
|
|
</section>
|
|
|
|
{{else}}
|
|
<!-- No reports yet -->
|
|
{{if .HasConfig}}
|
|
<section class="card">
|
|
<h2>Waiting for First Report</h2>
|
|
<p class="text-muted">This customer has been configured but no controller report has been received yet.</p>
|
|
<p class="text-muted" style="margin-top: 0.5rem;">Use one of the setup commands below to deploy the controller on the customer node.</p>
|
|
</section>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<!-- Config Management -->
|
|
{{if .HasConfig}}
|
|
<section class="card">
|
|
<h2>Credentials</h2>
|
|
<div class="credential-row">
|
|
<div>
|
|
<span class="label">Retrieval Password</span>
|
|
<div class="credential-box">
|
|
<code id="retrieval-pw" data-secret="{{.Config.RetrievalPassword}}">••••••••••••••••</code>
|
|
<button type="button" class="copy-btn" id="reveal-retrieval-pw" onclick="toggleSecret('retrieval-pw')" title="Reveal / hide">Reveal</button>
|
|
<button type="button" class="copy-btn" onclick="copySecret('retrieval-pw')" title="Copy">⎘</button>
|
|
</div>
|
|
<span class="form-hint">The per-customer secret that fetches the whole config — masked by default; never place it on a command line (the installer reads it at a no-echo prompt).</span>
|
|
</div>
|
|
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
|
|
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
|
|
</form>
|
|
</div>
|
|
<div class="credential-row" style="margin-top: 1rem;">
|
|
<div>
|
|
<span class="label">API Key</span>
|
|
<div class="credential-box">
|
|
<code id="api-key">{{.Config.APIKey}}</code>
|
|
<button type="button" class="copy-btn" onclick="copyText('api-key')" title="Copy">⎘</button>
|
|
</div>
|
|
</div>
|
|
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Setup Command</h2>
|
|
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
|
Day-0 host bootstrap for host-install <strong>{{.ScriptVersion}}</strong>. Run on a
|
|
freshly-PVE-installed Proxmox <strong>host</strong> as root (create the customer in the
|
|
hub first). It enrolls the host, installs + verifies the agent, and provisions the guest;
|
|
the in-guest controller then pulls its own <code>controller.yaml</code>. The retrieval
|
|
passphrase is entered at the no-echo prompt — never on the command line.
|
|
</p>
|
|
|
|
<!-- Generator controls (client-side only; nothing is submitted) -->
|
|
<div class="gen-controls" id="gen-controls" data-customer-id="{{.CustomerID}}">
|
|
<div class="form-group">
|
|
<label>Mode <span style="color:var(--red,#E5534B)">*</span></label>
|
|
<div class="gen-radios">
|
|
<label class="gen-radio"><input type="radio" name="gen-mode" value="appliance" onchange="genUpdate()"> appliance <span class="form-hint">(Felhom-owned box)</span></label>
|
|
<label class="gen-radio"><input type="radio" name="gen-mode" value="byo" onchange="genUpdate()"> byo <span class="form-hint">(a host you do not own)</span></label>
|
|
</div>
|
|
</div>
|
|
<div class="form-grid" style="margin-top:0.75rem;">
|
|
<div class="form-group" id="gen-cores-grp">
|
|
<label>Cores <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
|
|
<input type="number" id="gen-cores" min="1" placeholder="e.g. 12" oninput="genUpdate()">
|
|
<span class="form-hint">byo: required — a conservative slice of the host</span>
|
|
</div>
|
|
<div class="form-group" id="gen-memory-grp">
|
|
<label>Memory (MiB) <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
|
|
<input type="number" id="gen-memory" min="256" placeholder="e.g. 32768 (= 32 GB)" oninput="genUpdate()">
|
|
<span class="form-hint">byo: required — MiB (32 GB → 32768)</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>VMID <span class="form-hint">(optional)</span></label>
|
|
<input type="number" id="gen-vmid" min="100" placeholder="e.g. 9201" oninput="genUpdate()">
|
|
<span class="form-hint">pick from <code>pct list</code>+<code>qm list</code>; blank = default/auto</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Node <span class="form-hint">(optional)</span></label>
|
|
<input type="text" id="gen-node" placeholder="e.g. pve1" oninput="genUpdate()">
|
|
<span class="form-hint">required only on a multi-node cluster</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>ACL storages <span class="form-hint">(optional)</span></label>
|
|
<input type="text" id="gen-acl" placeholder='e.g. local local-lvm' oninput="genUpdate()">
|
|
<span class="form-hint">grant the token write access on exactly these storages</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Operator pubkey file <span class="form-hint">(optional)</span></label>
|
|
<input type="text" id="gen-pubkey" placeholder="/path/to/operator-keys" oninput="genUpdate()">
|
|
<span class="form-hint">arm self-update from day-0</span>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Preserve state from <span class="form-hint">(optional)</span></label>
|
|
<input type="text" id="gen-preserve" placeholder="/var/lib/felhom-agent.old" oninput="genUpdate()">
|
|
<span class="form-hint">reinstall keeping the leaf pin stable</span>
|
|
</div>
|
|
</div>
|
|
<div class="gen-checks">
|
|
<label class="gen-check"><input type="checkbox" id="gen-dry" onchange="genUpdate()"> <code>--dry-run</code> <span class="form-hint">print, don't execute</span></label>
|
|
<label class="gen-check"><input type="checkbox" id="gen-preflight" onchange="genUpdate()"> <code>--preflight-only</code> <span class="form-hint">checks + verdict, no install</span></label>
|
|
<label class="gen-check"><input type="checkbox" id="gen-skip" onchange="genUpdate()"> <code>--skip-provision</code> <span class="form-hint">agent only, no guest</span></label>
|
|
<label class="gen-check"><input type="checkbox" id="gen-leaf" onchange="genUpdate()"> <code>--allow-new-leaf</code> <span class="form-hint" id="gen-leaf-warn"></span></label>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="gen-msg" id="gen-msg" style="display:none;"></p>
|
|
|
|
<h3 style="margin-top:1rem;">Option 1: Online install (recommended)</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">
|
|
Download-then-run (not <code>curl | sudo bash</code>) so you can inspect the script first —
|
|
the right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
|
|
</p>
|
|
<div class="credential-box">
|
|
<code id="cmd-online">curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \
|
|
&& sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo></code>
|
|
<button type="button" class="copy-btn" onclick="copyText('cmd-online')" title="Copy">⎘</button>
|
|
</div>
|
|
|
|
<h3 style="margin-top: 1rem;">Option 2: Local install (script already on host)</h3>
|
|
<div class="credential-box">
|
|
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo></code>
|
|
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
|
</div>
|
|
|
|
<h3 style="margin-top: 1rem;">Option 3: Manual config fetch (debug only)</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning. Replace the placeholder with the Retrieval Password above (Reveal to see it) — it is intentionally NOT baked into this command.</p>
|
|
<div class="credential-box">
|
|
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: <YOUR-RETRIEVAL-PASSWORD>" -o controller.yaml</code>
|
|
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>YAML Preview</h2>
|
|
<div id="yaml-preview" class="yaml-preview">
|
|
<p class="text-muted">Loading preview...</p>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
|
|
{{if .HasReports}}
|
|
<!-- Controller Update -->
|
|
<section class="card">
|
|
<h2>Controller Update</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Controller version</span>
|
|
<span class="value">{{.Customer.ControllerVersion}}</span>
|
|
</div>
|
|
{{if .LatestVersion}}
|
|
<div class="info-item">
|
|
<span class="label">Registry latest</span>
|
|
<span class="value">
|
|
v{{.LatestVersion}}
|
|
{{if .UpdateAvailable}}
|
|
<span style="color: var(--blue-bright); margin-left: 0.3em;">● update available</span>
|
|
{{else}}
|
|
<span style="color: var(--text-2); margin-left: 0.3em;">— up to date</span>
|
|
{{end}}
|
|
</span>
|
|
</div>
|
|
{{end}}
|
|
{{if .ControllerURL}}
|
|
<div class="info-item">
|
|
<span class="label">Controller URL</span>
|
|
<span class="value"><a href="{{.ControllerURL}}" target="_blank" style="color: var(--blue-bright);">{{.ControllerURL}}</a></span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
<!-- Phase 2 managed-update floor (operator-enforced minimum) -->
|
|
<div class="info-grid" style="margin-top: 0.75rem; border-top: 1px solid var(--line); padding-top: 0.75rem;">
|
|
<div class="info-item">
|
|
<span class="label">Effective floor (min. version)</span>
|
|
<span class="value">
|
|
{{if .EffectiveFloor}}v{{.EffectiveFloor}}
|
|
{{if .BelowFloor}}<span style="color: #f59e0b; margin-left: 0.3em;">● below floor — will auto-update</span>
|
|
{{else}}<span style="color: var(--text-2); margin-left: 0.3em;">— at/above floor</span>{{end}}
|
|
{{else}}<span style="color: var(--text-2);">none (Phase 2 inert)</span>{{end}}
|
|
</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Global floor</span>
|
|
<span class="value">{{if .GlobalFloor}}v{{.GlobalFloor}}{{else}}<span style="color: var(--text-2);">unset</span>{{end}}</span>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="/customers/{{.CustomerID}}/floor" style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
{{.CSRFField}}
|
|
<label style="font-size: 0.85em; color: #cbd5e1;">Per-customer override</label>
|
|
<input type="text" name="min_controller_version" value="{{.FloorOverride}}" placeholder="e.g. 0.87.0 (blank = use global)" style="padding: 0.3em 0.5em; font-size: 0.85em; width: 14em;">
|
|
<button class="btn btn-outline btn-sm" type="submit">Save floor</button>
|
|
<span style="font-size: 0.8em; color: var(--text-2);">Boxes below the effective floor auto-update on their next report. Blank clears the override.</span>
|
|
</form>
|
|
<p class="text-muted" style="margin-top: 0.75em; font-size: 0.8em;">
|
|
Controller updates are agent-driven (the version floor above) and config is delivered by the
|
|
box pulling it on a config change — the hub never connects into the box. Edit the config via
|
|
the <strong>Edit</strong> button (top of page); the controller re-pulls and restarts on its
|
|
next report.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- Events -->
|
|
<section class="card">
|
|
<h2>Events
|
|
{{if .EventCounts}}
|
|
{{with mapGet .EventCounts "critical"}}<span class="severity-badge severity-critical">{{.}} critical</span>{{end}}
|
|
{{with mapGet .EventCounts "error"}}<span class="severity-badge severity-error">{{.}} error{{if gt . 1}}s{{end}}</span>{{end}}
|
|
{{with mapGet .EventCounts "warning"}}<span class="severity-badge severity-warning">{{.}} warning{{if gt . 1}}s{{end}}</span>{{end}}
|
|
{{end}}
|
|
<span class="text-muted" style="font-size: 0.7em; font-weight: normal;"> (last 24h)</span>
|
|
</h2>
|
|
{{if .Events}}
|
|
<div style="margin-bottom: 0.5rem;">
|
|
<button class="btn btn-sm btn-outline event-filter active" data-filter="all">All</button>
|
|
<button class="btn btn-sm btn-outline event-filter" data-filter="error">Errors</button>
|
|
<button class="btn btn-sm btn-outline event-filter" data-filter="warning">Warnings</button>
|
|
<button class="btn btn-sm btn-outline event-filter" data-filter="info">Info</button>
|
|
</div>
|
|
<table class="history-table" id="events-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Severity</th>
|
|
<th>Type</th>
|
|
<th>Message</th>
|
|
<th>Source</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Events}}
|
|
<tr data-severity="{{.Severity}}">
|
|
<td title="{{.CreatedAt.Format "2006-01-02 15:04:05"}}">{{.CreatedAt.Format "Jan 02 15:04"}}</td>
|
|
<td><span class="severity-badge severity-{{.Severity}}">{{.Severity}}</span></td>
|
|
<td><code>{{.EventType}}</code></td>
|
|
<td>{{.Message}}</td>
|
|
<td>{{.Source}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
<script>
|
|
document.querySelectorAll('.event-filter').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
document.querySelectorAll('.event-filter').forEach(b => b.classList.remove('active'));
|
|
this.classList.add('active');
|
|
const filter = this.dataset.filter;
|
|
document.querySelectorAll('#events-table tbody tr').forEach(row => {
|
|
row.style.display = (filter === 'all' || row.dataset.severity === filter) ? '' : 'none';
|
|
});
|
|
});
|
|
});
|
|
</script>
|
|
{{else}}
|
|
<p class="text-muted">No events recorded yet.</p>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- App telemetry -->
|
|
{{if .HasAppTelemetry}}
|
|
<section class="card">
|
|
<h2>App Telemetry <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(last 7 days)</span></h2>
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>App</th>
|
|
<th>Memory (current)</th>
|
|
<th>Memory (avg 7d)</th>
|
|
<th>Memory (peak 7d)</th>
|
|
<th>Catalog Limit</th>
|
|
<th>Errors</th>
|
|
<th>Warnings</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .AppTelemetry}}
|
|
<tr>
|
|
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
|
|
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
|
|
<td>{{formatFloat .MemoryAvgMB}} MB</td>
|
|
<td>{{formatFloat .MemoryPeakMB}} MB</td>
|
|
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
|
|
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
|
|
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{end}}
|
|
|
|
{{if .HasDRRecipe}}
|
|
<!-- DR recipe (secret-free reconstruction recipe) -->
|
|
<section class="card">
|
|
<h2>DR Recipe <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(secret-free reconstruction plan)</span></h2>
|
|
<p class="text-muted" style="margin-top: 0;">
|
|
The non-secret re-provision plan — guest sizing, drive inventory (durable-id → role → mount → intent),
|
|
PVE storage defs, PBS coordinates, and app inventory + storage bindings. It complements escrow (keys)
|
|
and PBS/restic (bytes): <strong>it contains no key, password, or token</strong>. Use it to rebuild the
|
|
host/guest/storage scaffolding before the PBS bytes land.
|
|
</p>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Storage / guest / PBS half (agent)</span>
|
|
<span class="value">{{if .DRRecipeHasHost}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting host-report</span>{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Customer / apps half (controller)</span>
|
|
<span class="value">{{if .DRRecipeHasApps}}<span class="badge badge-ok">present</span>{{else}}<span class="badge badge-warn">awaiting controller report</span>{{end}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Last updated</span>
|
|
<span class="value">{{if .DRRecipeUpdatedAt}}{{.DRRecipeUpdatedAt}}{{else}}—{{end}}</span>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 1rem;">
|
|
<a href="/customers/{{.CustomerID}}/dr-recipe.json" class="btn" download>Download recipe (JSON)</a>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
|
|
<!-- Notifications -->
|
|
<section class="card">
|
|
<h2>Notifications</h2>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Email</span>
|
|
<span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
|
|
</div>
|
|
{{if .NotifPrefs}}
|
|
<div class="info-item">
|
|
<span class="label">Events</span>
|
|
<span class="value">{{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{if .RecentNotifications}}
|
|
<h3>Recent (last 10)</h3>
|
|
<table class="history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Channel</th>
|
|
<th>Event</th>
|
|
<th>Status</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .RecentNotifications}}
|
|
<tr>
|
|
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
|
|
<td><span class="status-badge status-badge-{{.Channel}}">{{.Channel}}</span></td>
|
|
<td>{{.EventType}}</td>
|
|
<td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
|
|
<td>{{.Message}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
</section>
|
|
|
|
<!-- Report History -->
|
|
{{if .History}}
|
|
<section class="card">
|
|
<h2>Report History (last 24h)</h2>
|
|
<details>
|
|
<summary>{{len .History}} reports</summary>
|
|
<table class="history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Status</th>
|
|
<th>CPU</th>
|
|
<th>Memory</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .History}}
|
|
<tr>
|
|
<td>{{.ReceivedAt.Format "Jan 02 15:04"}}</td>
|
|
<td><span class="status-badge status-badge-{{.HealthStatus}}">{{.HealthStatus}}</span></td>
|
|
<td>{{formatFloat .CPUPercent}}%</td>
|
|
<td>{{formatFloat .MemoryPercent}}%</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</details>
|
|
</section>
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<footer>
|
|
{{if .HasReports}}<p>Auto-refreshes every 60 seconds · {{end}}<a href="/">Felhom Hub</a> {{hubVersion}}{{if .HasReports}}</p>{{end}}
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
function copyText(elementId) {
|
|
var el = document.getElementById(elementId);
|
|
var text = el.textContent || el.innerText;
|
|
navigator.clipboard.writeText(text.trim()).then(function() {
|
|
var btn = el.parentElement.querySelector('.copy-btn');
|
|
var orig = btn.innerHTML;
|
|
btn.innerHTML = '✓';
|
|
setTimeout(function() { btn.innerHTML = orig; }, 1500);
|
|
});
|
|
}
|
|
|
|
// Masked-secret controls (retrieval password): the value lives in data-secret and is masked in
|
|
// the visible node by default; Reveal toggles it, Copy copies the real value. It is never baked
|
|
// into a copyable command (see the Option-3 placeholder).
|
|
var _secretMask = '•'.repeat(16);
|
|
function toggleSecret(elementId) {
|
|
var el = document.getElementById(elementId);
|
|
var btn = document.getElementById('reveal-' + elementId);
|
|
if (el.dataset.revealed === '1') {
|
|
el.textContent = _secretMask; el.dataset.revealed = '0';
|
|
if (btn) btn.textContent = 'Reveal';
|
|
} else {
|
|
el.textContent = el.getAttribute('data-secret') || ''; el.dataset.revealed = '1';
|
|
if (btn) btn.textContent = 'Hide';
|
|
}
|
|
}
|
|
function copySecret(elementId) {
|
|
var el = document.getElementById(elementId);
|
|
var val = (el.getAttribute('data-secret') || '').trim();
|
|
navigator.clipboard.writeText(val).then(function() {
|
|
var btn = el.parentElement.querySelectorAll('.copy-btn');
|
|
var b = btn[btn.length - 1]; var orig = b.innerHTML;
|
|
b.innerHTML = '✓';
|
|
setTimeout(function() { b.innerHTML = orig; }, 1500);
|
|
});
|
|
}
|
|
|
|
// ── Install-command generator (GL-7) — client-side only; assembles a command from the controls
|
|
// and only ever emits real host-install flags. Nothing is submitted.
|
|
function genFlags() {
|
|
// returns {flags: "<assembled>", err: "<message or empty>"}
|
|
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
|
|
if (!mode) return { flags: '', err: 'Pick a mode (appliance or byo) to generate the command.' };
|
|
var f = ['--mode ' + mode];
|
|
var cores = (document.getElementById('gen-cores').value || '').trim();
|
|
var memory = (document.getElementById('gen-memory').value || '').trim();
|
|
var isInt = function (v) { return v !== '' && /^[0-9]+$/.test(v) && parseInt(v, 10) > 0; };
|
|
if (mode === 'byo') {
|
|
if (!isInt(cores) || !isInt(memory)) {
|
|
return { flags: '', err: 'byo mode requires --cores and --memory (noisy-neighbor caps on a host you do not own).' };
|
|
}
|
|
f.push('--cores ' + cores, '--memory ' + memory);
|
|
} else {
|
|
// appliance: caps optional; emit only if both are valid positive integers
|
|
if (isInt(cores)) f.push('--cores ' + cores);
|
|
if (isInt(memory)) f.push('--memory ' + memory);
|
|
}
|
|
var vmid = (document.getElementById('gen-vmid').value || '').trim();
|
|
if (vmid !== '' && isInt(vmid)) f.push('--vmid ' + vmid);
|
|
var node = (document.getElementById('gen-node').value || '').trim();
|
|
if (node) f.push('--node ' + node);
|
|
var acl = (document.getElementById('gen-acl').value || '').trim().replace(/\s+/g, ' ');
|
|
if (acl) f.push('--acl-storages "' + acl + '"');
|
|
var pub = (document.getElementById('gen-pubkey').value || '').trim();
|
|
if (pub) f.push('--operator-pubkey-file ' + pub);
|
|
var pre = (document.getElementById('gen-preserve').value || '').trim();
|
|
if (pre) f.push('--preserve-state-from ' + pre);
|
|
if (document.getElementById('gen-skip').checked) f.push('--skip-provision');
|
|
if (document.getElementById('gen-dry').checked) f.push('--dry-run');
|
|
if (document.getElementById('gen-preflight').checked) f.push('--preflight-only');
|
|
if (document.getElementById('gen-leaf').checked) f.push('--allow-new-leaf');
|
|
return { flags: f.join(' '), err: '' };
|
|
}
|
|
|
|
function genUpdate() {
|
|
var ctrl = document.getElementById('gen-controls');
|
|
if (!ctrl) return;
|
|
var cid = ctrl.getAttribute('data-customer-id') || '';
|
|
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
|
|
// byo requires caps → show the required markers only in byo
|
|
var reqOn = (mode === 'byo');
|
|
document.querySelectorAll('.gen-req').forEach(function (e) { e.style.display = reqOn ? 'inline' : 'none'; });
|
|
// allow-new-leaf inline warning
|
|
var lw = document.getElementById('gen-leaf-warn');
|
|
lw.textContent = document.getElementById('gen-leaf').checked
|
|
? '⚠ regenerates the agent leaf — every provisioned guest must then be re-bootstrapped' : '';
|
|
|
|
var r = genFlags();
|
|
var msg = document.getElementById('gen-msg');
|
|
var online = document.getElementById('cmd-online');
|
|
var local = document.getElementById('cmd-setup');
|
|
var base = 'felhom-host-install.sh --customer-id ' + cid;
|
|
if (r.err) {
|
|
msg.textContent = r.err; msg.style.display = 'block';
|
|
// Do NOT emit a runnable command — show the incomplete shape with a placeholder.
|
|
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' --mode <appliance|byo>';
|
|
local.textContent = 'sudo ./' + base + ' --mode <appliance|byo>';
|
|
return;
|
|
}
|
|
msg.style.display = 'none';
|
|
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' ' + r.flags;
|
|
local.textContent = 'sudo ./' + base + ' ' + r.flags;
|
|
}
|
|
// initialize on load (also gives JS-enabled users the "pick a mode" prompt state)
|
|
if (document.getElementById('gen-controls')) { genUpdate(); }
|
|
|
|
function disableGeo(customerID) {
|
|
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
|
|
var btn = document.getElementById('btn-geo-disable');
|
|
var msg = document.getElementById('geo-msg');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Eltávolítás...';
|
|
msg.style.display = 'none';
|
|
fetch('/customers/' + customerID + '/geo/disable', {method: 'POST', headers: csrfHeaders()})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.ok) {
|
|
msg.textContent = data.message || 'Geo-korlátozás eltávolítva';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#2EA8F5';
|
|
setTimeout(function() { location.reload(); }, 2000);
|
|
} else {
|
|
msg.textContent = data.error || 'Hiba történt';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#E5534B';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Összes geo-korlátozás eltávolítása';
|
|
}
|
|
})
|
|
.catch(function() {
|
|
msg.textContent = 'Kapcsolódási hiba';
|
|
msg.style.display = 'inline';
|
|
msg.style.color = '#E5534B';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Összes geo-korlátozás eltávolítása';
|
|
});
|
|
}
|
|
{{if .HasConfig}}
|
|
// Load YAML preview
|
|
fetch('/configs/{{.CustomerID}}/preview')
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(yaml) {
|
|
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&').replace(/</g,'<') + '</pre>';
|
|
})
|
|
.catch(function() {
|
|
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
|
|
});
|
|
{{end}}
|
|
</script>
|
|
|
|
{{if .HasReports}}
|
|
<style>
|
|
.auto-refresh-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin-left: 1rem;
|
|
cursor: pointer;
|
|
vertical-align: middle;
|
|
font-size: 0.8rem;
|
|
user-select: none;
|
|
}
|
|
.auto-refresh-toggle input { display: none; }
|
|
.toggle-slider {
|
|
position: relative;
|
|
width: 32px;
|
|
height: 18px;
|
|
background: var(--bg-2);
|
|
border-radius: 9px;
|
|
transition: background 0.2s;
|
|
}
|
|
.toggle-slider::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: var(--text-2);
|
|
border-radius: 50%;
|
|
transition: transform 0.2s, background 0.2s;
|
|
}
|
|
.auto-refresh-toggle input:checked + .toggle-slider {
|
|
background: #22c55e;
|
|
}
|
|
.auto-refresh-toggle input:checked + .toggle-slider::after {
|
|
transform: translateX(14px);
|
|
background: #fff;
|
|
}
|
|
.toggle-label { color: var(--text-2); }
|
|
</style>
|
|
<script>
|
|
(function() {
|
|
var toggle = document.getElementById('autoRefreshToggle');
|
|
if (!toggle) return;
|
|
var key = 'hub_auto_refresh';
|
|
var enabled = localStorage.getItem(key) !== 'off';
|
|
var timer = null;
|
|
|
|
toggle.checked = enabled;
|
|
|
|
function setRefresh(on) {
|
|
clearTimeout(timer);
|
|
if (on) timer = setTimeout(function() { location.reload(); }, 60000);
|
|
}
|
|
|
|
toggle.addEventListener('change', function() {
|
|
localStorage.setItem(key, this.checked ? 'on' : 'off');
|
|
setRefresh(this.checked);
|
|
});
|
|
|
|
setRefresh(enabled);
|
|
})();
|
|
</script>
|
|
{{end}}
|
|
</body>
|
|
</html>
|