hub v0.26.0: pull-based config delivery + retire inbound GUI controls
config_version counter (bumped on every config save) advertised in the report ACK; controller re-pulls + self-restarts on a change. Retire Trigger Update / Push Config / Pull Config / Show Diff handlers+routes+buttons and the inbound geo-notify (keep hub->Cloudflare geo removal). Setup command -> host-install; delete dead customer.html + config_detail.html. Closes AUDIT-hub-gui F-S1/F-S4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
@@ -1,155 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Hub — {{.Config.CustomerID}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<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="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="/configs" class="back-link">← All customers</a>
|
||||
|
||||
{{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.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">
|
||||
<code>{{.Config.CustomerID}}</code>
|
||||
{{if .Config.CustomerName}}<span class="text-muted" style="font-weight: 400;"> — {{.Config.CustomerName}}</span>{{end}}
|
||||
</h2>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/configs/{{.Config.CustomerID}}/edit" class="btn btn-outline">Edit</a>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.Config.CustomerID}}? This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Customer Details</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Customer ID</span>
|
||||
<span class="value"><code>{{.Config.CustomerID}}</code></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Name</span>
|
||||
<span class="value">{{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Domain</span>
|
||||
<span class="value">{{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Email</span>
|
||||
<span class="value">{{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Updated</span>
|
||||
<span class="value">{{timeAgo .Config.UpdatedAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Credentials</h2>
|
||||
<div class="credential-row">
|
||||
<div>
|
||||
<span class="label">Retrieval Password</span>
|
||||
<div class="credential-box">
|
||||
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
|
||||
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Setup Commands</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
|
||||
|
||||
<h3>Option 1: docker-setup.sh (recommended)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>YAML Preview</h2>
|
||||
<div id="yaml-preview" class="yaml-preview">
|
||||
<p class="text-muted">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub {{hubVersion}} — Customer Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el.textContent || el.innerText;
|
||||
navigator.clipboard.writeText(text.trim()).then(function() {
|
||||
const btn = el.parentElement.querySelector('.copy-btn');
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '✓';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Load YAML preview
|
||||
fetch('/configs/{{.Config.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>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,323 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Customer.CustomerName}} — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
</head>
|
||||
<body>
|
||||
<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">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
<h1>
|
||||
<span class="status-dot" style="color: {{statusColor .OverallStatus}}">{{statusIcon .OverallStatus}}</span>
|
||||
{{.Customer.CustomerName}}
|
||||
</h1>
|
||||
<p class="subtitle">Last report: {{timeAgo .Customer.ReceivedAt}} · Controller {{.Customer.ControllerVersion}}</p>
|
||||
</header>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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}}
|
||||
{{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>
|
||||
|
||||
<!-- 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: #4ade80; margin-left: 0.3em;">● update available</span>
|
||||
{{else}}
|
||||
<span style="color: #94a3b8; 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: #60a5fa;">{{.ControllerURL}}</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
{{else if and .ControllerURL (not .LatestVersion)}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function triggerControllerUpdate(customerID) {
|
||||
if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return;
|
||||
var btn = document.getElementById('btn-trigger-update');
|
||||
var msg = document.getElementById('update-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Triggering...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Update triggered — controller restarting';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Connection error';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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>Event</th>
|
||||
<th>Status</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentNotifications}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</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 (last 24h) -->
|
||||
{{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}}
|
||||
|
||||
<footer>
|
||||
<p>Auto-refreshes every 60 seconds · <a href="/">Felhom Hub</a> {{hubVersion}}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -380,16 +380,22 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Setup Commands</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
|
||||
<h2>Setup Command</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
||||
Day-0 host bootstrap. 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>
|
||||
|
||||
<h3>Option 1: docker-setup.sh (recommended)</h3>
|
||||
<h3>Option 1: Host install (recommended)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
|
||||
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
|
||||
<h3 style="margin-top: 1rem;">Option 2: 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.</p>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
||||
@@ -456,43 +462,12 @@
|
||||
<button class="btn btn-outline btn-sm" type="submit">Save floor</button>
|
||||
<span style="font-size: 0.8em; color: #94a3b8;">Boxes below the effective floor auto-update on their next report. Blank clears the override.</span>
|
||||
</form>
|
||||
|
||||
{{if and .HasConfig .ConfigSyncStatus}}
|
||||
<div class="info-item" style="margin-top: 0.5rem;">
|
||||
<span class="label">Config Sync</span>
|
||||
<span class="value">
|
||||
{{if eq .ConfigSyncStatus "in_sync"}}<span style="color: #22c55e;">✓ In sync</span>
|
||||
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">⚠ Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}}</span>
|
||||
<button class="btn btn-outline btn-sm" style="margin-left: 0.5em; font-size: 0.8em;" onclick="showConfigDiff('{{.CustomerID}}')">Show Diff</button>
|
||||
{{else}}<span style="color: #94a3b8;">Unknown — use "Show Diff" to compare live</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div id="config-diff-container" style="display: none; margin-top: 0.5rem;"></div>
|
||||
{{end}}
|
||||
<div style="margin-top: 0.75em; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
<button class="btn btn-sm" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
{{else if and .ControllerURL (not .LatestVersion)}}
|
||||
<button class="btn btn-sm" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
{{end}}
|
||||
{{if and .HasConfig .ControllerURL}}
|
||||
<button class="btn btn-outline btn-sm" id="btn-push-config" onclick="pushConfig('{{.CustomerID}}')">
|
||||
Push Config
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" id="btn-pull-config" onclick="pullConfig('{{.CustomerID}}')">
|
||||
Pull Config
|
||||
</button>
|
||||
{{end}}
|
||||
<span id="action-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
{{if and .ControllerURL (not .LatestVersion)}}
|
||||
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
||||
{{end}}
|
||||
<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 -->
|
||||
@@ -734,146 +709,6 @@
|
||||
btn.textContent = 'Összes geo-korlátozás eltávolítása';
|
||||
});
|
||||
}
|
||||
|
||||
function triggerControllerUpdate(customerID) {
|
||||
if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return;
|
||||
var btn = document.getElementById('btn-trigger-update');
|
||||
var msg = document.getElementById('action-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Triggering...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Update triggered — controller restarting';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Connection error';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Trigger Update';
|
||||
});
|
||||
}
|
||||
|
||||
function pushConfig(customerID) {
|
||||
if (!confirm('Push the Hub configuration to this controller?\n\nThe controller will apply the new config.')) return;
|
||||
var btn = document.getElementById('btn-push-config');
|
||||
var msg = document.getElementById('action-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Pushing...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Config pushed successfully';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Push Config';
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Connection error';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Push Config';
|
||||
});
|
||||
}
|
||||
|
||||
function pullConfig(customerID) {
|
||||
if (!confirm('Import the controller\'s current config into the Hub?\n\nThis updates the Hub\'s stored configuration to match the controller.')) return;
|
||||
var btn = document.getElementById('btn-pull-config');
|
||||
var msg = document.getElementById('action-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Pulling...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
msg.textContent = 'Config imported successfully';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#4ade80';
|
||||
setTimeout(function() { location.reload(); }, 1500);
|
||||
} else {
|
||||
msg.textContent = data.error || 'Failed';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Pull Config';
|
||||
})
|
||||
.catch(function() {
|
||||
msg.textContent = 'Connection error';
|
||||
msg.style.display = 'inline';
|
||||
msg.style.color = '#f87171';
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Pull Config';
|
||||
});
|
||||
}
|
||||
|
||||
function showConfigDiff(customerID) {
|
||||
var container = document.getElementById('config-diff-container');
|
||||
if (container.style.display !== 'none') {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<p class="text-muted">Loading diff...</p>';
|
||||
container.style.display = 'block';
|
||||
fetch('/customers/' + customerID + '/config-diff')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
container.innerHTML = '<p style="color: #f87171;">' + (data.error || 'Failed to load diff') + '</p>';
|
||||
return;
|
||||
}
|
||||
if (data.in_sync) {
|
||||
container.innerHTML = '<p style="color: #22c55e;">Configs are in sync (no differences found).</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="data-table" style="font-size: 0.85em;">';
|
||||
html += '<thead><tr><th>Key</th><th>Hub Value</th><th>Controller Value</th><th>Status</th></tr></thead><tbody>';
|
||||
data.diffs.forEach(function(d) {
|
||||
var cls = 'diff-' + d.status;
|
||||
var statusLabel = d.status === 'changed' ? 'Changed' : d.status === 'hub_only' ? 'Hub only' : 'Controller only';
|
||||
html += '<tr class="' + cls + '">';
|
||||
html += '<td style="font-family: monospace; white-space: nowrap;">' + escHtml(d.key) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.hub) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.controller) + '</td>';
|
||||
html += '<td>' + statusLabel + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function() {
|
||||
container.innerHTML = '<p style="color: #f87171;">Failed to fetch diff from controller.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(s));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
{{if .HasConfig}}
|
||||
// Load YAML preview
|
||||
fetch('/configs/{{.CustomerID}}/preview')
|
||||
|
||||
Reference in New Issue
Block a user