update UX: badge→modal flow, orange update button, Update All, toast notifications, workflow triggers
This commit is contained in:
@@ -10,9 +10,10 @@ const { success } = require('../response-helpers');
|
|||||||
* @param {Object} deps.docker - Docker client wrapper (client, pull methods)
|
* @param {Object} deps.docker - Docker client wrapper (client, pull methods)
|
||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||||
|
* @param {Object} deps.workflowEngine - WorkflowEngine instance (optional)
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
module.exports = function({ docker, log, asyncHandler }) {
|
module.exports = function({ docker, log, asyncHandler, workflowEngine }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper: verify container exists before operating on it
|
// 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}`);
|
log.info('docker', `Pulling latest image: ${imageName}`);
|
||||||
await docker.pull(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
|
// Get current container config for recreation
|
||||||
const hostConfig = containerInfo.HostConfig;
|
const hostConfig = containerInfo.HostConfig;
|
||||||
const config = {
|
const config = {
|
||||||
@@ -138,6 +144,11 @@ module.exports = function({ docker, log, asyncHandler }) {
|
|||||||
message: `Container ${containerName} updated successfully`,
|
message: `Container ${containerName} updated successfully`,
|
||||||
newContainerId: newContainerInfo.Id
|
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'));
|
}, 'container-update'));
|
||||||
|
|
||||||
// Check for available updates (compares local and remote image digests)
|
// Check for available updates (compares local and remote image digests)
|
||||||
|
|||||||
@@ -384,7 +384,8 @@ async function createApp() {
|
|||||||
apiRouter.use('/containers', containerRoutes({
|
apiRouter.use('/containers', containerRoutes({
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
asyncHandler: ctx.asyncHandler
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
workflowEngine: ctx.workflowEngine
|
||||||
}));
|
}));
|
||||||
apiRouter.use(serviceRoutes({
|
apiRouter.use(serviceRoutes({
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
|||||||
@@ -41,8 +41,11 @@
|
|||||||
dismissedUpdates = new Set();
|
dismissedUpdates = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track global update state for cross-component access
|
||||||
|
let knownUpdates = [];
|
||||||
|
|
||||||
// Fetch update data and show badges
|
// Fetch update data and show badges
|
||||||
async function refreshCardUpdates() {
|
async function refreshCardUpdates(notifyNew) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/updates/available');
|
const res = await fetch('/api/v1/updates/available');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -51,9 +54,21 @@
|
|||||||
// Clear all update badges first
|
// Clear all update badges first
|
||||||
document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible'));
|
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
|
// Try to match by container name to service id
|
||||||
const apps = window.APPS || [];
|
const apps = window.APPS || [];
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
@@ -61,17 +76,24 @@
|
|||||||
// Skip dismissed updates
|
// Skip dismissed updates
|
||||||
if (dismissedUpdates.has(app.id)) break;
|
if (dismissedUpdates.has(app.id)) break;
|
||||||
const badge = document.getElementById('update-badge-' + app.id);
|
const badge = document.getElementById('update-badge-' + app.id);
|
||||||
|
const updateBtn = document.getElementById('update-btn-' + app.id);
|
||||||
if (badge) {
|
if (badge) {
|
||||||
badge.classList.add('visible');
|
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.style.cursor = 'pointer';
|
||||||
badge.onclick = (e) => {
|
badge.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
badge.classList.remove('visible');
|
// Open Update Management modal focused on this app
|
||||||
dismissedUpdates.add(app.id);
|
if (window.openUpdateModal) window.openUpdateModal(app.id);
|
||||||
safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates]));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,10 +112,10 @@
|
|||||||
refreshCardUpdates();
|
refreshCardUpdates();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// Periodic refresh every 60 seconds
|
// Periodic refresh every 60 seconds — notify on new updates detected
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
refreshCardHealth();
|
refreshCardHealth();
|
||||||
refreshCardUpdates();
|
refreshCardUpdates(true); // true = notify if new updates found
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
|
|
||||||
<!-- Tab: Available Updates -->
|
<!-- Tab: Available Updates -->
|
||||||
<div id="updates-available" class="panel-section active">
|
<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-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>
|
||||||
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
|
<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 class="panel-empty"><span class="empty-icon">📦</span> Click "Check for Updates" to scan containers.</div>
|
||||||
@@ -94,13 +96,24 @@
|
|||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
|
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
|
||||||
lastCheckSpan.textContent = '';
|
lastCheckSpan.textContent = '';
|
||||||
|
document.getElementById('updates-update-all-btn').style.display = 'none';
|
||||||
|
document.getElementById('updates-count-badge').style.display = 'none';
|
||||||
|
window._pendingUpdates = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
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>';
|
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) {
|
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; font-weight: 500;">${escapeHtml(u.containerName)}</td>`;
|
||||||
html += `<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</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(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`;
|
||||||
@@ -114,6 +127,20 @@
|
|||||||
availableContainer.innerHTML = html;
|
availableContainer.innerHTML = html;
|
||||||
lastCheckSpan.textContent = updates.length + ' update(s) available';
|
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
|
// Wire update buttons
|
||||||
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
|
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
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() {
|
async function checkForUpdates() {
|
||||||
checkBtn.textContent = '🔍 Checking...';
|
checkBtn.textContent = '🔍 Checking...';
|
||||||
checkBtn.disabled = true;
|
checkBtn.disabled = true;
|
||||||
@@ -499,6 +558,21 @@
|
|||||||
});
|
});
|
||||||
wireModal(modal, cancelBtn);
|
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
|
// Lazy-load tabs
|
||||||
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
|
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
|
||||||
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
|
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
|
||||||
|
|||||||
Reference in New Issue
Block a user