hub v0.3.1: Config diff display + pull config

Replace broken SHA256 hash comparison with value-based YAML comparison.
Add "Show Diff" button showing per-key differences in a color-coded table.
Add "Pull Config" to import controller's current config into the Hub.
New endpoints: GET /customers/{id}/config-diff, POST /customers/{id}/pull-config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 19:26:53 +01:00
parent 3217cb4751
commit 11428659d1
6 changed files with 501 additions and 34 deletions
@@ -375,11 +375,13 @@
<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 — Hub config differs from controller</span>
{{else}}<span style="color: #94a3b8;">Unknown — controller not reporting config hash (update controller)</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}}
@@ -395,6 +397,9 @@
<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>
@@ -610,6 +615,83 @@
});
}
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')