diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 8467086..1b1a700 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -10,9 +10,10 @@ const { success } = require('../response-helpers'); * @param {Object} deps.docker - Docker client wrapper (client, pull methods) * @param {Object} deps.log - Logger instance * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {Object} deps.workflowEngine - WorkflowEngine instance (optional) * @returns {express.Router} */ -module.exports = function({ docker, log, asyncHandler }) { +module.exports = function({ docker, log, asyncHandler, workflowEngine }) { const router = express.Router(); // Helper: verify container exists before operating on it @@ -66,6 +67,11 @@ module.exports = function({ docker, log, asyncHandler }) { log.info('docker', `Pulling latest image: ${imageName}`); await docker.pull(imageName); + // Trigger pre-update workflow (backup before update) + if (workflowEngine) { + try { await workflowEngine.triggerEvent('pre-update', { containerId: containerId, containerName, imageName }); } catch (w) { log.warn('workflow', 'pre-update trigger failed: ' + w.message); } + } + // Get current container config for recreation const hostConfig = containerInfo.HostConfig; const config = { @@ -135,10 +141,15 @@ module.exports = function({ docker, log, asyncHandler }) { } success(res, { - message: `Container ${containerName} updated successfully`, - newContainerId: newContainerInfo.Id - }); - }, 'container-update')); + message: `Container ${containerName} updated successfully`, + newContainerId: newContainerInfo.Id + }); + + // Trigger post-update workflow + if (workflowEngine) { + try { await workflowEngine.triggerEvent('post-update', { containerId: containerId, containerName, imageName, newContainerId: newContainerInfo.Id }); } catch (w) { log.warn('workflow', 'post-update trigger failed: ' + w.message); } + } + }, 'container-update')); // Check for available updates (compares local and remote image digests) router.get('/:id/check-update', asyncHandler(async (req, res) => { diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index f3e2c6c..82ecf7e 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -384,7 +384,8 @@ async function createApp() { apiRouter.use('/containers', containerRoutes({ docker: ctx.docker, log: ctx.log, - asyncHandler: ctx.asyncHandler + asyncHandler: ctx.asyncHandler, + workflowEngine: ctx.workflowEngine })); apiRouter.use(serviceRoutes({ servicesStateManager: ctx.servicesStateManager, diff --git a/status/js/card-badges.js b/status/js/card-badges.js index a854b92..b6aed3f 100644 --- a/status/js/card-badges.js +++ b/status/js/card-badges.js @@ -41,8 +41,11 @@ dismissedUpdates = new Set(); } + // Track global update state for cross-component access + let knownUpdates = []; + // Fetch update data and show badges - async function refreshCardUpdates() { + async function refreshCardUpdates(notifyNew) { try { const res = await fetch('/api/v1/updates/available'); const data = await res.json(); @@ -51,9 +54,21 @@ // Clear all update badges first document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible')); - if (!data.updates?.length) return; + const updates = data.updates || []; + knownUpdates = updates; // store globally - for (const upd of data.updates) { + // Notify if new updates appeared (periodic check with notification) + if (notifyNew && updates.length > 0) { + const prev = window._lastKnownUpdateCount || 0; + if (prev > 0 && updates.length > prev) { + showNotification(`${updates.length} container update(s) available — click Update Management to review.`, 'info'); + } + window._lastKnownUpdateCount = updates.length; + } + + if (!updates.length) return; + + for (const upd of updates) { // Try to match by container name to service id const apps = window.APPS || []; for (const app of apps) { @@ -61,17 +76,24 @@ // Skip dismissed updates if (dismissedUpdates.has(app.id)) break; const badge = document.getElementById('update-badge-' + app.id); + const updateBtn = document.getElementById('update-btn-' + app.id); if (badge) { badge.classList.add('visible'); - badge.title = `Image digest changed. Click to dismiss if already up to date.\n${upd.imageName || ''}`; + badge.title = `Update available — click to open Update Management.`; badge.style.cursor = 'pointer'; badge.onclick = (e) => { e.stopPropagation(); - badge.classList.remove('visible'); - dismissedUpdates.add(app.id); - safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates])); + // Open Update Management modal focused on this app + if (window.openUpdateModal) window.openUpdateModal(app.id); }; } + // Highlight update button if update is available + if (updateBtn) { + updateBtn.style.background = '#f97316'; + updateBtn.style.borderColor = '#f97316'; + updateBtn.style.boxShadow = '0 0 6px #f9731688'; + updateBtn.title = `Update available — click to open Update Management.`; + } break; } } @@ -90,10 +112,10 @@ refreshCardUpdates(); }, 5000); - // Periodic refresh every 60 seconds + // Periodic refresh every 60 seconds — notify on new updates detected setInterval(() => { refreshCardHealth(); - refreshCardUpdates(); + refreshCardUpdates(true); // true = notify if new updates found }, 60000); } diff --git a/status/js/update-management.js b/status/js/update-management.js index 51adf08..b7fc664 100644 --- a/status/js/update-management.js +++ b/status/js/update-management.js @@ -17,8 +17,10 @@
-
+
+ +
📦 Click "Check for Updates" to scan containers.
@@ -94,13 +96,24 @@ if (updates.length === 0) { availableContainer.innerHTML = '
All containers are up to date.
'; lastCheckSpan.textContent = ''; + document.getElementById('updates-update-all-btn').style.display = 'none'; + document.getElementById('updates-count-badge').style.display = 'none'; + window._pendingUpdates = []; return; } let html = ''; html += ''; for (const u of updates) { - html += ``; + // Match app by containerId first, then name + const appId = (() => { + const apps = window.APPS || []; + for (const a of apps) { + if (a.containerId === u.containerId || a.name === u.containerName || a.id === u.containerName) return a.id; + } + return u.containerName; + })(); + html += ``; html += ``; html += ``; html += ``; @@ -114,6 +127,20 @@ availableContainer.innerHTML = html; lastCheckSpan.textContent = updates.length + ' update(s) available'; + // Show count badge and Update All button + const countBadge = document.getElementById('updates-count-badge'); + const updateAllBtn = document.getElementById('updates-update-all-btn'); + if (countBadge) { + countBadge.textContent = updates.length + ' pending'; + countBadge.style.display = ''; + } + if (updateAllBtn && updates.length > 0) { + updateAllBtn.style.display = ''; + } + + // Store updates for Update All button + window._pendingUpdates = updates; + // Wire update buttons availableContainer.querySelectorAll('.update-now-btn').forEach(btn => { btn.addEventListener('click', async () => { @@ -174,6 +201,38 @@ } } + // Update All — sequentially, skip failures + async function updateAllContainers() { + const updates = window._pendingUpdates || []; + if (!updates.length) return; + const btn = document.getElementById('updates-update-all-btn'); + if (!confirm(`Update all ${updates.length} containers? Each will restart.`)) return; + btn.textContent = '⏳ Updating...'; + btn.disabled = true; + let success = 0, failed = 0; + for (const u of updates) { + try { + const r = await secureFetch(`/api/v1/updates/update/${encodeURIComponent(u.containerId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ autoRollback: true }) + }); + const d = await r.json(); + if (d.success) success++; + else failed++; + } catch (_) { failed++; } + } + btn.textContent = `✅ Done`; + showNotification(`Update all: ${success} succeeded, ${failed} failed.`, success > 0 && failed === 0 ? 'success' : 'error'); + setTimeout(() => { + btn.textContent = '⬆️ Update All'; + btn.disabled = false; + loadAvailable(); + }, 3000); + } + + document.getElementById('updates-update-all-btn')?.addEventListener('click', updateAllContainers); + async function checkForUpdates() { checkBtn.textContent = '🔍 Checking...'; checkBtn.disabled = true; @@ -499,6 +558,21 @@ }); wireModal(modal, cancelBtn); + // Open Update Management modal, optionally scrolled to a specific app + window.openUpdateModal = function(appId) { + modal?.classList.add('show'); + loadAvailable().then(() => { + if (!appId) return; + // Scroll to and highlight the matching row + const row = availableContainer.querySelector(`[data-app-id="${appId}"]`); + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.style.background = 'rgba(249,115,22,0.15)'; + setTimeout(() => { row.style.background = ''; }, 3000); + } + }); + }; + // Lazy-load tabs document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory); document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
ContainerImageCurrentLatestActions
${escapeHtml(u.containerName)}${escapeHtml(u.imageName)}${escapeHtml(u.currentDigest)}