update UX: badge→modal flow, orange update button, Update All, toast notifications, workflow triggers

This commit is contained in:
Hermes
2026-05-27 23:57:32 -07:00
parent 11823a1466
commit 588188edb5
4 changed files with 125 additions and 17 deletions
+12 -1
View File
@@ -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 = {
@@ -138,6 +144,11 @@ module.exports = function({ docker, log, asyncHandler }) {
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)
+2 -1
View File
@@ -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,
+31 -9
View File
@@ -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);
}
+76 -2
View File
@@ -17,8 +17,10 @@
<!-- Tab: Available Updates -->
<div id="updates-available" class="panel-section active">
<div style="margin-bottom: 12px;">
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
<button id="updates-check-btn" class="btn-accent-solid">🔍 Check for Updates</button>
<button id="updates-update-all-btn" style="display: none; padding: 6px 14px; font-size: 0.82rem; background: #f97316; color: #fff; border: 1px solid #f97316; border-radius: 6px; cursor: pointer;">⬆️ Update All</button>
<span id="updates-count-badge" style="display: none; padding: 4px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);"></span>
</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>
@@ -94,13 +96,24 @@
if (updates.length === 0) {
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
lastCheckSpan.textContent = '';
document.getElementById('updates-update-all-btn').style.display = 'none';
document.getElementById('updates-count-badge').style.display = 'none';
window._pendingUpdates = [];
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);">`;
// 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 += `<tr data-app-id="${escapeHtml(appId)}" 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>`;
@@ -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);