From 975966146fd59872b2e6118b892c7bb7cca31c87 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 3 Apr 2026 21:20:32 +0200 Subject: [PATCH] Docker Manager: fix container update flow and UI sync Align updateContainer with the panel (name vs containerName), pull new images before removing the old container, and sync the Containers model after a successful update. getContainerList now shows live Config.Image so tags match Docker. Add notification-center progress for updates, guard overlapping requests, and return new_image on success. --- .../templates/baseTemplate/index.html | 173 +++++++++++++++++- dockerManager/container.py | 98 ++++++++-- .../static/dockerManager/dockerManager.js | 40 +++- static/dockerManager/dockerManager.js | 40 +++- 4 files changed, 321 insertions(+), 30 deletions(-) diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index 0476e85f7..0ea106fb6 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -410,6 +410,63 @@ text-decoration: underline; color: #3730a3 !important; } + + /* Ephemeral notifications (e.g. Docker update progress) */ + .notification-center-item-ephemeral { + border-color: #c7d2fe; + background: linear-gradient(135deg, #f8fafc 0%, #eef2ff 100%); + } + .notification-center-progress-track { + height: 8px; + border-radius: 999px; + background: #e5e7eb; + overflow: hidden; + margin-top: 0.5rem; + } + .notification-center-progress-bar { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #4f46e5, #6366f1); + transition: width 0.35s ease; + } + .notification-center-progress-bar.indeterminate { + width: 35% !important; + animation: cp-nc-progress-indet 1.2s ease-in-out infinite; + } + .notification-center-progress-bar.success { + width: 100% !important; + background: linear-gradient(90deg, #16a34a, #22c55e); + animation: none; + } + .notification-center-progress-bar.error { + width: 100% !important; + background: linear-gradient(90deg, #dc2626, #ef4444); + animation: none; + } + @keyframes cp-nc-progress-indet { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(280%); } + } + .notification-center-ephemeral-dismiss { + margin-top: 0.75rem; + font-size: 0.8rem; + color: #6b7280; + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; + } + .notification-center-ephemeral-dismiss:hover { color: #111827; } + .notification-center-btn.has-active-operation { + animation: cp-nc-bell-pulse 1.5s ease-in-out infinite; + border-color: #6366f1; + color: #4f46e5; + } + @keyframes cp-nc-bell-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.35); } + 50% { box-shadow: 0 0 0 6px rgba(79, 70, 229, 0); } + } /* Sidebar */ #sidebar { @@ -2504,7 +2561,7 @@ - + @@ -2750,6 +2807,93 @@ } return false; } + + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + + function cpEscapeHtmlNC(str) { + if (str == null || str === '') return ''; + var div = document.createElement('div'); + div.textContent = String(str); + return div.innerHTML; + } + + function cpEscapeAttrNC(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + function renderEphemeralNotificationsHTML() { + var items = window.__cpEphemeralNotifications || []; + return items.map(function(notif) { + var nameEsc = cpEscapeHtmlNC(notif.containerName); + var imgEsc = cpEscapeHtmlNC(notif.imageRef); + var idAttr = cpEscapeAttrNC(notif.id); + var textHtml = ''; + var barHtml = ''; + if (notif.state === 'running') { + textHtml = '

Updating ' + nameEsc + ' to ' + imgEsc + '. Pulling image and recreating the container — you can leave this tab open.

'; + barHtml = '
'; + } else if (notif.state === 'done_ok') { + textHtml = '

' + nameEsc + ' updated successfully.

' + cpEscapeHtmlNC(notif.resultMessage) + '

'; + barHtml = '
'; + } else { + textHtml = '

Update failed for ' + nameEsc + '.

' + cpEscapeHtmlNC(notif.resultMessage) + '

