4d6ec7c7bb
gates / gates (push) Successful in 14s
Four paper debts and one fact given a reader. Hub-only — nothing to bake. A4 — the entry about "the tester's machine" named a risk correctly and labelled it in a way that invited deleting it. Established from the hub's own store: `peti-felhom` is a REAL machine (482 reports, 2026-02-27 → 2026-07-15, a named person's own box) and the 3.6 GB with no key and no backup is real. `david` → `tester-1` is a DIFFERENT record with no host, no escrow and no report, ever — deleted 07:55:49 and re-created 07:56:47 this morning. The prompt's premise conflated the two; the register now says which is which. A1 — R-312/R-313/R-303 recorded as DECIDED with their re-open triggers, and moved out of STATUS's "Waiting on you", which is now empty. A3 — day0-install §C.1 said pushing the installer publishes it. It has not since R-110. Corrected, with the two manifest pins named and an outside-verification command; the one copy that repeated it (a dated audit, true when written) carries a superseded note. A5 — standing rule 5: evidence comes off the machine at the end of the phase that produced it, before any revert. Earned twice in three days on the same box at the same point (R-320). Four homes, plus what to do when it is already gone. R-295 hub half — „Beállító kód" everywhere; „Visszaállító kód" retired. New `reenroll` mail kind so the mail names the page a REBUILT box actually shows („A szerver beállítása"), not the „Elfelejtett jelszó" page it has no login screen to reach. Naming only; the acceptance pin proves the secret is untouched. R-319 — the hub models `guest_net` after 23 days of receiving and discarding it. The signal is `heals_last_hour`, not `state`: a guest the watchdog keeps repairing reads healthy between repairs. `heal_succeeded` decoded too (R-260's lesson). Unknown is never drawn as healthy — three absences, three sentences. No alarm, deliberately. Three red-proofs, mutations asserted applied. Wire-gate checked tags 182 → 190. B1 — the operator's 2026-08-12 dispositions were NOT in the register; they are now. Third allowlist kind for the five ruled "no reader wanted"; `reporting_disabled` reclassified redundant. 8 read · 5 deliberately unread · 1 redundant · 6 still owed. Also filed: R-321 (a deliberately-silent box still alarms stale/down — the checker is age-only, and decoding the flag would not have fixed it), R-322 (the claim guard has never scanned the hub; a hand scan returns zero, so it is a scope gap, not a defect).
1448 lines
83 KiB
HTML
1448 lines
83 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?v={{hubVersion}}">
|
|
<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>
|
|
{{template "inline_confirm_js"}}
|
|
</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}}
|
|
{{if .HostCause}}<span class="status-badge status-badge-warn">{{.HostCause}}</span>{{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>
|
|
<span id="refresh-paused-hint" class="toggle-label" style="display:none">(paused)</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 "pbsdr_reissued"}}PBS DR credentials re-issued — a fresh one-time token secret is staged for the agent. Confirm it actually landed: the host's <code>pvesm status</code> must show the PBS entry <strong>active</strong>. A converged agent can report <code>applied</code> while the storage still authenticates 401 (R-39).
|
|
{{else if eq .Flash "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen.
|
|
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
|
|
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
|
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
|
{{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded.
|
|
{{else if eq .Flash "claim-resent"}}Code re-sent to the registered address. A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik.
|
|
{{else if eq .Flash "claim-resend-failed"}}Claim code resend FAILED — check the hub log (email delivery / send error).
|
|
{{else if eq .Flash "selfbind-sent"}}Self-bind link sent to the registered address — valid for 7 days. The customer enters the box's console pairing code + their retrieval passphrase; no operator bind needed.
|
|
{{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page.
|
|
{{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error).
|
|
{{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it.
|
|
{{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}}
|
|
|
|
{{if .StaleSinceReset}}
|
|
<div class="flash flash-warn">
|
|
<strong>No data since the RESET</strong> ({{.ResetAt}}) — „RESET óta nincs adat".
|
|
Every health figure below predates it and describes a lifecycle that no longer exists.
|
|
The box repopulates this page on its first report after re-onboarding.
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .OffsiteUnprovisioned}}
|
|
<div class="flash flash-warn">
|
|
<strong>Offsite is enabled but was never provisioned</strong> — no descriptor exists for
|
|
this customer, so nothing is being backed up offsite. Provisioning is <em>Save</em>-triggered:
|
|
open the Edit tab and press Save once to provision it, then verify on the Offsite page.
|
|
Re-enrollment will not fix this on its own — the auto-re-issue deliberately skips an
|
|
unprovisioned target. (R-36)
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Sticky summary strip: identity + liveness at a glance while any tab is scrolled.
|
|
Values mirror fields already rendered in the panels — no extra handler data. -->
|
|
<div class="summary-strip">
|
|
<span class="strip-name">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</span>
|
|
<span class="strip-item"><span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span> {{.OverallStatus}}</span>
|
|
{{if .HostCause}}<span class="strip-item"><span class="status-badge status-badge-warn">{{.HostCause}}</span></span>{{end}}
|
|
{{if .HasReports}}
|
|
<span class="strip-item">Controller <code>{{.Customer.ControllerVersion}}</code></span>
|
|
<span class="strip-item">Last report {{timeAgo .Customer.ReceivedAt}}</span>
|
|
<span class="strip-item">Containers {{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}}</span>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{if not .HasReports}}
|
|
{{if .HasConfig}}
|
|
<!-- Always above the tab bar — visible whichever tab is active. -->
|
|
<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}}
|
|
|
|
<!-- Tab nav: hash-based (#tab=<name>); with JS off it is plain anchors and every panel
|
|
below stays visible. data-live-tabs = the tabs whose content changes with incoming
|
|
reports; the 60s auto-refresh fires ONLY while one of them is active (v0.48.0). -->
|
|
<nav class="tab-nav" id="tab-nav" data-live-tabs="overview,applications,events,host">
|
|
<a href="#tab=overview" data-tab="overview" class="active">Overview</a>
|
|
<a href="#tab=applications" data-tab="applications">Applications</a>
|
|
<a href="#tab=setup" data-tab="setup">Setup</a>
|
|
<a href="#tab=edit" data-tab="edit">Edit</a>
|
|
<a href="#tab=backup" data-tab="backup">Backup & DR</a>
|
|
<a href="#tab=events" data-tab="events">Events{{with mapGet .EventCounts "error"}}<span class="tab-badge">{{.}}</span>{{end}}</a>
|
|
<a href="#tab=notifications" data-tab="notifications">Notifications</a>
|
|
<a href="#tab=host" data-tab="host">Host</a>
|
|
</nav>
|
|
|
|
<!-- ═══ Overview ═══ -->
|
|
<div class="tab-panel tab-panel-active" data-tab="overview">
|
|
|
|
<!-- 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;">
|
|
{{/* v0.48.0 edit-a: Edit/Block/Delete moved to the Edit tab (form + danger
|
|
zone); only the config-less bootstrap action stays in the header. */}}
|
|
{{if not .HasConfig}}
|
|
<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}}
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Applications ═══ -->
|
|
<div class="tab-panel" data-tab="applications">
|
|
|
|
{{if .HasReports}}
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
<th>Logs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .AppTelemetry}}
|
|
<tr>
|
|
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{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>
|
|
<td>
|
|
{{if index $.PendingTails .AppName}}
|
|
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
|
|
{{else}}
|
|
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
|
|
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
|
<input type="hidden" name="app" value="{{.AppName}}">
|
|
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
|
|
</form>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{end}}
|
|
|
|
<!-- Received log tails (on-demand, transient — last 2 per app) -->
|
|
{{if .HasLogTails}}
|
|
<section class="card">
|
|
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>App</th>
|
|
<th>Collected</th>
|
|
<th>Received</th>
|
|
<th>Lines</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .LogTails}}
|
|
<tr>
|
|
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
|
|
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
|
|
<td>{{timeAgo .ReceivedAt}}</td>
|
|
<td>{{len .Lines}}</td>
|
|
<td style="white-space: nowrap;">
|
|
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
|
|
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
|
|
</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{end}}
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">Container and app data appear once the first report arrives.</p></section>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Setup ═══ -->
|
|
<div class="tab-panel" data-tab="setup">
|
|
|
|
{{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;">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Regenerate retrieval password? The old password will stop working immediately.">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>Dashboard access — customer claim</h2>
|
|
<div class="credential-row">
|
|
<div>
|
|
<span class="label">Claim status</span>
|
|
<div style="margin: 0.3rem 0;">
|
|
{{if .Claim}}
|
|
{{if .Claim.ClaimedAt}}
|
|
<span class="badge badge-ok">Claimed {{timeAgoPtr .Claim.ClaimedAt}}</span>
|
|
{{else if .Claim.EmailedAt}}
|
|
<span class="badge badge-warn">Nyitott — kód kiküldve {{timeAgoPtr .Claim.EmailedAt}}</span>
|
|
{{else}}
|
|
<span class="badge badge-error">Nyitott — a kód e-mail NEM ment ki (resend!)</span>
|
|
{{end}}
|
|
<span class="form-hint" style="margin-left: 0.5rem;">generation {{.Claim.Generation}} · issued {{timeAgo .Claim.IssuedAt}}</span>
|
|
{{else}}
|
|
<span class="badge badge-neutral">no code issued yet</span>
|
|
<span class="form-hint" style="margin-left: 0.5rem;">issued automatically at the first config pull or report</span>
|
|
{{end}}
|
|
</div>
|
|
<span class="form-hint">The customer sets + owns the dashboard password (claim code → own password). The code goes ONLY to the registered address ({{.Email}}); the hub stores a hash — no plaintext code exists to display.</span>
|
|
</div>
|
|
{{if .Claim}}
|
|
<form method="POST" action="/configs/{{.CustomerID}}/claim-resend" style="margin-top: 0.5rem;">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Send a fresh code to the registered address? The previous code stops working immediately (the box activates it on its next report, ~15 min).">{{if .Claim.ClaimedAt}}Beállító kód küldése{{else}}Kód újraküldése{{end}}</button>
|
|
</form>
|
|
{{end}}
|
|
|
|
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
|
<label class="form-label">Customer self-bind (R-27)</label>
|
|
<span class="form-hint">Let the customer bind their own freshly-installed appliance — no operator bind needed. Sends a 7-day capability link to the registered address ({{.Email}}); the customer opens it and enters the box's <strong>console pairing code</strong> + their <strong>retrieval passphrase</strong>. Wrong entries lock the link after 5 attempts. If the link expires, bind the appliance manually from the Hosts page.</span>
|
|
<form method="POST" action="/customers/{{.CustomerID}}/selfbind-link" style="margin-top: 0.5rem;">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Email a self-bind link to the registered address? Any previous self-bind link for this customer stops working immediately.">Send self-bind link</button>
|
|
</form>
|
|
</div>
|
|
</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. The command always fetches the <strong>current</strong>
|
|
felhom-host-install.sh from felhom.eu — there is no version to pick here. Run it
|
|
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>
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">No managed config yet — create one from the Overview tab to get setup commands.</p></section>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Edit ═══ (v0.48.0 edit-a: the standalone config form embedded via the shared
|
|
config_form_body sub-template, plus the relocated Block/Delete danger zone. The
|
|
Controller Update / Geo / Danger zone cards are SIBLINGS after </form> — nesting
|
|
them inside the config <form> is invalid HTML and breaks the formaction sub-buttons. -->
|
|
<div class="tab-panel" data-tab="edit">
|
|
|
|
{{if .HasConfig}}
|
|
{{template "config_form_body" .ConfigForm}}
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">No managed config yet — create one with the Create Config button above.</p></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 in
|
|
the form above; the controller re-pulls and restarts on its next report.
|
|
</p>
|
|
</section>
|
|
|
|
<!-- Geo-restriction -->
|
|
{{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}}
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">Controller update and geo-restriction settings appear once the first report arrives.</p></section>
|
|
{{end}}
|
|
|
|
{{if .HasConfig}}
|
|
<!-- Reset customer (v0.61.0): the MIDDLE lifecycle tier — host delete < RESET < customer Delete.
|
|
One action returns the customer to pre-first-install: every OPERATIONAL trace dies (offsite
|
|
repo, PBS namespace, DR recipe, one-time secret, claim state, retained escrow custody), while
|
|
IDENTITY and the basic config SURVIVE. Amber (--warn), deliberately distinct from the red
|
|
Danger-zone Delete below it. Refuses while any host row exists (delete hosts first). -->
|
|
<section class="card" style="border-color: var(--warn);">
|
|
<h2>Ügyfél-visszaállítás <span class="text-muted" style="font-size: 0.8em; font-weight: normal;">(RESET — pre-első-telepítés)</span></h2>
|
|
<p class="text-muted">Egyetlen művelettel visszaállítja az ügyfelet az első telepítés előtti állapotba: <strong>minden működési állapot törlődik</strong> (offsite tároló, PBS névtér, DR-recept, egyszeri jelszó, azonosítási állapot). Az <strong>azonosság és az alapkonfiguráció megmarad</strong> (ügyfélrekord, előzmények, események). Ez NEM törli a hostokat — ha még van host, előbb azt kell törölni. Kevesebb, mint a Danger zone Delete: az ügyfél megmarad, csak a működési nyomok tűnnek el.</p>
|
|
<button type="button" class="btn btn-sm" style="border-color: var(--warn); color: var(--warn);" onclick="customerResetConfirm('{{.CustomerID}}')">Ügyfél visszaállítása…</button>
|
|
<div id="cust-reset-confirm-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--warn); background: var(--warn-dim); border-radius: var(--radius); max-width: 46em;">
|
|
<p id="cust-reset-inv-{{.CustomerID}}" style="margin: 0 0 0.5rem; font-size: 0.9em;">…</p>
|
|
<label id="cust-reset-escrow-row-{{.CustomerID}}" style="display: none; margin: 0 0 0.6rem; font-size: 0.85em; color: var(--crit);">
|
|
<input type="checkbox" id="cust-reset-escrow-{{.CustomerID}}">
|
|
<strong>Megőrzött helyreállítási-kulcs letét megsemmisítése</strong> — külön megerősítés (ez visszafordíthatatlanul törli a megőrzött escrow blobokat).
|
|
</label>
|
|
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Írd be az ügyfél azonosítóját a megerősítéshez:</p>
|
|
<form method="POST" action="/configs/{{.CustomerID}}/reset" id="cust-reset-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
{{.CSRFField}}
|
|
<input type="hidden" name="confirm_id" id="cust-reset-confirm-hidden-{{.CustomerID}}" value="">
|
|
<input type="hidden" name="escrow_ack" id="cust-reset-escrow-hidden-{{.CustomerID}}" value="">
|
|
<input type="text" id="cust-reset-input-{{.CustomerID}}" placeholder="ügyfél-azonosító…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button type="button" class="btn btn-sm" id="cust-reset-go-{{.CustomerID}}" style="border-color: var(--warn); color: var(--warn);" onclick="customerResetSubmit('{{.CustomerID}}')">Megerősítés & visszaállítás</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-reset-confirm-{{.CustomerID}}').style.display='none';">Mégse</button>
|
|
</form>
|
|
<p id="cust-reset-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
|
|
</div>
|
|
</section>
|
|
<script>
|
|
function customerResetConfirm(cid) {
|
|
var box = document.getElementById('cust-reset-confirm-' + cid);
|
|
var inv = document.getElementById('cust-reset-inv-' + cid);
|
|
var go = document.getElementById('cust-reset-go-' + cid);
|
|
document.getElementById('cust-reset-input-' + cid).value = '';
|
|
document.getElementById('cust-reset-err-' + cid).textContent = '';
|
|
box.style.display = 'block';
|
|
inv.textContent = 'Leltár lekérése…';
|
|
go.disabled = false;
|
|
fetch('/configs/' + encodeURIComponent(cid) + '/reset')
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
if (d.refused) {
|
|
inv.innerHTML = '<strong style="color: var(--crit)">Elutasítva:</strong> ehhez az ügyfélhez még ' + d.host_count +
|
|
' host tartozik. A RESET soha nem töröl hostot — előbb töröld a host(oka)t.';
|
|
go.disabled = true;
|
|
document.getElementById('cust-reset-escrow-row-' + cid).style.display = 'none';
|
|
return;
|
|
}
|
|
var dies = [];
|
|
if (d.offsite_enabled) dies.push('offsite tároló' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
|
|
if (d.pbs_tenancy_configured) dies.push('PBS névtér + mentések');
|
|
if (d.dr_recipe_present) dies.push('DR-recept');
|
|
if (d.one_time_secret) dies.push('egyszeri jelszó');
|
|
if (d.claim_present) dies.push('azonosítási állapot (friss kód a következő onboardingnál)');
|
|
if (d.superseded_blobs > 0) dies.push(d.superseded_blobs + ' megőrzött escrow blob');
|
|
inv.innerHTML = '<strong>Törlődik:</strong> ' + (dies.length ? dies.join(', ') : 'nincs működési állapot') +
|
|
'. <strong>Megmarad:</strong> ügyfélrekord, alapkonfiguráció, előzmények, események.';
|
|
var escrowRow = document.getElementById('cust-reset-escrow-row-' + cid);
|
|
escrowRow.style.display = d.escrow_ack_required ? 'block' : 'none';
|
|
document.getElementById('cust-reset-escrow-' + cid).checked = false;
|
|
})
|
|
.catch(function(){ inv.textContent = 'A leltár nem kérhető le — a szerver minden feltételt így is kikényszerít.'; });
|
|
}
|
|
function customerResetSubmit(cid) {
|
|
var typed = document.getElementById('cust-reset-input-' + cid).value.trim();
|
|
var err = document.getElementById('cust-reset-err-' + cid);
|
|
if (typed !== cid) { err.textContent = 'A beírt azonosító nem egyezik.'; return; }
|
|
var escrowRow = document.getElementById('cust-reset-escrow-row-' + cid);
|
|
var escrowCb = document.getElementById('cust-reset-escrow-' + cid);
|
|
if (escrowRow.style.display !== 'none' && !escrowCb.checked) {
|
|
err.textContent = 'A megőrzött kulcs-letét megsemmisítéséhez pipáld be a külön megerősítést.';
|
|
return;
|
|
}
|
|
document.getElementById('cust-reset-confirm-hidden-' + cid).value = typed;
|
|
document.getElementById('cust-reset-escrow-hidden-' + cid).value = escrowCb.checked ? '1' : '';
|
|
document.getElementById('cust-reset-form-' + cid).submit();
|
|
}
|
|
</script>
|
|
{{end}}
|
|
|
|
<!-- Danger zone (v0.48.0 edit-a): the Block/Delete forms relocated verbatim from the
|
|
Customer Info header — endpoints and confirm() handlers unchanged.
|
|
v0.70.1: gated on .Deletable, NOT .HasConfig — a GHOST customer (config gone, hosts or
|
|
report/telemetry residue remain) must render the Delete button, or the whole v0.70.0
|
|
ghost-delete path is dead UI (found live on demo-vm-felhom). RESET stays HasConfig-gated
|
|
above (identity-preserving re-onboarding — a ghost has no identity to preserve). -->
|
|
{{if .Deletable}}
|
|
<section class="card">
|
|
<h2>Danger zone</h2>
|
|
<p class="text-muted">{{if not .HasConfig}}<strong style="color: var(--warn);">Ghost customer</strong> — the configuration record is already gone; Delete is the applicable action. {{end}}Blocking hides the customer from the Dashboard (reports are still accepted). <strong>Delete customer</strong> is the full offboarding teardown (v0.69.0): it deletes the host(s), then RESETs the customer (offsite repository destroyed, PBS credentials revoked, tunnel and zone removed), then purges the customer record and all escrow ciphertext — including the <strong>retained recovery-key custody</strong> for this customer's hosts. This is the one true purge point; host deletion only demotes custody, never destroys it. Three acknowledgements and the typed customer-id are required. For identity-preserving re-onboarding use <em>Ügyfél-visszaállítás (RESET)</em> above instead.</p>
|
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;">
|
|
{{if .HasConfig}}{{/* v0.70.1: blocking gates dashboard visibility of a CONFIGURED customer — meaningless for a ghost */}}
|
|
{{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">
|
|
{{.CSRFField}}
|
|
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Block this customer? They will be hidden from the Dashboard.">Block</button>
|
|
</form>
|
|
{{end}}
|
|
{{end}}
|
|
<button type="button" class="btn btn-danger btn-sm" onclick="customerDeleteOpen('{{.CustomerID}}')">Delete customer…</button>
|
|
</div>
|
|
|
|
<!-- Guided full-teardown cascade (v0.69.0, R-25b). The inventory panel is fetched from
|
|
GET /configs/{id}/delete; the three acknowledgements + typed customer-id are ALSO
|
|
enforced server-side (this is convenience, never the gate). -->
|
|
<div id="cust-del-box-{{.CustomerID}}" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid var(--crit); border-radius: var(--radius); max-width: 52em;">
|
|
<p id="cust-del-inv-{{.CustomerID}}" style="margin: 0 0 0.75rem; font-size: 0.9em;">…</p>
|
|
<div id="cust-del-journal-{{.CustomerID}}" style="display: none; margin: 0 0 0.75rem; padding: 0.5rem; border: 1px solid var(--warn); font-size: 0.85em;"></div>
|
|
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
|
|
<input type="checkbox" id="cust-del-ack1-{{.CustomerID}}">
|
|
<strong>1.</strong> <span id="cust-del-ack1-text-{{.CustomerID}}">The host(s) will be deleted</span> — recovery-key custody is <strong>demoted</strong> to retained custody, not destroyed.
|
|
</label>
|
|
<label style="display: block; margin: 0 0 0.5rem; font-size: 0.85em;">
|
|
<input type="checkbox" id="cust-del-ack2-{{.CustomerID}}">
|
|
<strong>2.</strong> The customer will be <strong>RESET</strong> — the offsite repository is <strong>DESTROYED</strong>, PBS credentials are revoked, tunnel and zone are removed.
|
|
</label>
|
|
<label style="display: block; margin: 0 0 0.75rem; font-size: 0.85em; color: var(--crit);">
|
|
<input type="checkbox" id="cust-del-ack3-{{.CustomerID}}">
|
|
<strong>3.</strong> The customer record and <strong>ALL escrow ciphertext</strong> are <strong>PURGED</strong> — unrecoverable.
|
|
</label>
|
|
<p style="margin: 0 0 0.4rem; font-size: 0.85em; color: var(--text-2);">Type the customer-id to confirm:</p>
|
|
<form method="POST" action="/configs/{{.CustomerID}}/delete" id="cust-del-form-{{.CustomerID}}" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
{{.CSRFField}}
|
|
<input type="hidden" name="confirm_id" id="cust-del-cid-{{.CustomerID}}" value="">
|
|
<input type="hidden" name="ack_hosts" id="cust-del-h1-{{.CustomerID}}" value="">
|
|
<input type="hidden" name="ack_reset" id="cust-del-h2-{{.CustomerID}}" value="">
|
|
<input type="hidden" name="ack_purge" id="cust-del-h3-{{.CustomerID}}" value="">
|
|
<input type="hidden" name="expect_hosts" id="cust-del-exp-{{.CustomerID}}" value="">
|
|
<input type="text" id="cust-del-input-{{.CustomerID}}" placeholder="customer-id…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button type="button" class="btn btn-danger btn-sm" id="cust-del-go-{{.CustomerID}}" onclick="customerDeleteSubmit('{{.CustomerID}}')">Confirm & delete everything</button>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="document.getElementById('cust-del-box-{{.CustomerID}}').style.display='none';">Cancel</button>
|
|
</form>
|
|
<p id="cust-del-err-{{.CustomerID}}" style="margin: 0.4em 0 0; font-size: 0.8em; color: var(--crit);"></p>
|
|
</div>
|
|
</section>
|
|
<script>
|
|
function customerDeleteOpen(cid) {
|
|
var box = document.getElementById('cust-del-box-' + cid);
|
|
var inv = document.getElementById('cust-del-inv-' + cid);
|
|
var go = document.getElementById('cust-del-go-' + cid);
|
|
var jr = document.getElementById('cust-del-journal-' + cid);
|
|
box.style.display = 'block';
|
|
inv.textContent = 'Loading inventory…';
|
|
jr.style.display = 'none';
|
|
document.getElementById('cust-del-input-' + cid).value = '';
|
|
document.getElementById('cust-del-err-' + cid).textContent = '';
|
|
document.getElementById('cust-del-exp-' + cid).value = '';
|
|
['ack1', 'ack2', 'ack3'].forEach(function(a) { document.getElementById('cust-del-' + a + '-' + cid).checked = false; });
|
|
go.disabled = false;
|
|
fetch('/configs/' + encodeURIComponent(cid) + '/delete')
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
document.getElementById('cust-del-exp-' + cid).value = String(d.host_count);
|
|
var hostNames = (d.hosts || []).map(function(h){ return h.host_id + ' (' + h.status + ')'; });
|
|
document.getElementById('cust-del-ack1-text-' + cid).textContent =
|
|
d.host_count + ' host(s) will be deleted' + (hostNames.length ? ': ' + hostNames.join(', ') : '');
|
|
var dies = [];
|
|
if (d.host_count) dies.push(d.host_count + ' host row(s)');
|
|
if (d.offsite_enabled) dies.push('offsite repository' + (d.offsite_identifier ? ' (' + d.offsite_identifier + ')' : ''));
|
|
if (d.pbs_tenancy_configured) dies.push('PBS namespace + backups + token');
|
|
if (d.dr_recipe_present) dies.push('DR recipe');
|
|
if (d.one_time_secret) dies.push('one-time password');
|
|
if (d.claim_present) dies.push('claim state');
|
|
if (d.residue && d.residue.reports) dies.push(d.residue.reports + ' report row(s)');
|
|
if (d.residue && d.residue.app_telemetry) dies.push(d.residue.app_telemetry + ' app-telemetry row(s)');
|
|
if (d.residue && d.residue.appliance_registrations) dies.push('appliance registration (token)');
|
|
if (d.residue && d.residue.selfbind_tokens) dies.push('self-bind token(s)');
|
|
if (d.residue && d.residue.notification_prefs) dies.push('notification preferences');
|
|
dies.push('customer record');
|
|
var custody = d.superseded_blobs > 0
|
|
? d.superseded_blobs + ' retained escrow blob(s) + every current host escrow'
|
|
: 'every current host escrow';
|
|
inv.innerHTML = '<strong>Will be destroyed:</strong> ' + dies.join(', ') +
|
|
'. <strong>Custody:</strong> ' + custody + ' (purged in the final leg). ' +
|
|
'<strong>Survives:</strong> the audit event stream, the notification log and the deletion provenance.';
|
|
if (d.has_config === false) {
|
|
inv.innerHTML = '<strong style="color: var(--warn)">Ghost customer:</strong> the configuration record ' +
|
|
'is already gone, but ' + d.residue_total + ' row(s) of report/telemetry state keep it on the ' +
|
|
'Customers list and keep it raising staleness alerts. This clears it.<br>' + inv.innerHTML;
|
|
}
|
|
if (d.online_host_present) {
|
|
inv.innerHTML += '<br><strong style="color: var(--crit)">Refused:</strong> a host is ONLINE. ' +
|
|
'Decommission the box first — the cascade never deletes a live host.';
|
|
go.disabled = true;
|
|
}
|
|
if (d.pending_journal) {
|
|
var legs = d.pending_journal.legs || {};
|
|
var parts = Object.keys(legs).map(function(k){ return k + '=' + legs[k]; });
|
|
jr.innerHTML = '<strong>An earlier cascade stopped mid-way</strong> (journal #' + d.pending_journal.id +
|
|
', started ' + d.pending_journal.started_at + '). Legs: ' + (parts.length ? parts.join(', ') : 'none recorded') +
|
|
'. Confirming again RESUMES it — completed legs are no-ops.';
|
|
jr.style.display = 'block';
|
|
document.getElementById('cust-del-go-' + cid).textContent = 'Confirm & resume teardown';
|
|
}
|
|
})
|
|
.catch(function(){ inv.textContent = 'Inventory unavailable — the server enforces every gate regardless.'; });
|
|
}
|
|
function customerDeleteSubmit(cid) {
|
|
var err = document.getElementById('cust-del-err-' + cid);
|
|
var a1 = document.getElementById('cust-del-ack1-' + cid).checked;
|
|
var a2 = document.getElementById('cust-del-ack2-' + cid).checked;
|
|
var a3 = document.getElementById('cust-del-ack3-' + cid).checked;
|
|
if (!a1 || !a2 || !a3) { err.textContent = 'All three acknowledgements are required.'; return; }
|
|
var typed = document.getElementById('cust-del-input-' + cid).value.trim();
|
|
if (typed !== cid) { err.textContent = 'The typed customer-id does not match.'; return; }
|
|
document.getElementById('cust-del-cid-' + cid).value = typed;
|
|
document.getElementById('cust-del-h1-' + cid).value = '1';
|
|
document.getElementById('cust-del-h2-' + cid).value = '1';
|
|
document.getElementById('cust-del-h3-' + cid).value = '1';
|
|
document.getElementById('cust-del-form-' + cid).submit();
|
|
}
|
|
</script>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Backup & DR ═══ -->
|
|
<div class="tab-panel" data-tab="backup">
|
|
|
|
{{if .HasReports}}
|
|
{{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>
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">No DR recipe yet — it assembles from the host-report and controller report.</p></section>
|
|
{{end}}
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">Backup and DR data appear once the first report arrives.</p></section>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Events ═══ -->
|
|
<div class="tab-panel" data-tab="events">
|
|
|
|
{{if .HasReports}}
|
|
<!-- 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>
|
|
|
|
<!-- 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}}
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">Events appear once the first report arrives.</p></section>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Notifications ═══ -->
|
|
<div class="tab-panel" data-tab="notifications">
|
|
|
|
{{if .HasReports}}
|
|
<!-- 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>
|
|
{{else}}
|
|
<section class="card"><p class="text-muted">Notification data appears once the first report arrives.</p></section>
|
|
{{end}}
|
|
|
|
</div>
|
|
|
|
<!-- ═══ Host ═══ (a LIST by design — 1 host today, N for a later HA cluster) -->
|
|
<div class="tab-panel" data-tab="host">
|
|
{{if .Hosts}}
|
|
{{range .Hosts}}
|
|
<p style="margin: 0 0 0.5rem;"><a href="/hosts/{{.HostID}}" class="back-link">Open host page: {{.HostID}} →</a></p>
|
|
{{template "host_detail_body" .}}
|
|
{{end}}
|
|
{{else}}
|
|
<section class="card">
|
|
<div class="empty-state" style="border: none;">
|
|
<p>No host enrolled yet.</p>
|
|
<p class="hint">A host appears here once it enrolls via the Day-0 bootstrap.</p>
|
|
</div>
|
|
</section>
|
|
{{end}}
|
|
</div>
|
|
|
|
<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) {
|
|
var geoBtn = document.getElementById('btn-geo-disable');
|
|
felhomConfirm(geoBtn, 'Összes geo-korlátozás eltávolítása? Ez közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.', function () {
|
|
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>
|
|
|
|
<script>
|
|
// Client-side hash tabs (v0.47.0). Without JS this never runs — body never gets
|
|
// .js-tabs, so every panel stays visible and the page reads top-to-bottom.
|
|
// The 60s auto-refresh (location.reload) preserves the hash, so the active tab
|
|
// survives a refresh; an unknown/absent hash falls back to Overview.
|
|
(function() {
|
|
var panels = document.querySelectorAll('.tab-panel');
|
|
var links = document.querySelectorAll('#tab-nav a');
|
|
if (!panels.length || !links.length) return;
|
|
document.body.classList.add('js-tabs');
|
|
var known = {};
|
|
panels.forEach(function(p) { known[p.getAttribute('data-tab')] = true; });
|
|
function currentTab() {
|
|
var m = (location.hash || '').match(/^#tab=([a-z-]+)$/);
|
|
var t = m ? m[1] : '';
|
|
if (t === 'settings') t = 'edit'; // legacy alias: the Settings tab became Edit (v0.48.0)
|
|
return known[t] ? t : 'overview';
|
|
}
|
|
function activate() {
|
|
var tab = currentTab();
|
|
panels.forEach(function(p) { p.classList.toggle('tab-panel-active', p.getAttribute('data-tab') === tab); });
|
|
links.forEach(function(a) { a.classList.toggle('active', a.getAttribute('data-tab') === tab); });
|
|
}
|
|
window.addEventListener('hashchange', activate);
|
|
activate();
|
|
})();
|
|
</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>
|
|
// Scoped auto-refresh (v0.48.0): the 60s reload fires ONLY while a live tab (the nav's
|
|
// data-live-tabs list) is active AND no form on the page is dirty. Dirty = any input/change
|
|
// anywhere (delegated listener, never reset — a reload resets it naturally). A skipped tick
|
|
// reschedules, so switching back to a live tab resumes on the next tick. The toggle +
|
|
// localStorage key + 60s cadence + default-on behavior are unchanged.
|
|
(function() {
|
|
var toggle = document.getElementById('autoRefreshToggle');
|
|
if (!toggle) return;
|
|
var key = 'hub_auto_refresh';
|
|
var enabled = localStorage.getItem(key) !== 'off';
|
|
var timer = null;
|
|
var dirty = false;
|
|
var nav = document.getElementById('tab-nav');
|
|
var liveTabs = ((nav && nav.getAttribute('data-live-tabs')) || '').split(',');
|
|
var hint = document.getElementById('refresh-paused-hint');
|
|
|
|
toggle.checked = enabled;
|
|
|
|
function activeTab() {
|
|
var m = (location.hash || '').match(/^#tab=([a-z-]+)$/);
|
|
var t = m ? m[1] : 'overview';
|
|
if (t === 'settings') t = 'edit'; // legacy alias, same as the tabs script
|
|
return t;
|
|
}
|
|
function onLiveTab() { return liveTabs.indexOf(activeTab()) !== -1; }
|
|
function updateHint() {
|
|
if (hint) hint.style.display = (!onLiveTab() || dirty) ? 'inline' : 'none';
|
|
}
|
|
function tick() {
|
|
if (toggle.checked && onLiveTab() && !dirty) { location.reload(); return; }
|
|
schedule(); // skipped (non-live tab or dirty form) — try again next tick
|
|
}
|
|
function schedule() {
|
|
clearTimeout(timer);
|
|
if (toggle.checked) timer = setTimeout(tick, 60000);
|
|
}
|
|
function markDirty(e) {
|
|
if (e.target === toggle) return; // the auto-refresh toggle itself is not form input
|
|
dirty = true;
|
|
updateHint();
|
|
}
|
|
document.addEventListener('input', markDirty);
|
|
document.addEventListener('change', markDirty);
|
|
window.addEventListener('hashchange', updateHint);
|
|
|
|
toggle.addEventListener('change', function() {
|
|
localStorage.setItem(key, this.checked ? 'on' : 'off');
|
|
updateHint();
|
|
schedule();
|
|
});
|
|
|
|
updateHint();
|
|
schedule();
|
|
})();
|
|
</script>
|
|
{{end}}
|
|
</body>
|
|
</html>
|