Files
dashcaddy/status/js/update-management.js
Sami 52577b11ed Fix 7 frontend security vulnerabilities (4 critical, 3 high)
- Escape all innerHTML assignments with user/external data across 12 JS files
- Upgrade credential encryption: per-value IV, key moved to sessionStorage
- Fix open redirect in TOTP auth via proper URL hostname validation
- Remove sensitive DNS topology data from localStorage cache
- Add security regression test suite (51 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:29:04 -08:00

275 lines
14 KiB
JavaScript

// ========== UPDATE MANAGEMENT ==========
(function() {
// Inject modal HTML
injectModal('updates-modal', `<div id="updates-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>⬆️ Update Management</h3>
<p class="modal-subtitle">
Check for container image updates, apply them, and manage rollbacks.
</p>
<div class="panel-tabs">
<button class="panel-tab active" data-panel="updates-available">Available</button>
<button class="panel-tab" data-panel="updates-history">History</button>
<button class="panel-tab" data-panel="updates-auto">Auto-Update</button>
</div>
<!-- Tab: Available Updates -->
<div id="updates-available" class="panel-section active">
<div style="margin-bottom: 12px;">
<button id="updates-check-btn" class="btn-accent-solid">🔍 Check for Updates</button>
</div>
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
<div class="panel-empty"><span class="empty-icon">📦</span> Click "Check for Updates" to scan containers.</div>
</div>
</div>
<!-- Tab: History -->
<div id="updates-history" class="panel-section">
<div id="updates-history-container" class="scroll-container">
<div class="panel-empty"><span class="brand-spinner"></span> Loading update history...</div>
</div>
</div>
<!-- Tab: Auto-Update -->
<div id="updates-auto" class="panel-section">
<div id="updates-auto-container" class="scroll-container">
<div class="panel-empty"><span class="empty-icon">🤖</span> Loading auto-update configuration...</div>
</div>
</div>
<div class="panel-bottom-bar">
<span id="updates-last-check" class="text-auto-right"></span>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('updates-modal');
const openBtn = document.getElementById('updates-btn');
const cancelBtn = document.getElementById('updates-cancel');
const checkBtn = document.getElementById('updates-check-btn');
const availableContainer = document.getElementById('updates-available-container');
const historyContainer = document.getElementById('updates-history-container');
const autoContainer = document.getElementById('updates-auto-container');
const lastCheckSpan = document.getElementById('updates-last-check');
async function loadAvailable() {
try {
const res = await fetch('/api/v1/updates/available');
const data = await res.json();
if (!data.success) throw new Error(data.error);
const updates = data.updates || [];
if (updates.length === 0) {
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
lastCheckSpan.textContent = '';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
for (const u of updates) {
html += `<tr style="border-bottom: 1px solid var(--border);">`;
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`;
html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`;
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`;
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.latestDigest)}</code></td>`;
html += `<td style="padding: 8px; text-align: right;">`;
html += `<button class="update-now-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`;
html += `<button class="rollback-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`;
html += '</td></tr>';
}
html += '</table>';
availableContainer.innerHTML = html;
lastCheckSpan.textContent = updates.length + ' update(s) available';
// Wire update buttons
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const name = btn.dataset.name;
if (!confirm(`Update "${name}" to the latest version? The container will restart.`)) return;
btn.textContent = 'Updating...';
btn.disabled = true;
try {
const r = await secureFetch(`/api/v1/updates/update/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoRollback: true })
});
const d = await r.json();
if (d.success) {
btn.textContent = 'Done!';
btn.style.background = 'var(--ok-fg)';
setTimeout(() => loadAvailable(), 2000);
} else {
throw new Error(d.error || 'Update failed');
}
} catch (e) {
btn.textContent = 'Failed';
btn.style.color = 'var(--bad-fg)';
showNotification('Update error: ' + e.message, 'error');
setTimeout(() => { btn.textContent = 'Update'; btn.disabled = false; btn.style.color = ''; btn.style.background = ''; }, 3000);
}
});
});
// Wire rollback buttons
availableContainer.querySelectorAll('.rollback-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const name = btn.dataset.name;
if (!confirm(`Rollback "${name}" to its previous version?`)) return;
btn.textContent = 'Rolling back...';
btn.disabled = true;
try {
const r = await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(id)}`, { method: 'POST' });
const d = await r.json();
if (d.success) {
btn.textContent = 'Rolled back!';
setTimeout(() => loadAvailable(), 2000);
} else {
throw new Error(d.error || 'Rollback failed');
}
} catch (e) {
btn.textContent = 'Failed';
showNotification('Rollback error: ' + e.message, 'error');
setTimeout(() => { btn.textContent = 'Rollback'; btn.disabled = false; }, 3000);
}
});
});
} catch (e) {
availableContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
async function checkForUpdates() {
checkBtn.textContent = '🔍 Checking...';
checkBtn.disabled = true;
try {
const res = await secureFetch('/api/v1/updates/check', { method: 'POST' });
const data = await res.json();
if (!data.success) throw new Error(data.error);
checkBtn.textContent = '✅ Done!';
await loadAvailable();
} catch (e) {
checkBtn.textContent = '❌ Failed';
showNotification('Check error: ' + e.message, 'error');
}
setTimeout(() => { checkBtn.textContent = '🔍 Check for Updates'; checkBtn.disabled = false; }, 3000);
}
async function loadHistory() {
try {
historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
const res = await fetch('/api/v1/updates/history?limit=50');
const data = await res.json();
const history = data.success && data.history ? data.history : [];
if (history.length === 0) {
historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span>No update history yet.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">Status</th></tr>';
for (const h of history) {
const ok = h.status === 'success';
const dur = h.duration ? (h.duration < 1000 ? h.duration + 'ms' : Math.round(h.duration / 1000) + 's') : '-';
html += `<tr style="border-bottom: 1px solid var(--border);">`;
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(h.timestamp)}</td>`;
html += `<td style="padding: 6px; font-weight: 500;">${escapeHtml(h.containerName)}</td>`;
html += `<td style="padding: 6px; color: var(--muted);">${escapeHtml(h.imageName)}</td>`;
html += `<td style="padding: 6px;">${dur}</td>`;
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓ success' : '✗ failed'}</span></td>`;
html += '</tr>';
if (!ok && h.error) {
html += `<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${escapeHtml(h.error)}</td></tr>`;
}
}
html += '</table>';
historyContainer.innerHTML = html;
} catch (e) {
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
async function loadAutoConfig() {
try {
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
// Get running containers to show auto-update toggles
const res = await fetch('/api/v1/stats/containers');
const data = await res.json();
const containers = data.success && data.stats ? data.stats : [];
if (containers.length === 0) {
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
return;
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Auto-Rollback</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
for (const c of containers) {
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
const cid = c.containerId || c.Id;
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${escapeHtml(cid)}">`;
html += `<td style="padding: 8px; font-weight: 500;">${escapeHtml(name)}</td>`;
html += `<td style="padding: 8px;">
<select class="auto-schedule" data-id="${escapeHtml(cid)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value="">Disabled</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select></td>`;
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(cid)}" checked /></td>`;
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(cid)}" data-name="${escapeHtml(name)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
html += '</tr>';
}
html += '</table>';
autoContainer.innerHTML = html;
// Wire save buttons
autoContainer.querySelectorAll('.save-auto-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const row = btn.closest('tr');
const schedule = row.querySelector('.auto-schedule').value;
const rollback = row.querySelector('.auto-rollback').checked;
btn.textContent = 'Saving...';
btn.disabled = true;
try {
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback })
});
const d = await r.json();
if (d.success) {
btn.textContent = '✓ Saved';
} else {
throw new Error(d.error);
}
} catch (e) {
btn.textContent = '✗ Error';
showNotification('Save error: ' + e.message, 'error');
}
setTimeout(() => { btn.textContent = 'Save'; btn.disabled = false; }, 2000);
});
});
} catch (e) {
autoContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(e.message)}</div>`;
}
}
checkBtn?.addEventListener('click', checkForUpdates);
openBtn?.addEventListener('click', () => {
modal?.classList.add('show');
loadAvailable();
});
wireModal(modal, cancelBtn);
// Lazy-load tabs
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
})();