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 @@
| Container | Image | Current | Latest | Actions |
|---|---|---|---|---|
| ${escapeHtml(u.containerName)} | `; html += `${escapeHtml(u.imageName)} | `; html += `${escapeHtml(u.currentDigest)} | `;
@@ -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);