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',