- self-updater.js: polls for new versions, downloads/verifies tarballs, triggers host-side rebuild via systemd path unit - dashcaddy-update.sh + systemd units: host-side container rebuild with automatic rollback on health check failure - 7 new /api/v1/system/* endpoints for version info, update check/apply, rollback, and update history - Frontend: DashCaddy tab in Updates modal with version display, changelog, update button, rollback, and notification dot - install.sh: updater service installation, volume mounts, env vars - build-release.sh + webhook-handler.js: release server pipeline (Gitea webhook → build tarball → deploy to get.dashcaddy.net) - Dockerfile: DASHCADDY_COMMIT build arg → VERSION file - Version bump to 1.1.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
491 lines
25 KiB
JavaScript
491 lines
25 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>
|
|
<button class="panel-tab" data-panel="updates-dashcaddy" id="updates-dashcaddy-tab">DashCaddy</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>
|
|
|
|
<!-- Tab: DashCaddy Self-Update -->
|
|
<div id="updates-dashcaddy" class="panel-section">
|
|
<div id="dashcaddy-version-info" style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: var(--bg);">
|
|
<div style="flex: 1;">
|
|
<div style="font-weight: 600; font-size: 1rem;">DashCaddy</div>
|
|
<div id="dashcaddy-current-version" style="color: var(--muted); font-size: 0.85rem;">Loading...</div>
|
|
</div>
|
|
<div id="dashcaddy-update-badge" style="display: none; padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);">Update available</div>
|
|
</div>
|
|
<div id="dashcaddy-update-details" style="display: none; margin-bottom: 16px; padding: 12px; border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
<span style="font-weight: 600;">New version: <span id="dashcaddy-new-version"></span></span>
|
|
<button id="dashcaddy-apply-btn" class="btn-accent-solid" style="padding: 6px 16px; font-size: 0.85rem;">Update Now</button>
|
|
</div>
|
|
<div id="dashcaddy-changelog" style="font-size: 0.8rem; color: var(--muted); max-height: 120px; overflow-y: auto; white-space: pre-wrap; font-family: var(--font-mono); line-height: 1.5;"></div>
|
|
</div>
|
|
<div id="dashcaddy-status-bar" style="display: none; margin-bottom: 16px; padding: 10px 12px; border-radius: 8px; font-size: 0.85rem;"></div>
|
|
<div style="margin-bottom: 12px;">
|
|
<button id="dashcaddy-check-btn" style="padding: 6px 14px; font-size: 0.82rem;">Check for Updates</button>
|
|
<button id="dashcaddy-rollback-btn" style="padding: 6px 14px; font-size: 0.82rem; margin-left: 6px;">Rollback</button>
|
|
</div>
|
|
<div id="dashcaddy-history-container" style="max-height: 250px; overflow-y: auto;">
|
|
<div class="panel-empty"><span class="empty-icon">📦</span>No self-update history.</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>`;
|
|
}
|
|
}
|
|
|
|
// ===== DASHCADDY SELF-UPDATE =====
|
|
|
|
const dcVersionInfo = document.getElementById('dashcaddy-current-version');
|
|
const dcUpdateBadge = document.getElementById('dashcaddy-update-badge');
|
|
const dcUpdateDetails = document.getElementById('dashcaddy-update-details');
|
|
const dcNewVersion = document.getElementById('dashcaddy-new-version');
|
|
const dcChangelog = document.getElementById('dashcaddy-changelog');
|
|
const dcApplyBtn = document.getElementById('dashcaddy-apply-btn');
|
|
const dcCheckBtn = document.getElementById('dashcaddy-check-btn');
|
|
const dcRollbackBtn = document.getElementById('dashcaddy-rollback-btn');
|
|
const dcStatusBar = document.getElementById('dashcaddy-status-bar');
|
|
const dcHistoryContainer = document.getElementById('dashcaddy-history-container');
|
|
|
|
let dcLastCheck = null;
|
|
|
|
function dcShowStatus(msg, type) {
|
|
if (!dcStatusBar) return;
|
|
dcStatusBar.style.display = 'block';
|
|
dcStatusBar.style.background = type === 'error' ? 'var(--bad-bg)' : type === 'success' ? 'var(--ok-bg)' : 'var(--bg)';
|
|
dcStatusBar.style.color = type === 'error' ? 'var(--bad-fg)' : type === 'success' ? 'var(--ok-fg)' : 'var(--fg)';
|
|
dcStatusBar.textContent = msg;
|
|
}
|
|
|
|
async function dcLoadVersion() {
|
|
try {
|
|
const res = await fetch('/api/v1/system/version');
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
dcVersionInfo.textContent = 'v' + data.version + (data.commit ? ' (' + data.commit.substring(0, 7) + ')' : '');
|
|
}
|
|
} catch (_) {
|
|
dcVersionInfo.textContent = 'Unable to fetch version';
|
|
}
|
|
}
|
|
|
|
async function dcCheckForUpdate(silent) {
|
|
if (!silent) {
|
|
dcCheckBtn.textContent = 'Checking...';
|
|
dcCheckBtn.disabled = true;
|
|
}
|
|
try {
|
|
const res = await fetch('/api/v1/system/update-check');
|
|
const data = await res.json();
|
|
dcLastCheck = data;
|
|
|
|
if (data.success && data.available && data.remote) {
|
|
dcUpdateBadge.style.display = '';
|
|
dcUpdateDetails.style.display = '';
|
|
dcNewVersion.textContent = 'v' + data.remote.version;
|
|
dcChangelog.textContent = data.remote.changelog || 'No changelog available.';
|
|
|
|
// Add notification dot to the Updates button
|
|
const updatesBtn = document.getElementById('updates-btn');
|
|
if (updatesBtn && !updatesBtn.querySelector('.update-dot')) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'update-dot';
|
|
dot.style.cssText = 'position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);';
|
|
updatesBtn.style.position = 'relative';
|
|
updatesBtn.appendChild(dot);
|
|
}
|
|
// Also add dot to the DashCaddy tab
|
|
const dcTab = document.getElementById('updates-dashcaddy-tab');
|
|
if (dcTab && !dcTab.querySelector('.update-dot')) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'update-dot';
|
|
dot.style.cssText = 'display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;';
|
|
dcTab.appendChild(dot);
|
|
}
|
|
} else {
|
|
dcUpdateBadge.style.display = 'none';
|
|
dcUpdateDetails.style.display = 'none';
|
|
if (!silent) dcShowStatus('You are running the latest version.', 'success');
|
|
}
|
|
|
|
if (!silent) {
|
|
dcCheckBtn.textContent = 'Check for Updates';
|
|
dcCheckBtn.disabled = false;
|
|
}
|
|
} catch (e) {
|
|
if (!silent) {
|
|
dcShowStatus('Failed to check: ' + e.message, 'error');
|
|
dcCheckBtn.textContent = 'Check for Updates';
|
|
dcCheckBtn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function dcApplyUpdate() {
|
|
if (!confirm('Apply DashCaddy update? The API container will restart.')) return;
|
|
dcApplyBtn.textContent = 'Updating...';
|
|
dcApplyBtn.disabled = true;
|
|
dcShowStatus('Downloading and applying update...', 'info');
|
|
try {
|
|
const res = await secureFetch('/api/v1/system/update-apply', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
dcShowStatus('Update initiated: v' + (data.fromVersion || '?') + ' → v' + (data.toVersion || '?') + '. The container will restart shortly.', 'success');
|
|
dcApplyBtn.textContent = 'Applied!';
|
|
// Remove notification dots
|
|
document.querySelectorAll('.update-dot').forEach(d => d.remove());
|
|
} else {
|
|
throw new Error(data.error || 'Update failed');
|
|
}
|
|
} catch (e) {
|
|
dcShowStatus('Update failed: ' + e.message, 'error');
|
|
dcApplyBtn.textContent = 'Update Now';
|
|
dcApplyBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function dcLoadHistory() {
|
|
try {
|
|
const res = await fetch('/api/v1/system/update-history');
|
|
const data = await res.json();
|
|
const history = data.success && data.history ? data.history : [];
|
|
if (history.length === 0) {
|
|
dcHistoryContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📦</span>No self-update history.</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;">Version</th><th style="padding: 6px; text-align: left;">From</th><th style="padding: 6px; text-align: left;">Status</th></tr>';
|
|
for (const h of history) {
|
|
const st = h.status === 'success' ? '✓ success' : h.status === 'pending' ? '⏳ pending' : h.status === 'partial' ? '⚠ partial' : '✗ ' + h.status;
|
|
const stColor = h.status === 'success' ? 'var(--ok-fg)' : h.status === 'pending' ? 'var(--muted)' : 'var(--bad-fg)';
|
|
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;">v' + escapeHtml(h.version) + (h.rollback ? ' (rollback)' : '') + '</td>';
|
|
html += '<td style="padding: 6px; color: var(--muted);">v' + escapeHtml(h.fromVersion || '?') + '</td>';
|
|
html += '<td style="padding: 6px;"><span style="color: ' + stColor + ';">' + st + '</span></td>';
|
|
html += '</tr>';
|
|
if (h.error) {
|
|
html += '<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">' + escapeHtml(h.error) + '</td></tr>';
|
|
}
|
|
if (h.note) {
|
|
html += '<tr><td colspan="4" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--muted);">' + escapeHtml(h.note) + '</td></tr>';
|
|
}
|
|
}
|
|
html += '</table>';
|
|
dcHistoryContainer.innerHTML = html;
|
|
} catch (e) {
|
|
dcHistoryContainer.innerHTML = '<div class="panel-empty" style="color: var(--bad-fg);">Failed: ' + escapeHtml(e.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
async function dcShowRollback() {
|
|
try {
|
|
const res = await fetch('/api/v1/system/rollback-versions');
|
|
const data = await res.json();
|
|
const versions = data.success && data.versions ? data.versions : [];
|
|
if (versions.length === 0) {
|
|
showNotification('No rollback versions available.', 'info');
|
|
return;
|
|
}
|
|
const version = prompt('Available rollback versions:\n' + versions.join('\n') + '\n\nEnter version to rollback to:');
|
|
if (!version) return;
|
|
if (!versions.includes(version)) {
|
|
showNotification('Invalid version: ' + version, 'error');
|
|
return;
|
|
}
|
|
if (!confirm('Rollback DashCaddy to v' + version + '? The container will restart.')) return;
|
|
dcShowStatus('Rolling back to v' + version + '...', 'info');
|
|
const r = await secureFetch('/api/v1/system/rollback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ version })
|
|
});
|
|
const d = await r.json();
|
|
if (d.success) {
|
|
dcShowStatus('Rollback to v' + version + ' initiated. Container will restart.', 'success');
|
|
} else {
|
|
throw new Error(d.error || 'Rollback failed');
|
|
}
|
|
} catch (e) {
|
|
dcShowStatus('Rollback failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false));
|
|
dcApplyBtn?.addEventListener('click', dcApplyUpdate);
|
|
dcRollbackBtn?.addEventListener('click', dcShowRollback);
|
|
|
|
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);
|
|
document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener('click', () => {
|
|
dcLoadVersion();
|
|
dcLoadHistory();
|
|
if (!dcLastCheck) dcCheckForUpdate(true);
|
|
});
|
|
|
|
// Non-blocking check on page load — just adds notification dot if update available
|
|
setTimeout(() => dcCheckForUpdate(true), 5000);
|
|
})();
|