'; + barHtml = '
'; + } + return '
' + + '
Docker update
' + + '
' + textHtml + '
' + + barHtml + + '' + + '
'; + }).join(''); + } + + window.cpDismissEphemeralNotification = function(id) { + window.__cpEphemeralNotifications = (window.__cpEphemeralNotifications || []).filter(function(n) { return n.id !== id; }); + var stillRunning = (window.__cpEphemeralNotifications || []).some(function(n) { return n.state === 'running'; }); + var btn = document.getElementById('notification-center-btn'); + if (btn && !stillRunning) btn.classList.remove('has-active-operation'); + loadNotificationCenter(); + }; + + window.cpDockerUpdateNotifyStart = function(containerName, imageRef) { + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + var id = 'docker-update-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9); + window.__cpEphemeralNotifications.unshift({ + id: id, + kind: 'docker-update', + containerName: String(containerName || ''), + imageRef: String(imageRef || ''), + state: 'running', + resultMessage: '' + }); + var dd = document.getElementById('notification-center-dropdown'); + if (dd) dd.classList.add('show'); + var bell = document.getElementById('notification-center-btn'); + if (bell) bell.classList.add('has-active-operation'); + loadNotificationCenter(); + return id; + }; + + window.cpDockerUpdateNotifyEnd = function(nid, ok, message) { + window.__cpEphemeralNotifications = window.__cpEphemeralNotifications || []; + var n = null; + for (var i = 0; i < window.__cpEphemeralNotifications.length; i++) { + if (window.__cpEphemeralNotifications[i].id === nid) { n = window.__cpEphemeralNotifications[i]; break; } + } + if (!n) return; + n.state = ok ? 'done_ok' : 'done_err'; + n.resultMessage = message ? String(message) : (ok ? 'Done.' : 'Unknown error.'); + var stillRunning = window.__cpEphemeralNotifications.some(function(x) { return x.state === 'running'; }); + var btn = document.getElementById('notification-center-btn'); + if (btn && !stillRunning) btn.classList.remove('has-active-operation'); + loadNotificationCenter(); + }; + function toggleNotificationCenter() { const dropdown = document.getElementById('notification-center-dropdown'); if (dropdown) { @@ -2779,10 +2923,10 @@ learnMoreLink: 'https://cyberpanel.net/cyberpanel-htaccess-module', dismissed: isNotificationDismissed('htaccess-notification') } ]; - if (notifications.length === 0) { - list.innerHTML = '
No notifications available
'; - } else { - list.innerHTML = notifications.map(notif => { + const ephemeralHtml = renderEphemeralNotificationsHTML(); + let staticHtml = ''; + if (notifications.length > 0) { + staticHtml = notifications.map(notif => { let linkIcon = notif.linkText.includes('Configure') ? '' : notif.linkText.includes('Start') ? '' : (notif.linkText.includes('View') || notif.linkText.includes('Details')) ? '' : ''; @@ -2802,7 +2946,14 @@ `; }).join(''); } - const activeCount = notifications.filter(n => !n.dismissed).length; + if (!ephemeralHtml && !staticHtml) { + list.innerHTML = '
No notifications available
'; + } else { + list.innerHTML = ephemeralHtml + staticHtml; + } + const staticActive = notifications.filter(n => !n.dismissed).length; + const runningEphem = (window.__cpEphemeralNotifications || []).filter(function(n) { return n.state === 'running'; }).length; + const activeCount = staticActive + runningEphem; const badge = document.getElementById('notification-badge'); if (badge) { badge.textContent = activeCount; @@ -2819,6 +2970,16 @@ // Check all notification statuses when page loads document.addEventListener('DOMContentLoaded', function() { + var ncList = document.getElementById('notification-center-list'); + if (ncList) { + ncList.addEventListener('click', function(ev) { + var dismissBtn = ev.target.closest('[data-cp-ephemeral-dismiss]'); + if (dismissBtn && window.cpDismissEphemeralNotification) { + var eid = dismissBtn.getAttribute('data-cp-ephemeral-dismiss'); + if (eid) window.cpDismissEphemeralNotification(eid); + } + }); + } loadNotificationCenter(); checkBackupStatus(); // Optional: open notification dropdown for testing (e.g. ?showNotifications=1) diff --git a/dockerManager/container.py b/dockerManager/container.py index efdbc3635..c3f0f95a0 100644 --- a/dockerManager/container.py +++ b/dockerManager/container.py @@ -772,9 +772,28 @@ class ContainerManager(multi.Thread): end = start + items_per_page page_containers = all_containers[start:end] + client = docker.from_env() rows = [] for items in page_containers: - rows.append({'name': items.name, 'admin': items.admin.userName, 'tag': items.tag, 'image': items.image}) + disp_image = items.image + disp_tag = items.tag + try: + running = client.containers.get(items.name) + cfg_ref = running.attrs.get('Config', {}).get('Image') or '' + if cfg_ref and '@' not in cfg_ref: + if ':' in cfg_ref: + disp_image, disp_tag = cfg_ref.rsplit(':', 1) + else: + disp_image, disp_tag = cfg_ref, 'latest' + elif running.image and running.image.tags: + ref = running.image.tags[0] + if ':' in ref: + disp_image, disp_tag = ref.rsplit(':', 1) + else: + disp_image, disp_tag = ref, 'latest' + except Exception: + pass + rows.append({'name': items.name, 'admin': items.admin.userName, 'tag': disp_tag, 'image': disp_image}) json_data = json.dumps(rows) final_dic = { @@ -2346,15 +2365,28 @@ class ContainerManager(multi.Thread): client = docker.from_env() dockerAPI = docker.APIClient() - containerName = data['containerName'] - newImage = data['newImage'] - newTag = data.get('newTag', 'latest') + # UI (dockerManager.js) sends "name"; older callers may use "containerName" + containerName = (data.get('containerName') or data.get('name') or '').strip() + if not containerName: + data_ret = {'updateContainerStatus': 0, 'error_message': 'Container name is required'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + + newImage = (data.get('newImage') or '').strip() + newTag = (data.get('newTag') or 'latest').strip() or 'latest' # Get the current container try: currentContainer = client.containers.get(containerName) except docker.errors.NotFound: - data_ret = {'updateContainerStatus': 0, 'error_message': f'Container {containerName} not found'} + data_ret = { + 'updateContainerStatus': 0, + 'error_message': ( + f'Container {containerName} not found. ' + 'If you clicked Update twice, wait for the first request to finish; ' + 'do not start another update until you see success or failure.' + ), + } json_data = json.dumps(data_ret) return HttpResponse(json_data) except Exception as e: @@ -2365,6 +2397,19 @@ class ContainerManager(multi.Thread): # Get container configuration for recreation containerConfig = currentContainer.attrs['Config'] hostConfig = currentContainer.attrs['HostConfig'] + + # If no new image specified, use current image repository (same as first updateContainer implementation) + if not newImage: + current_image = containerConfig.get('Image', '') or '' + if ':' in current_image: + newImage = current_image.split(':')[0] + else: + newImage = current_image + newTag = 'latest' + if not newImage: + data_ret = {'updateContainerStatus': 0, 'error_message': 'Could not determine image name for update'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) # Extract volumes for data preservation volumes = {} @@ -2399,6 +2444,20 @@ class ContainerManager(multi.Thread): if memory_limit > 0: memory_limit = memory_limit // 1048576 # Convert bytes to MB + image_name = f"{newImage}:{newTag}" + + # Pull BEFORE stop/remove so a slow/failed pull never leaves the container deleted + # (double-clicks or retries would otherwise see "container not found"). + try: + logging.CyberCPLogFileWriter.writeToFile(f'Pulling new image {image_name} (container still running)') + client.images.pull(newImage, tag=newTag) + logging.CyberCPLogFileWriter.writeToFile(f'Successfully pulled image {image_name}') + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile(f'Error pulling image {newImage}:{newTag}: {str(e)}') + data_ret = {'updateContainerStatus': 0, 'error_message': f'Error pulling new image: {str(e)}'} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + # Stop the current container try: if currentContainer.status == 'running': @@ -2420,18 +2479,6 @@ class ContainerManager(multi.Thread): json_data = json.dumps(data_ret) return HttpResponse(json_data) - # Pull the new image - try: - image_name = f"{newImage}:{newTag}" - logging.CyberCPLogFileWriter.writeToFile(f'Pulling new image {image_name}') - client.images.pull(newImage, tag=newTag) - logging.CyberCPLogFileWriter.writeToFile(f'Successfully pulled image {image_name}') - except Exception as e: - logging.CyberCPLogFileWriter.writeToFile(f'Error pulling image {newImage}:{newTag}: {str(e)}') - data_ret = {'updateContainerStatus': 0, 'error_message': f'Error pulling new image: {str(e)}'} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - # Create new container with same configuration but new image try: containerArgs = { @@ -2475,12 +2522,27 @@ class ContainerManager(multi.Thread): json_data = json.dumps(data_ret) return HttpResponse(json_data) + # Sync DB — container list UI reads image/tag from Containers model, not Docker + try: + container_record = Containers.objects.get(name=containerName) + container_record.image = newImage + container_record.tag = newTag + container_record.cid = newContainer.short_id + container_record.save() + except Containers.DoesNotExist: + pass + except Exception as db_err: + logging.CyberCPLogFileWriter.writeToFile( + f'updateContainer DB sync failed for {containerName}: {db_err}' + ) + # Log successful update logging.CyberCPLogFileWriter.writeToFile(f'Successfully updated container {containerName} to image {image_name}') data_ret = { - 'updateContainerStatus': 1, + 'updateContainerStatus': 1, 'error_message': 'None', + 'new_image': image_name, 'message': f'Container {containerName} successfully updated to {image_name}' } json_data = json.dumps(data_ret) diff --git a/dockerManager/static/dockerManager/dockerManager.js b/dockerManager/static/dockerManager/dockerManager.js index ba9569bef..e89dcf7c0 100644 --- a/dockerManager/static/dockerManager/dockerManager.js +++ b/dockerManager/static/dockerManager/dockerManager.js @@ -974,6 +974,15 @@ app.controller('listContainers', function ($scope, $http) { return; } + if ($scope.dockerUpdateInProgress) { + new PNotify({ + title: 'Update in progress', + text: 'Wait until the current update finishes before starting another.', + type: 'warning' + }); + return; + } + // If no new image specified, use current image if (!$scope.newImage) { $scope.newImage = $scope.currentImage; @@ -1000,9 +1009,18 @@ app.controller('listContainers', function ($scope, $http) { history: false } })).get().on('pnotify.confirm', function () { + var dockerUpdateNotificationId = null; + $scope.dockerUpdateInProgress = true; $('#imageLoading').show(); $("#updateContainer").modal("hide"); + if (typeof window.cpDockerUpdateNotifyStart === 'function') { + dockerUpdateNotificationId = window.cpDockerUpdateNotifyStart( + $scope.updateContainerName, + $scope.newImage + ':' + $scope.newTag + ); + } + url = "/docker/updateContainer"; var data = { name: $scope.updateContainerName, @@ -1020,15 +1038,27 @@ app.controller('listContainers', function ($scope, $http) { function ListInitialData(response) { console.log(response); + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); - if (response.data.updateContainerStatus === 1) { + var ok = response.data && response.data.updateContainerStatus === 1; + var imgLabel = ok + ? (response.data.new_image || response.data.message || 'Updated') + : (response.data && response.data.error_message ? response.data.error_message : 'Update failed'); + + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, ok, imgLabel); + } + + if (ok) { new PNotify({ title: 'Container Updated Successfully', - text: `Container updated to ${response.data.new_image}`, + text: 'Container updated to ' + (response.data.new_image || response.data.message || 'new image'), type: 'success' }); - location.reload(); + setTimeout(function () { + location.reload(); + }, 2200); } else { new PNotify({ title: 'Update Failed', @@ -1039,7 +1069,11 @@ app.controller('listContainers', function ($scope, $http) { } function cantLoadInitialData(response) { + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, false, 'Could not connect to server'); + } new PNotify({ title: 'Update Failed', text: 'Could not connect to server', diff --git a/static/dockerManager/dockerManager.js b/static/dockerManager/dockerManager.js index ba9569bef..e89dcf7c0 100644 --- a/static/dockerManager/dockerManager.js +++ b/static/dockerManager/dockerManager.js @@ -974,6 +974,15 @@ app.controller('listContainers', function ($scope, $http) { return; } + if ($scope.dockerUpdateInProgress) { + new PNotify({ + title: 'Update in progress', + text: 'Wait until the current update finishes before starting another.', + type: 'warning' + }); + return; + } + // If no new image specified, use current image if (!$scope.newImage) { $scope.newImage = $scope.currentImage; @@ -1000,9 +1009,18 @@ app.controller('listContainers', function ($scope, $http) { history: false } })).get().on('pnotify.confirm', function () { + var dockerUpdateNotificationId = null; + $scope.dockerUpdateInProgress = true; $('#imageLoading').show(); $("#updateContainer").modal("hide"); + if (typeof window.cpDockerUpdateNotifyStart === 'function') { + dockerUpdateNotificationId = window.cpDockerUpdateNotifyStart( + $scope.updateContainerName, + $scope.newImage + ':' + $scope.newTag + ); + } + url = "/docker/updateContainer"; var data = { name: $scope.updateContainerName, @@ -1020,15 +1038,27 @@ app.controller('listContainers', function ($scope, $http) { function ListInitialData(response) { console.log(response); + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); - if (response.data.updateContainerStatus === 1) { + var ok = response.data && response.data.updateContainerStatus === 1; + var imgLabel = ok + ? (response.data.new_image || response.data.message || 'Updated') + : (response.data && response.data.error_message ? response.data.error_message : 'Update failed'); + + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, ok, imgLabel); + } + + if (ok) { new PNotify({ title: 'Container Updated Successfully', - text: `Container updated to ${response.data.new_image}`, + text: 'Container updated to ' + (response.data.new_image || response.data.message || 'new image'), type: 'success' }); - location.reload(); + setTimeout(function () { + location.reload(); + }, 2200); } else { new PNotify({ title: 'Update Failed', @@ -1039,7 +1069,11 @@ app.controller('listContainers', function ($scope, $http) { } function cantLoadInitialData(response) { + $scope.dockerUpdateInProgress = false; $('#imageLoading').hide(); + if (typeof window.cpDockerUpdateNotifyEnd === 'function' && dockerUpdateNotificationId) { + window.cpDockerUpdateNotifyEnd(dockerUpdateNotificationId, false, 'Could not connect to server'); + } new PNotify({ title: 'Update Failed', text: 'Could not connect to server',