Files
felhom.eu/hub/internal/web/templates/customer_unified.html
T
admin 6bcbaa1574 hub v0.3.2: Show Hub version in page footers
Add hubVersion template function via closure in web.New(). Version is
passed from main.go (set via ldflags at build time) and displayed in
the footer of all 6 page templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:39:52 +01:00

709 lines
32 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">
{{if .HasReports}}<meta http-equiv="refresh" content="60">{{end}}
</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 active">Customers</a>
</nav>
<a href="/configs" class="back-link">&larr; All Customers</a>
<h1>
<span class="status-dot" style="color: {{statusColor .OverallStatus}}">{{statusIcon .OverallStatus}}</span>
{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}
</h1>
{{if .HasReports}}
<p class="subtitle">Last report: {{timeAgo .Customer.ReceivedAt}} &middot; Controller v{{.Customer.ControllerVersion}}</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 "blocked"}}Customer blocked — hidden from Dashboard.
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
{{end}}
</div>
{{end}}
{{if .IsBlocked}}
<div class="flash flash-blocked">
This customer is blocked — reports are accepted but not shown on the Dashboard.
</div>
{{end}}
<!-- Customer Info -->
<section class="card">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<h2>Customer Info</h2>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{{if .HasConfig}}
<a href="/configs/{{.CustomerID}}/edit" class="btn btn-outline btn-sm">Edit</a>
{{if .IsBlocked}}
<form method="POST" action="/customers/{{.CustomerID}}/unblock" style="display:inline">
<button type="submit" class="btn btn-sm">Unblock</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/block" style="display:inline"
onsubmit="return confirm('Block this customer? They will be hidden from the Dashboard.')">
<button type="submit" class="btn btn-outline btn-sm">Block</button>
</form>
{{end}}
<form method="POST" action="/configs/{{.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.CustomerID}}? This cannot be undone.')">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
{{else}}
<form method="POST" action="/customers/{{.CustomerID}}/create-config" style="display:inline">
<button type="submit" class="btn btn-sm">Create Config</button>
</form>
{{end}}
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span class="label">Customer ID</span>
<span class="value"><code>{{.CustomerID}}</code></span>
</div>
<div class="info-item">
<span class="label">Name</span>
<span class="value">{{if .CustomerName}}{{.CustomerName}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Domain</span>
<span class="value">{{if .Domain}}{{.Domain}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .Email}}{{.Email}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Config</span>
<span class="value">
{{if .HasConfig}}
<span class="config-badge config-badge-managed">MANAGED</span>
{{else}}
<span class="config-badge config-badge-manual">MANUAL</span>
{{end}}
{{if .IsBlocked}}<span class="config-badge config-badge-blocked">BLOCKED</span>{{end}}
</span>
</div>
{{if .HasConfig}}
<div class="info-item">
<span class="label">Config Created</span>
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
</div>
{{end}}
</div>
</section>
{{if .HasReports}}
<!-- System Info -->
<section class="card">
<h2>System</h2>
<div class="info-grid">
{{with .Report.system}}
<div class="info-item">
<span class="label">Hostname</span>
<span class="value">{{index . "hostname"}}</span>
</div>
<div class="info-item">
<span class="label">OS</span>
<span class="value">{{index . "os"}}</span>
</div>
<div class="info-item">
<span class="label">Kernel</span>
<span class="value">{{index . "kernel"}}</span>
</div>
<div class="info-item">
<span class="label">CPU</span>
<span class="value">{{index . "cpu_model"}} ({{index . "cpu_cores"}} cores)</span>
</div>
{{end}}
</div>
<div class="metrics-grid">
<div class="metric">
<span class="metric-label">CPU</span>
<span class="metric-value">{{formatFloat .Customer.CPUPercent}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.CPUPercent}}%"></div></div>
</div>
<div class="metric">
<span class="metric-label">Memory</span>
<span class="metric-value">{{formatFloat .Customer.MemoryPercent}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.MemoryPercent}}%"></div></div>
</div>
</div>
</section>
<!-- Storage -->
<section class="card">
<h2>Storage</h2>
{{with .Report.storage}}
<div class="metrics-grid">
{{range .}}
<div class="metric">
<span class="metric-label">{{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}}</span>
<span class="metric-value">{{printf "%.0f" (index . "percent")}}%</span>
<div class="bar"><div class="bar-fill" style="width: {{printf "%.0f" (index . "percent")}}%"></div></div>
<span class="metric-detail">{{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB</span>
</div>
{{end}}
</div>
{{end}}
</section>
<!-- Containers -->
<section class="card">
<h2>Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})</h2>
{{with .Report.containers}}
{{$list := index . "list"}}
{{if $list}}
<table class="container-table">
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
{{range $list}}
<tr>
<td>{{index . "name"}}</td>
<td><span class="container-state container-state-{{index . "state"}}">{{index . "state"}}</span></td>
<td>{{printf "%.1f" (index . "cpu_percent")}}%</td>
<td>{{printf "%.0f" (index . "memory_mb")}} MB</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}
</section>
<!-- Backup -->
<section class="card">
<h2>Backup</h2>
{{with .Report.backup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Enabled</span>
<span class="value">{{if index . "enabled"}}Yes{{else}}No{{end}}</span>
</div>
<div class="info-item">
<span class="label">Snapshots</span>
<span class="value">{{index . "snapshot_count"}}</span>
</div>
<div class="info-item">
<span class="label">Repo Size</span>
<span class="value">{{index . "repo_size_mb"}} MB</span>
</div>
<div class="info-item">
<span class="label">Integrity</span>
<span class="value">{{if index . "integrity_ok"}}OK{{else}}Unknown{{end}}</span>
</div>
</div>
{{end}}
</section>
<!-- Infra Backup -->
<section class="card">
<h2>Infra Backup</h2>
{{if .InfraBackup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Last Updated</span>
<span class="value">{{.InfraBackupAge}}</span>
</div>
<div class="info-item">
<span class="label">Deployed Stacks</span>
<span class="value">{{.InfraBackup.StackCount}}</span>
</div>
<div class="info-item">
<span class="label">Disks</span>
<span class="value">{{.InfraBackup.DiskCount}}</span>
</div>
</div>
{{else}}
<p style="color: #facc15">No infra backup received yet</p>
{{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 if eq .OverallStatus "blocked"}}
<p class="health-status health-status-disabled">Customer is blocked</p>
{{else}}
{{with .Report.health}}
<p class="health-status health-status-{{index . "status"}}">
Status: {{index . "status"}}
</p>
{{$issues := index . "issues"}}
{{if $issues}}
<h3>Issues</h3>
<ul class="issue-list">
{{range $issues}}
<li class="issue">{{.}}</li>
{{end}}
</ul>
{{end}}
{{$warnings := index . "warnings"}}
{{if $warnings}}
<h3>Warnings</h3>
<ul class="warning-list">
{{range $warnings}}
<li class="warning">{{.}}</li>
{{end}}
</ul>
{{end}}
{{end}}
{{end}}
</section>
{{else}}
<!-- No reports yet -->
{{if .HasConfig}}
<section class="card">
<h2>Waiting for First Report</h2>
<p class="text-muted">This customer has been configured but no controller report has been received yet.</p>
<p class="text-muted" style="margin-top: 0.5rem;">Use one of the setup commands below to deploy the controller on the customer node.</p>
</section>
{{end}}
{{end}}
<!-- Config Management -->
{{if .HasConfig}}
<section class="card">
<h2>Credentials</h2>
<div class="credential-row">
<div>
<span class="label">Retrieval Password</span>
<div class="credential-box">
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">&#x2398;</button>
</div>
</div>
<form method="POST" action="/configs/{{.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
<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">&#x2398;</button>
</div>
</div>
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
</div>
</section>
<section class="card">
<h2>Setup 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 {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">&#x2398;</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/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">&#x2398;</button>
</div>
</section>
<section class="card">
<h2>YAML Preview</h2>
<div id="yaml-preview" class="yaml-preview">
<p class="text-muted">Loading preview...</p>
</div>
</section>
{{end}}
{{if .HasReports}}
<!-- Controller Update -->
<section class="card">
<h2>Controller Update</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Current version</span>
<span class="value">v{{.Customer.ControllerVersion}}</span>
</div>
{{if .LatestVersion}}
<div class="info-item">
<span class="label">Latest version</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 .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;">&#x2713; In sync</span>
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">&#x26A0; 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 — no infra backup available yet</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}}
</section>
<!-- Events -->
<section class="card">
<h2>Events
{{if .EventCounts}}
{{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>
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
</div>
{{if .NotifPrefs}}
<div class="info-item">
<span class="label">Events</span>
<span class="value">{{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}}</span>
</div>
{{end}}
</div>
{{if .RecentNotifications}}
<h3>Recent (last 10)</h3>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Channel</th>
<th>Event</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{{range .RecentNotifications}}
<tr>
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
<td><span class="status-badge status-badge-{{.Channel}}">{{.Channel}}</span></td>
<td>{{.EventType}}</td>
<td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
<td>{{.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</section>
<!-- Report History -->
{{if .History}}
<section class="card">
<h2>Report History (last 24h)</h2>
<details>
<summary>{{len .History}} reports</summary>
<table class="history-table">
<thead>
<tr>
<th>Time</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
{{range .History}}
<tr>
<td>{{.ReceivedAt.Format "Jan 02 15:04"}}</td>
<td><span class="status-badge status-badge-{{.HealthStatus}}">{{.HealthStatus}}</span></td>
<td>{{formatFloat .CPUPercent}}%</td>
<td>{{formatFloat .MemoryPercent}}%</td>
</tr>
{{end}}
</tbody>
</table>
</details>
</section>
{{end}}
{{end}}
<footer>
{{if .HasReports}}<p>Auto-refreshes every 60 seconds &middot; {{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 = '&#x2713;';
setTimeout(function() { btn.innerHTML = orig; }, 1500);
});
}
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'})
.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'})
.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'})
.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')
.then(function(r) { return r.text(); })
.then(function(yaml) {
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</pre>';
})
.catch(function() {
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
});
{{end}}
</script>
</body>
</html>