From 0519e797f563dd82ef0ae39ef985ef86cc426380 Mon Sep 17 00:00:00 2001 From: master3395 Date: Sat, 11 Apr 2026 20:31:58 +0200 Subject: [PATCH] Docker Manager: image refresh, history UI, ports in settings - container.py: optional force_update on image pull/install; saveContainerSettings accepts normalized client ports on recreate; loadContainerHome exposes ports_json for Angular; _normalize_ports_for_save helper. - manageImages.html: image history modal layout; Refresh control for image pull. - viewContainer.html: port mappings inside Container Settings; ports_json ng-init; checkbox/toggle/compose CSS fixes; remove standalone Edit Ports modal. - dockerManager.js (3x): initAngularPortsFromServer, flat DB port map + inspect fallback, portsDirty/saveSettings integration, advanced-env switch checked styles support, refreshContainerInfo, image history formatting helpers. --- dockerManager/container.py | 81 +++++- .../static/dockerManager/dockerManager.js | 200 +++++++++++++-- .../templates/dockerManager/manageImages.html | 102 +++++++- .../dockerManager/viewContainer.html | 203 +++++++++------ public/static/dockerManager/dockerManager.js | 241 ++++++++++++++++-- static/dockerManager/dockerManager.js | 200 +++++++++++++-- 6 files changed, 842 insertions(+), 185 deletions(-) diff --git a/dockerManager/container.py b/dockerManager/container.py index c3f0f95a0..71e3be3cb 100644 --- a/dockerManager/container.py +++ b/dockerManager/container.py @@ -210,6 +210,9 @@ class ContainerManager(multi.Thread): data['memoryUsage'] = 0 data['cpuUsage'] = 0 + # JSON for Angular ng-init (flat map container_port -> host_port from DB) + data['ports_json'] = json.dumps(data.get('ports', {})) + template = 'dockerManager/viewContainer.html' proc = httpProc(request, template, data, 'admin') return proc.render() @@ -588,14 +591,21 @@ class ContainerManager(multi.Thread): image = data['image'] tag = data['tag'] + force_update = bool(data.get('force_update')) - try: - inspectImage = dockerAPI.inspect_image(image + ":" + tag) - data_ret = {'installImageStatus': 0, 'error_message': "Image already installed"} + if not self._validate_image_name(image): + data_ret = {'installImageStatus': 0, 'error_message': 'Invalid image name format'} json_data = json.dumps(data_ret) return HttpResponse(json_data) - except docker.errors.ImageNotFound: - pass + + if not force_update: + try: + inspectImage = dockerAPI.inspect_image(image + ":" + tag) + data_ret = {'installImageStatus': 0, 'error_message': "Image already installed"} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + except docker.errors.ImageNotFound: + pass try: image = client.images.pull(image, tag=tag) @@ -636,14 +646,17 @@ class ContainerManager(multi.Thread): json_data = json.dumps(data_ret) return HttpResponse(json_data) - # Check if image already exists - try: - inspectImage = dockerAPI.inspect_image(image + ":" + tag) - data_ret = {'pullImageStatus': 0, 'error_message': "Image already exists locally"} - json_data = json.dumps(data_ret) - return HttpResponse(json_data) - except docker.errors.ImageNotFound: - pass + force_update = bool(data.get('force_update')) + + # Check if image already exists (skip when refreshing from registry) + if not force_update: + try: + inspectImage = dockerAPI.inspect_image(image + ":" + tag) + data_ret = {'pullImageStatus': 0, 'error_message': "Image already exists locally"} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + except docker.errors.ImageNotFound: + pass # Pull the image try: @@ -1306,6 +1319,36 @@ class ContainerManager(multi.Thread): secure_log_error(e, 'container_operation') return 'Operation failed' + @staticmethod + def _normalize_ports_for_save(ports_raw): + """ + Parse client-submitted port map for saveContainerSettings recreate. + Returns (True, dict) with container_port -> host_port (str), + (True, None) if ports_raw is None (caller should use DB), + or (False, error_message). + """ + if ports_raw is None: + return True, None + if not isinstance(ports_raw, dict): + return False, 'Invalid ports payload' + out = {} + for ck, cv in ports_raw.items(): + if not ck: + continue + ckey = str(ck).strip() + if not ckey: + continue + if cv in (None, '', 'null'): + continue + try: + hp = int(cv) + except (ValueError, TypeError): + return False, 'Invalid host port for %s' % ckey + if hp < 1024 or hp > 65535: + return False, 'Choose host port between 1024 and 65535' + out[ckey] = str(hp) + return True, out + def saveContainerSettings(self, userID=None, data=None): try: name = data['name'] @@ -1375,6 +1418,15 @@ class ContainerManager(multi.Thread): continue volumes[volume['src']] = {'bind': volume['dest'], 'mode': 'rw'} + ports_for_recreate = json.loads(con.ports) + if 'ports' in data and data.get('ports') is not None: + ok_ports, norm_ports = self._normalize_ports_for_save(data.get('ports')) + if not ok_ports: + data_ret = {'saveSettingsStatus': 0, 'error_message': norm_ports} + json_data = json.dumps(data_ret) + return HttpResponse(json_data) + if norm_ports is not None: + ports_for_recreate = norm_ports # Prepare data for recreate function data = { 'name': name, @@ -1382,7 +1434,7 @@ class ContainerManager(multi.Thread): 'image': con.image, 'tag': con.tag, 'env': envDict, - 'ports': json.loads(con.ports), + 'ports': ports_for_recreate, 'volumes': volumes, 'memory': con.memory } @@ -1395,6 +1447,7 @@ class ContainerManager(multi.Thread): con.env = json.dumps(envDict) con.volumes = json.dumps(volumes) + con.ports = json.dumps(ports_for_recreate) con.save() data_ret = {'saveSettingsStatus': 1, 'error_message': 'None'} diff --git a/dockerManager/static/dockerManager/dockerManager.js b/dockerManager/static/dockerManager/dockerManager.js index e89dcf7c0..e7af021ac 100644 --- a/dockerManager/static/dockerManager/dockerManager.js +++ b/dockerManager/static/dockerManager/dockerManager.js @@ -1389,6 +1389,8 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { $scope.cName = ""; $scope.status = ""; $scope.savingSettings = false; + $scope.currentPorts = {}; + $scope.initialPortsForSettings = {}; $scope.loadingTop = false; $scope.statusInterval = null; $scope.statsInterval = null; @@ -1398,7 +1400,37 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { $scope.advancedEnvText = ''; $scope.advancedEnvCount = 0; $scope.parsedEnvVars = {}; - + /** Flat map from DB (container_port -> host_port), set from template ng-init */ + $scope.serverPortMap = {}; + + $scope.initAngularPortsFromServer = function (obj) { + if (obj === undefined || obj === null) { + $scope.serverPortMap = {}; + $scope.ports = {}; + return; + } + if (angular.isString(obj)) { + try { + obj = angular.fromJson(obj); + } catch (e1) { + try { + obj = JSON.parse(obj); + } catch (e2) { + $scope.serverPortMap = {}; + $scope.ports = {}; + return; + } + } + } + if (!angular.isObject(obj)) { + $scope.serverPortMap = {}; + $scope.ports = {}; + return; + } + $scope.serverPortMap = obj; + $scope.ports = obj; + }; + // Auto-refresh status every 5 seconds $scope.startStatusMonitoring = function() { $scope.statusInterval = $interval(function() { @@ -2028,6 +2060,13 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { } }; + /** Used after legacy port-update path; full page reload syncs Django-rendered state */ + $scope.refreshContainerInfo = function () { + $timeout(function () { + window.location.reload(); + }, 300); + }; + $scope.addVolField = function () { $scope.volList[$scope.volListNumber] = {'dest': '', 'src': ''}; $scope.volListNumber = $scope.volListNumber + 1; @@ -2038,6 +2077,15 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { }; $scope.saveSettings = function () { + if ($scope.portsDirty && $scope.portsDirty() && !$scope.envConfirmation) { + new PNotify({ + title: 'Confirmation required', + text: 'Check the confirmation box to apply changes to ports, environment variables, or volumes (the container will be recreated).', + type: 'warning' + }); + return; + } + $('#containerSettingLoading').show(); url = "/docker/saveContainerSettings"; $scope.savingSettings = true; @@ -2063,7 +2111,8 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { envConfirmation: $scope.envConfirmation, envList: finalEnvList, volList: $scope.volList, - advancedEnvMode: $scope.advancedEnvMode + advancedEnvMode: $scope.advancedEnvMode, + ports: $scope.currentPorts || {} }; @@ -2088,6 +2137,7 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { title: 'Settings Saved', type: 'success' }); + $scope.initialPortsForSettings = angular.copy($scope.currentPorts || {}); } } else { @@ -2213,22 +2263,55 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { $("#commandModal").modal("show"); }; - // Port editing functionality - $scope.showPortEditModal = function() { - // Initialize current ports from container data + // Port editing (in Container Settings modal) + $scope.initSettingsPortsFromContainer = function () { $scope.currentPorts = {}; - if ($scope.ports) { - for (var iport in $scope.ports) { - var eport = $scope.ports[iport]; - if (eport && eport.length > 0) { + var src = $scope.serverPortMap && Object.keys($scope.serverPortMap).length + ? $scope.serverPortMap + : ($scope.ports || {}); + if (src && typeof src === 'object') { + for (var iport in src) { + if (!Object.prototype.hasOwnProperty.call(src, iport)) { + continue; + } + var eport = src[iport]; + if (angular.isArray(eport) && eport.length > 0 && eport[0] && eport[0].HostPort) { $scope.currentPorts[iport] = eport[0].HostPort; + } else if (eport !== undefined && eport !== null && eport !== '') { + $scope.currentPorts[iport] = String(eport); } } } - $("#portEditModal").modal("show"); + $scope.initialPortsForSettings = angular.copy($scope.currentPorts); }; - $scope.addNewPortMapping = function() { + $scope.portsJsonSnapshot = function (obj) { + if (!obj || typeof obj !== 'object') { + return '{}'; + } + var keys = Object.keys(obj).sort(); + var flat = {}; + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + var v = obj[k]; + flat[k] = v === null || v === undefined ? '' : String(v); + } + return JSON.stringify(flat); + }; + + $scope.portsDirty = function () { + return $scope.portsJsonSnapshot($scope.currentPorts) !== $scope.portsJsonSnapshot($scope.initialPortsForSettings); + }; + + $scope.showPortEditModal = function () { + $scope.initSettingsPortsFromContainer(); + $("#settings").modal("show"); + }; + + $scope.addNewPortMapping = function () { + if (!$scope.envConfirmation) { + return; + } var containerPort = prompt('Enter container port (e.g., 80/tcp):'); if (containerPort) { $scope.currentPorts[containerPort] = ''; @@ -2236,14 +2319,16 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { } }; - $scope.removePortMapping = function(containerPort) { + $scope.removePortMapping = function (containerPort) { + if (!$scope.envConfirmation) { + return; + } if (confirm('Are you sure you want to remove this port mapping?')) { delete $scope.currentPorts[containerPort]; } }; - $scope.updatePortMappings = function() { - $("#portEditLoading").show(); + $scope.updatePortMappings = function () { $scope.updatingPorts = true; var url = "/docker/updateContainerPorts"; @@ -2259,18 +2344,16 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { }; $http.post(url, data, config).then(function(response) { - $("#portEditLoading").hide(); $scope.updatingPorts = false; if (response.data.status === 1) { - $("#portEditModal").modal("hide"); - // Refresh container status and ports - $scope.refreshContainerInfo(); + $("#settings").modal("hide"); new PNotify({ title: 'Success', text: 'Port mappings updated successfully', type: 'success' }); + $scope.refreshContainerInfo(); } else { new PNotify({ title: 'Error', @@ -2279,11 +2362,10 @@ app.controller('viewContainer', function ($scope, $http, $interval, $timeout) { }); } }, function(error) { - $("#portEditLoading").hide(); $scope.updatingPorts = false; new PNotify({ title: 'Error', - text: 'Error updating port mappings: ' + error.data.error_message, + text: 'Error updating port mappings: ' + (error.data && error.data.error_message ? error.data.error_message : 'unknown'), type: 'error' }); }); @@ -2388,12 +2470,55 @@ app.controller('manageImages', function ($scope, $http) { $scope.showingSearch = false; $("#searchResult").hide(); + function dockerHistoryFormatSize(raw) { + var n = parseInt(raw, 10); + if (isNaN(n) || n < 0) { + return (raw === undefined || raw === null) ? '' : String(raw); + } + if (n === 0) { + return '0 B'; + } + var units = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + var v = n / Math.pow(1024, i); + var s = (i === 0) ? String(v) : (Math.round(v * 10) / 10).toFixed(1); + return s + ' ' + units[i]; + } + + function dockerHistoryFormatCreated(raw) { + if (raw === undefined || raw === null) { + return ''; + } + var n = Number(raw); + if (isNaN(n)) { + return String(raw); + } + var sec = n; + if (n > 1e14) { + sec = Math.floor(n / 1e9); + } else if (n > 1e12) { + sec = Math.floor(n / 1e6); + } + var d = new Date(sec * 1000); + if (isNaN(d.getTime())) { + return String(raw); + } + return d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'); + } + + function dockerHistoryEnrichLayer(h) { + var o = angular.extend({}, h); + o.SizeHuman = dockerHistoryFormatSize(h.Size); + o.CreatedDisplay = dockerHistoryFormatCreated(h.Created); + return o; + } + $scope.pullImage = function (image, tag) { function ListInitialDatas(response) { if (response.data.installImageStatus === 1) { new PNotify({ title: 'Image pulled successfully', - text: 'Reloading...', + text: 'Running containers keep the old image until you recreate them from Docker > Containers. Reloading…', type: 'success' }); location.reload() @@ -2424,7 +2549,8 @@ app.controller('manageImages', function ($scope, $http) { url = "/docker/installImage"; var data = { image: image, - tag: tag + tag: tag, + force_update: true }; var config = { headers: { @@ -2445,6 +2571,30 @@ app.controller('manageImages', function ($scope, $http) { } + $scope.refreshLocalImage = function (rowId, imageName) { + var sel = document.getElementById(String(rowId)); + if (!sel || sel.selectedIndex < 0) { + new PNotify({ + title: 'Unable to complete request', + text: 'Please select a tag for this image', + type: 'info' + }); + return; + } + var raw = sel.options[sel.selectedIndex].text; + if (!raw) { + new PNotify({ + title: 'Unable to complete request', + text: 'Please select a tag for this image', + type: 'info' + }); + return; + } + var li = raw.lastIndexOf(':'); + var tag = li > -1 ? raw.substring(li + 1) : raw; + $scope.pullImage(imageName, tag); + } + $scope.searchImages = function () { console.log($scope.searchString); if (!$scope.searchString) { @@ -2580,7 +2730,8 @@ app.controller('manageImages', function ($scope, $http) { $scope.getHistory = function (counter) { $('#imageLoading').show(); - var name = $("#" + counter).val() + var sel = $("#" + counter); + var name = (sel.find('option:selected').text() || sel.val() || '').trim(); url = "/docker/getImageHistory"; @@ -2600,7 +2751,8 @@ app.controller('manageImages', function ($scope, $http) { if (response.data.imageHistoryStatus === 1) { $('#history').modal('show'); - $scope.historyList = response.data.history; + var raw = response.data.history || []; + $scope.historyList = raw.map(dockerHistoryEnrichLayer); } else { new PNotify({ diff --git a/dockerManager/templates/dockerManager/manageImages.html b/dockerManager/templates/dockerManager/manageImages.html index 52bafac01..8c2d30745 100644 --- a/dockerManager/templates/dockerManager/manageImages.html +++ b/dockerManager/templates/dockerManager/manageImages.html @@ -375,8 +375,20 @@ padding: 1rem 2rem; } + .modal-dialog-history { + width: 96vw; + max-width: min(96vw, 1400px); + margin: 1.75rem auto; + } + + .history-modal-scroll { + overflow-x: auto; + max-width: 100%; + } + .history-table { width: 100%; + table-layout: fixed; background: var(--bg-hover, #f8f9ff); border-radius: 8px; overflow: hidden; @@ -390,13 +402,74 @@ color: var(--text-primary, #1e293b); font-size: 0.875rem; } + + .history-table th.history-col-size { + text-align: right; + width: 9%; + } + + .history-table th.history-col-created { + width: 14%; + } + + .history-table th.history-col-comment { + width: 12%; + } + + .history-table th.history-col-id { + width: 14%; + } .history-table td { padding: 0.75rem; color: var(--text-secondary, #64748b); font-size: 0.875rem; border-bottom: 1px solid var(--border-color, #e8e9ff); - word-break: break-all; + vertical-align: top; + word-break: normal; + overflow-wrap: anywhere; + } + + .history-table td.history-col-id { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + white-space: nowrap; + overflow-x: auto; + max-width: 0; + } + + .history-table td.history-col-cmd { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.8125rem; + } + + .history-layer-cmd { + margin: 0; + white-space: pre; + overflow: auto; + max-height: 14rem; + max-width: 100%; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #e8e9ff); + border-radius: 6px; + padding: 0.5rem 0.6rem; + } + + .history-table td.history-col-created { + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + .history-table td.history-col-comment { + overflow-x: auto; + max-width: 0; + font-size: 0.8125rem; + } + + .history-table td.history-col-size { + white-space: nowrap; + text-align: right; + font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } @keyframes spin { @@ -583,6 +656,11 @@
+
+
+ +
+ +
+
+ + {% trans "Important:" %} + {% trans "Changing host ports requires checking the confirmation box below; the container will be recreated." %} +
+
+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
+ +

{% trans "No port mappings configured" %}

+
+
+
+ +
+
+
+
@@ -834,7 +874,7 @@
@@ -856,10 +896,10 @@

-
@@ -1246,77 +1286,6 @@ - -