luci-app-dockerman: convert to JS
authorPaul Donald <newtwen+github@gmail.com>
Sun, 6 Jul 2025 00:33:02 +0000 (02:33 +0200)
committerPaul Donald <newtwen+github@gmail.com>
Wed, 4 Feb 2026 05:45:51 +0000 (06:45 +0100)
This is a complete rewrite of the original Lua
dockerman in ECMAScript and ucode. Now with most of the
Lua gone, we can rename LuCI to JUCI. JavaScript ucode
Configuration Interface :)

Docker manager basically saw no updates or bug fixes
due to the Lua update embargo and transition to ECMAScript
in the luci repo.

But now that the app is rewritten, updates should come
readily from the community. Expect a few bugs in this,
although it has seen lots of testing - it's also seen lots
of development in different directions. Networking
scenarios might require some additions and fixes to the
GUI.

Swarm functionality is not implemented in this client and
is left as an exercise to the community and those with time.
All functionality found in the original Lua version is
present in this one, except for container "upgrade".
Some minor differences are introduced to improve layout and
logic.

There is no "remote endpoint" any longer since sockets
are the main method of connecting to dockerd - and sockets
accept remote connections. Docker manager and dockerd
on the same host are a remote connection. Buuut, dockerd
removes listening on any IP without --tls* options after v27.

There is no encryption between docker manager and the
API endpoint, or the container consoles when using the
standard /var/run/docker.sock.

See: https://github.com/openwrt/luci/issues/7310

TODO: handle image update ("Upgrade") for a container

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
20 files changed:
applications/luci-app-dockerman/Makefile
applications/luci-app-dockerman/README.md [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/containers.svg
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js [new file with mode: 0644]
applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json [new file with mode: 0644]
applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json
applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc [new file with mode: 0755]
applications/luci-app-dockerman/ucode/controller/docker.uc [new file with mode: 0644]
applications/luci-app-dockerman/ucode/docker_socket.uc [new file with mode: 0644]

index 69b62c162cac7fa901fef41e24918c039d95919a..920fd1bb1d65619421510d2f534582dbfd13e94e 100644 (file)
@@ -3,18 +3,16 @@ include $(TOPDIR)/rules.mk
 LUCI_TITLE:=LuCI Support for docker
 LUCI_DEPENDS:=@(aarch64||arm||x86_64) \
        +luci-base \
-       +luci-compat \
-       +luci-lib-docker \
        +docker \
        +ttyd \
        +dockerd \
-       +docker-compose
+       +docker-compose \
+       +ucode-mod-socket
 
 PKG_LICENSE:=AGPL-3.0
-PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com> \
+PKG_MAINTAINER:=Paul Donald <newtwen+github@gmail.com> \
                Florian Eckert <fe@dev.tdt.de>
 
-PKG_VERSION:=0.5.13.20241008
 
 include ../../luci.mk
 
diff --git a/applications/luci-app-dockerman/README.md b/applications/luci-app-dockerman/README.md
new file mode 100644 (file)
index 0000000..2b7d59a
--- /dev/null
@@ -0,0 +1,206 @@
+# Dockerman JS
+
+## Notice
+
+After dockerd _v27_, docker will **remove** the ability to listen on sockets of the form
+
+`xxx://x.x.x.x:2375` or `xxx://x.x.x.x:2376` (or `xxx://[2001:db8::1]:2375`)
+
+unless you run the daemon with various `--tls*` flags. That is, dockerd will *refuse*
+to start unless it is configured to use TLS. See
+[here](https://docs.docker.com/engine/security/#docker-daemon-attack-surface)
+[here](https://docs.docker.com/engine/deprecated/#unauthenticated-tcp-connections)
+and [here](https://docs.docker.com/engine/security/protect-access/).
+
+ucode is not yet capable of TLS, so if you want dockerd to listen on a port,
+you have a few options.
+
+Issues opened in the luci repo regarding connection setup will go unanswered.
+DIY.
+
+This implementation includes three methods to connect to the API. 
+
+
+# API Availability
+
+
+|                  | rpcd/CGI | Reverse Proxy  | Controller |
+|------------------|----------|----------------|------------|
+| API              |    ✅    |        ✅      |      ✅    |
+| File Stream      |    ❌    |        ✅      |      ✅    |
+| Console Start    |    ✅    |        ❌      |      ❌    |
+| Stream endpoints |    ❌    |        ✅      |      ✅    |
+
+* Stream endpoints are docker API paths that continue to stream data, like logs
+
+Dockerman uses a combination of rpcd and ucode Controller so API, Console via
+ttyd and File Streaming operations are available. dockerd is configured by
+default to use `unix:///var/run/docker.sock`, and is secure this way.
+
+
+It is possible to configure dockerd to listen on e.g.:
+
+`['unix:///var/run/docker.sock', 'tcp://0.0.0.0:2375']`
+
+when you have a Reverse Proxy configured.
+
+## Reverse Proxy
+
+Use nginx or Caddy to proxy connections to dockerd which is configured with
+`--tls*` flags, or communicates directly with `unix:///var/run/docker.sock`,
+which adds the necessary `Access-Control-Allow-Origin: ...`
+headers for browser clients. You might even be able to run a
+docker container that does this. If you don't want to set a proxy up, use a
+[browser plugin](#browser-plug-in).
+
+https://github.com/lucaslorentz/caddy-docker-proxy
+https://github.com/Tecnativa/docker-socket-proxy
+
+## LuCI
+
+Included is a ucode rpc API interface to talk with the docker socket, so all
+API calls are sent via rpcd, and appear as POST calls in your front end at e.g.
+
+http://192.168.1.1/cgi-bin/luci
+
+
+All calls to the docker API are authenticated with your session login.
+
+### Controller
+
+Included also is a ucode based controller to forward requests more directly to
+the docker API socket to avoid the rpc penalty, and stream file uploads and
+downloads. These are still authenticated with your session login. The methods
+to reach the controller API are defined in the menu JSON file. The controller
+API interface only exposes a limited subset of API methods.
+
+
+# Architecture
+
+## High-Level Architecture
+
+### rpcd and controller
+```
+┌──────────────────────────────────────────────────────────────────┐
+│                         OpenWrt/LuCI                             │
+│                                                                  │
+│  ┌─────────────────────┐                                         │
+│  │   Browser / UI      │                                         │
+│  │  containers.js      │                                         │
+│  │  images.js          │                                         │
+│  └──────────┬──────────┘                                         │
+│             │                                                    │
+│             │ 1. GET /admin/docker/container/inspect/id?x=y      │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │  LuCI Dispatcher         │                                    │
+│  │  (dispatcher.uc)         │                                    │
+│  │  - Parses URL path       │                                    │
+│  │  - Looks up action       │                                    │
+│  │  - Extracts query params │                                    │
+│  └──────────┬───────────────┘                                    │
+│             │                                                    │
+│             │ 2. Call controller function(env)                   │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │  HTTP Controller         │                                    │
+│  │  (docker.uc)             │                                    │
+│  │  - container_inspect(env)│                                    │
+│  │  - Gets params from env  │                                    │
+│  │  - Creates socket        │                                    │
+│  └──────────┬───────────────┘                                    │
+│             │                                                    │
+│             │ 3. Connect to Docker socket                        │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │  Docker Socket           │                                    │
+│  │  /var/run/docker.sock    │                                    │
+│  │  (AF_UNIX socket)        │                                    │
+│  └──────────┬───────────────┘                                    │
+│             │                                                    │
+│             │ 4. HTTP GET /v1.47/containers/{id}/json            │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │  Docker Daemon 200 OK    │                                    │
+│  │  - Creates JSON blob     │                                    │
+│  │  - Streams binary data   │                                    │
+│  └──────────┬───────────────┘                                    │
+│             │                                                    │
+│             │ 5. data chunks (32KB blocks)                       │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │  UHTTPd Web Server       │                                    │
+│  │  - Receives chunks       │                                    │
+│  │  - Writes to HTTP socket │                                    │
+│  │  (no buffering)          │                                    │
+│  └──────────┬───────────────┘                                    │
+│             │                                                    │
+│             │ 6. HTTP 200 + data stream                          │
+│             V                                                    │
+│  ┌──────────────────────────┐                                    │
+│  │   Browser                │                                    │
+│  │  - Receives data stream  │                                    │
+│  │  - Processes response    │                                    │
+│  │  - Displays result       │                                    │
+│  └──────────────────────────┘                                    │
+│                                                                  │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+## Request/Response Flow
+
+### Container Export Flow
+
+```
+Browser               Ucode Controller     Docker
+  │                      │                 │
+  ├─ GET /admin/docker   │                 │
+  │   /container/export  │                 │
+  │   /{id}?abc123 ─────>│                 │
+  │                      ├─ Get param 'id' │
+  │                      │ from env.http   │
+  │                      │                 │
+  │                      ├─ Create socket  │
+  │                      │                 │
+  │                      ├─ Connect to     │
+  │                      │ /var/run/       │
+  │                      │ docker.sock ────>
+  │                      │                 │
+  │                      │ <─ HTTP 200 OK  │
+  │                      │                 │
+  │                      │ <─ tar chunk 1  │
+  │                      │ <─ tar chunk 2  │
+  │ <─ HTTP 200 OK ──────│ <─ tar chunk 3  │
+  │ <─ tar chunk 1 ──────│ <─ ...          │
+  │ <─ tar chunk 2 ──────│ <─ EOF          │
+  │ <─ ...               │                 │
+  │                      │                 │
+  ├─ Done                │                 │
+  │                      ├─ Close socket   │
+  │                      │                 │
+```
+
+
+## Socket Connection Details
+
+```
+┌──────────────────────────────────────┐
+│     UHTTPd (Web Server)              │
+│  [Controller Process]                │
+└─────────────┬────────────────────────┘
+              │
+              │ AF_UNIX socket
+              │ (named pipe)
+              V
+┌──────────────────────────────────────┐
+│     Docker Daemon                    │
+│  /var/run/docker.sock                │
+└─────────────┬────────────────────────┘
+              │
+              │ HTTP Protocol
+              │ (over socket)
+              V
+      Docker API Engine
+      - Creates export tar
+      - Sends as chunked stream
+```
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js
new file mode 100644 (file)
index 0000000..b5bee3c
--- /dev/null
@@ -0,0 +1,1409 @@
+'use strict';
+'require form';
+'require fs';
+'require uci';
+'require ui';
+'require rpc';
+'require view';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+LICENSE: GPLv2.0
+*/
+
+// docker df endpoint can take a while to resolve everything on *big* docker setups
+// L.env.timeout = 40;
+
+// Start docker API RPC methods
+
+// If you define new declarations here, remember to export them at the bottom.
+const container_changes = rpc.declare({
+       object: 'docker.container',
+       method: 'changes',
+       params: { id: '' },
+});
+
+const container_create = rpc.declare({
+       object: 'docker.container',
+       method: 'create',
+       params: { query: { }, body: { } },
+});
+
+/* We don't use rpcd for export functions, use controller instead
+const container_export = rpc.declare({
+       object: 'docker.container',
+       method: 'export',
+       params: { id: '' },
+});
+*/
+
+const container_info_archive = rpc.declare({
+       object: 'docker.container',
+       method: 'info_archive',
+       params: { id: '', query: { path: '/' } },
+});
+
+const container_inspect = rpc.declare({
+       object: 'docker.container',
+       method: 'inspect',
+       params: { id: '', query: { } },
+});
+
+const container_kill = rpc.declare({
+       object: 'docker.container',
+       method: 'kill',
+       params: { id: '', query: { } },
+});
+
+const container_list = rpc.declare({
+       object: 'docker.container',
+       method: 'list',
+       params: { query: { } },
+});
+
+const container_logs = rpc.declare({
+       object: 'docker.container',
+       method: 'logs',
+       params: { id: '', query: { } },
+});
+
+const container_pause = rpc.declare({
+       object: 'docker.container',
+       method: 'pause',
+       params: { id: '' },
+});
+
+const container_prune = rpc.declare({
+       object: 'docker.container',
+       method: 'prune',
+       params: { query: { } },
+});
+
+const container_remove = rpc.declare({
+       object: 'docker.container',
+       method: 'remove',
+       params: { id: '', query: { } },
+});
+
+const container_rename = rpc.declare({
+       object: 'docker.container',
+       method: 'rename',
+       params: { id: '', query: { } },
+});
+
+const container_restart = rpc.declare({
+       object: 'docker.container',
+       method: 'restart',
+       params: { id: '', query: { } },
+});
+
+const container_start = rpc.declare({
+       object: 'docker.container',
+       method: 'start',
+       params: { id: '', query: { } },
+});
+
+const container_stats = rpc.declare({
+       object: 'docker.container',
+       method: 'stats',
+       params: { id: '', query: { 'stream': false, 'one-shot': true } },
+});
+
+const container_stop = rpc.declare({
+       object: 'docker.container',
+       method: 'stop',
+       params: { id: '', query: { } },
+});
+
+const container_top = rpc.declare({
+       object: 'docker.container',
+       method: 'top',
+       params: { id: '', query: { 'ps_args': '' } },
+});
+
+const container_unpause = rpc.declare({
+       object: 'docker.container',
+       method: 'unpause',
+       params: { id: '' },
+});
+
+const container_update = rpc.declare({
+       object: 'docker.container',
+       method: 'update',
+       params: { id: '', body: { } },
+});
+
+const container_ttyd_start = rpc.declare({
+       object: 'docker.container',
+       method: 'ttyd_start',
+       params: { id: '', cmd: '/bin/sh', port: 7682, uid: '' },
+});
+
+// Data Usage
+const docker_df = rpc.declare({
+       object: 'docker',
+       method: 'df',
+});
+
+const docker_events = rpc.declare({
+       object: 'docker',
+       method: 'events',
+       params: { query: { since: '', until: '', filters: '' } }
+});
+
+const docker_info = rpc.declare({
+       object: 'docker',
+       method: 'info',
+});
+
+const docker_version = rpc.declare({
+       object: 'docker',
+       method: 'version',
+});
+
+/* We don't use rpcd for import/build functions, use controller instead
+const image_build = rpc.declare({
+       object: 'docker.image',
+       method: 'build',
+       params: { query: { }, headers: { } },
+});
+*/
+
+const image_create = rpc.declare({
+       object: 'docker.image',
+       method: 'create',
+       params: { query: { }, headers: { } },
+});
+
+/* We don't use rpcd for export functions, use controller instead
+const image_get = rpc.declare({
+       object: 'docker.image',
+       method: 'get',
+       params: { id: '', query: { } },
+});
+*/
+
+const image_history = rpc.declare({
+       object: 'docker.image',
+       method: 'history',
+       params: { id: '' },
+});
+
+const image_inspect = rpc.declare({
+       object: 'docker.image',
+       method: 'inspect',
+       params: { id: '' },
+});
+
+const image_list = rpc.declare({
+       object: 'docker.image',
+       method: 'list',
+});
+
+const image_prune = rpc.declare({
+       object: 'docker.image',
+       method: 'prune',
+       params: { query: { } },
+});
+
+const image_push = rpc.declare({
+       object: 'docker.image',
+       method: 'push',
+       params: { name: '', query: { }, headers: { } },
+});
+
+const image_remove = rpc.declare({
+       object: 'docker.image',
+       method: 'remove',
+       params: { id: '', query: { } },
+});
+
+const image_tag = rpc.declare({
+       object: 'docker.image',
+       method: 'tag',
+       params: { id: '', query: { } },
+});
+
+const network_connect = rpc.declare({
+       object: 'docker.network',
+       method: 'connect',
+       params: { id: '', body: {} },
+});
+
+const network_create = rpc.declare({
+       object: 'docker.network',
+       method: 'create',
+       params: { body: {} },
+});
+
+const network_disconnect = rpc.declare({
+       object: 'docker.network',
+       method: 'disconnect',
+       params: { id: '', body: {} },
+});
+
+const network_inspect = rpc.declare({
+       object: 'docker.network',
+       method: 'inspect',
+       params: { id: '' },
+});
+
+const network_list = rpc.declare({
+       object: 'docker.network',
+       method: 'list',
+});
+
+const network_prune = rpc.declare({
+       object: 'docker.network',
+       method: 'prune',
+       params: { query: { } },
+});
+
+const network_remove = rpc.declare({
+       object: 'docker.network',
+       method: 'remove',
+       params: { id: '' },
+});
+
+const volume_create = rpc.declare({
+       object: 'docker.volume',
+       method: 'create',
+       params: { opts: {} },
+});
+
+const volume_inspect = rpc.declare({
+       object: 'docker.volume',
+       method: 'inspect',
+       params: { id: '' },
+});
+
+const volume_list = rpc.declare({
+       object: 'docker.volume',
+       method: 'list',
+});
+
+const volume_prune = rpc.declare({
+       object: 'docker.volume',
+       method: 'prune',
+       params: { query: { } },
+});
+
+const volume_remove = rpc.declare({
+       object: 'docker.volume',
+       method: 'remove',
+       params: { id: '', query: { } },
+});
+
+// End docker API RPC methods
+
+const callMountPoints = rpc.declare({
+       object: 'luci',
+       method: 'getMountPoints',
+       expect: { result: [] }
+});
+
+
+const callRcInit = rpc.declare({
+       object: 'rc',
+       method: 'init',
+       params: [ 'name', 'action' ],
+});
+
+// End generic API methods
+
+
+const builder = Object.freeze({
+       prune: {e: '✂️', i18n: _('prune')},
+});
+
+
+const container = Object.freeze({
+       attach: {e: '🔌', i18n: _('attach')},
+       commit: {e: '🎯', i18n: _('commit')},
+       copy: {e: '📃➡️📃', i18n: _('copy')},
+       create: {e: '➕', i18n: _('create')},
+       destroy: {e: '💥', i18n: _('destroy')},
+       detach: {e: '❌🔌', i18n: _('detach')},
+       die: {e: '🪦', i18n: _('die')},
+       exec_create: {e: '➕', i18n: _('exec_create')},
+       exec_detach: {e: '❌🔌', i18n: _('exec_detach')},
+       exec_start: {e: '▶️', i18n: _('exec_start')},
+       exec_die: {e: '🪦', i18n: _('exec_die')},
+       export: {e: '📤⬇️', i18n: _('export')},
+       health_status: {e: '🩺⚕️', i18n: _('health_status')},
+       kill: {e: '☠️', i18n: _('kill')},
+       oom: {e: '0️⃣🧠', i18n: _('oom')},
+       pause: {e: '⏸️', i18n: _('pause')},
+       rename: {e: '✍️', i18n: _('rename')},
+       resize: {e: '↔️', i18n: _('resize')},
+       restart: {e: '🔄', i18n: _('restart')},
+       start: {e: '▶️', i18n: _('start')},
+       stop: {e: '⏹️', i18n: _('stop')},
+       top: {e: '🔝', i18n: _('top')},
+       unpause: {e: '⏯️', i18n: _('unpause')},
+       update: {e: '✏️', i18n: _('update')},
+       prune: {e: '✂️', i18n: _('prune')},
+});
+
+
+const daemon = Object.freeze({
+       reload: {e: '🔄', i18n: _('reload')},
+});
+
+
+const image = Object.freeze({
+       create: {e: '➕', i18n: _('create')},
+       delete: {e: '❌', i18n: _('delete')},
+       import: {e: '➡️', i18n: _('Import')},
+       load: {e: '⬆️', i18n: _('load')},
+       pull: {e: '☁️⬇️', i18n: _('Pull')},
+       push: {e: '☁️⬆️', i18n: _('Push')},
+       save: {e: '💾', i18n: _('save')},
+       tag: {e: '🏷️', i18n: _('tag')},
+       untag: {e: '❌🏷️', i18n: _('untag')},
+       prune: {e: '✂️', i18n: _('prune')},
+});
+
+
+const network = Object.freeze({
+       create: {e: '➕', i18n: _('create')},
+       connect: {e: '🔗', i18n: _('connect')},
+       disconnect: {e: '⛓️‍💥', i18n: _('disconnect')},
+       destroy: {e: '💥', i18n: _('destroy')},
+       update: {e: '✏️', i18n: _('update')},
+       remove: {e: '❌', i18n: _('remove')},
+       prune: {e: '✂️', i18n: _('prune')},
+});
+
+
+const volume = Object.freeze({
+       create: {e: '➕', i18n: _('create')},
+       mount: {e: '⬆️', i18n: _('mount')},
+       unmount: {e: '⬇️', i18n: _('unmount')},
+       destroy: {e: '💥', i18n: _('destroy')},
+       prune: {e: '✂️', i18n: _('prune')},
+});
+
+
+const CURTypes = Object.freeze({
+       create: {e: '➕', i18n: _('create')},
+       update: {e: '✏️', i18n: _('update')},
+       remove: {e: '❌', i18n: _('remove')},
+});
+
+
+const config = CURTypes;
+const node = CURTypes;
+const secret = CURTypes;
+const service = CURTypes;
+
+
+const Types = Object.freeze({
+       builder: {e: '🛠️', i18n: _('builder'), sub: builder},
+       config: {e: '⚙️', i18n: _('config'), sub: config},
+       container: {e: '🐳', i18n: _('container'), sub: container},
+       daemon: {e: '🔁', i18n: _('daemon'), sub: daemon},
+       image: {e: '🌄', i18n: _('image'), sub: image},
+       network: {e: '모', i18n: _('network'), sub: network },
+       node: {e: '✳️', i18n: _('node'), sub: node },
+       plugin: {e: '🔌', i18n: _('plugin') },
+       secret: {e: '🔐', i18n: _('secret'), sub: secret },
+       service: {e: '🛎️', i18n: _('service'), sub: service },
+       volume: {e: '💿', i18n: _('volume'), sub: volume},
+});
+
+
+const ActionTypes = Object.freeze({
+       build: {e: '🏗️', i18n: _('Build')},
+       clean: {e: '🧹', i18n: _('Clean')},
+       create: {e: '🪄➕', i18n: _('Create')},
+       edit: {e: '✏️', i18n: _('Edit')},
+       force_remove: {e: '❌', i18n: _('Force remove')},
+       history: {e: '🪶📜', i18n: _('History')},
+       inspect: {e: '🔎', i18n: _('Inspect') },
+       remove: {e: '❌', i18n: _('Remove')},
+       save: {e: '⬇️', i18n: _('Save locally')},
+       upload: {e: '⬆️', i18n: _('Upload')},
+       prune: {e: '✂️', i18n: _('Prune')},
+});
+
+
+const ignored_headers = ['cache-control', 'connection', 'content-length', 'content-type', 'pragma',
+       'Components', 'Platform'];
+
+const dv = view.extend({
+       outputText: '', // Initialize output text
+
+       get dockerman_url() {
+               return L.url('admin/services/dockerman');
+       },
+
+       parseHeaders(headers, array) {
+               for(const [k, v] of Object.entries(headers)) {
+                       if (ignored_headers.includes(k)) continue;
+                       array.push({entry: k, value: v});
+               }
+       },
+
+       parseBody(body, array) {
+               for(const [k, v] of Object.entries(body)) {
+                       if (ignored_headers.includes(k)) continue;
+                       if (!v) continue;
+                       array.push({entry: k, value: (typeof v !== 'string') ? JSON.stringify(v) : v});
+               }
+       },
+
+       rwxToMode(val) {
+               if (!val) return undefined;
+               const raw = String(val).trim();
+               if (/^[0-7]+$/.test(raw)) return parseInt(raw, 8);
+               const normalized = raw.replace(/[^rwx-]/gi, '').padEnd(9, '-').slice(0, 9);
+               const chunkToNum = (chunk) => (
+                       (chunk[0] === 'r' ? 4 : 0) +
+                       (chunk[1] === 'w' ? 2 : 0) +
+                       (chunk[2] === 'x' ? 1 : 0)
+               );
+               const owner = chunkToNum(normalized.slice(0, 3));
+               const group = chunkToNum(normalized.slice(3, 6));
+               const other = chunkToNum(normalized.slice(6, 9));
+               return (owner << 6) + (group << 3) + other;
+       },
+
+       modeToRwx(mode) {
+               const perms = mode & 0o777; // extract permission bits
+
+               const toRwx = n => 
+                       ((n & 4) ? 'r' : '-') +
+                       ((n & 2) ? 'w' : '-') +
+                       ((n & 1) ? 'x' : '-');
+
+               const owner = toRwx((perms >> 6) & 0b111);
+               const group = toRwx((perms >> 3) & 0b111);
+               const world = toRwx(perms & 0b111);
+
+               return `${owner}${group}${world}`;
+       },
+
+       parseMemory(value) {
+               if (!value) return 0;
+               const rex = /^([0-9.]+) *([bkmgt])?i? *[Bb]?/i;
+               let [, amount, unit] = rex.exec(value.toLowerCase());
+               amount = amount ? Number.parseFloat(amount) : 0;
+               switch (unit) {
+               default: break;
+               case 'k': amount *= (2 ** 10); break;
+               case 'm': amount *= (2 ** 20); break;
+               case 'g': amount *= (2 ** 30); break;
+               case 't': amount *= (2 ** 40); break;
+               case 'p': amount *= (2 ** 50); break;
+               }
+               return amount;
+       },
+
+       listToKv: (list) => {
+               const kv = {};
+               const items = Array.isArray(list) ? list : (list != null ? [list] : []);
+               items.forEach((entry) => {
+                       if (typeof entry !== 'string')
+                               return;
+
+                       const pos = entry.indexOf('=');
+                       if (pos <= 0)
+                               return;
+
+                       const key = entry.slice(0, pos);
+                       const val = entry.slice(pos + 1);
+                       if (key)
+                               kv[key] = val;
+               });
+               return kv;
+       },
+
+       objectToText(object) {
+               let result = '';
+               if (!object || typeof object !== 'object') return result;
+               if (Object.keys(object).length === 0) return result;
+               for (const [k, v] of Object.entries(object))
+                       result += `${!result ? '' : ', '}${k}: ${typeof v === 'object' ? this.objectToText(v) : v}`
+               return result;
+       },
+
+       objectCfgValueTT(sid) {
+               const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
+               return (val != null && typeof val === 'object') ? dv.prototype.objectToText.call(dv.prototype, val) : val;
+       },
+
+       insertOutputFrame(s, m) {
+               const frame = E('div', {
+                               'class': 'cbi-section'
+                       }, [
+                               E('h3', {}, _('Operational Output')),
+                               E('textarea', {
+                                       'readonly': true,
+                                       'rows': 30,
+                                       'style': 'width: 100%; font-family: monospace;',
+                                       'id': 'inspect-output-text'
+                               }, this.outputText)
+                       ]);
+               if (!m) return frame;
+               // Output section, for inspect results
+               s = m.section(form.NamedSection, null, 'inspect');
+               s.anonymous = true;
+               s.render = L.bind(() => {
+                       return frame;
+               }, this);
+       },
+
+       insertOutput(text) {
+               // send text to the output text-area and scroll to bottom
+               this.outputText = text;
+               const textarea = document.getElementById('inspect-output-text');
+               if (textarea) {
+                       textarea.value = this.outputText;
+                       textarea.scrollTop = textarea.scrollHeight;
+               }
+       },
+
+       buildTimeString(unixtime) {
+               return new Date(unixtime * 1000).toLocaleDateString([], {
+                       year: 'numeric',
+                       month: '2-digit',
+                       day: '2-digit',
+                       hour: '2-digit',
+                       minute: '2-digit',
+                       second: '2-digit',
+                       hour12: false
+               });
+       },
+
+       buildNetworkListValues(networks, option) {
+               for (const network of networks) {
+                       let name = `${network?.Name}`;
+                       name += network?.Driver ? ` | ${network?.Driver}` : '';
+                       name += network?.IPAM?.Config?.[0] ? ` | ${network?.IPAM?.Config?.[0]?.Subnet}` : '';
+                       name += network?.IPAM?.Config?.[1] ? ` | ${network?.IPAM?.Config?.[1]?.Subnet}` : '';
+                       option.value(network?.Name, name);
+               }
+       },
+
+       getContainerStatus(this_container) {
+               if (!this_container?.State)
+                       return 'unknown';
+               const state = this_container.State;
+               if (state.Status === 'paused')
+                       return 'paused';
+               else if (state.Running && !state.Restarting)
+                       return 'running';
+               else if (state.Running && state.Restarting)
+                       return 'restarting';
+               return 'stopped';
+       },
+
+       getImageFirstTag(image_list, image_id) {
+               const imageArray = Array.isArray(image_list) ? image_list : [];
+               const imageInfo = imageArray.find(img => img?.Id === image_id);
+               const imageName = imageInfo && Array.isArray(imageInfo.RepoTags) 
+                       ? imageInfo.RepoTags[0] 
+                       : 'unknown';
+               return imageName;
+       },
+
+       parseNetworkLinksForContainer(networks, containerNetworks, name_links) {
+               const links = [];
+
+               if (!Array.isArray(containerNetworks)) {
+                       if (containerNetworks && typeof containerNetworks === 'object')
+                               containerNetworks = Object.values(containerNetworks);
+               }
+
+               for (const cNet of containerNetworks) {
+                       const network = networks.find(n => 
+                               n.Name === cNet.Name || 
+                               n.Id === cNet?.NetworkID ||
+                               n.Id === cNet.Name
+                       );
+
+                       if (network) {
+                               links.push(E('a', {
+                                       href: `${this.dockerman_url}/network/${network.Id}`,
+                                       title: network.Id,
+                                       style: 'white-space: nowrap;'
+                               }, [name_links ? network.Name : network.Id.slice(0,12)]));
+                       }
+               }
+
+               if (!links.length)
+                       return '-';
+
+               // Join with pipes
+               const out = [];
+               for (let i = 0; i < links.length; i++) {
+                       out.push(links[i]);
+                       if (i < links.length - 1)
+                               out.push(' | ');
+               }
+
+               return E('div', {}, out);
+       },
+
+       parseContainerLinksForNetwork(network, containers) {
+               // Find all containers connected to this network
+               const containerLinks = [];
+               for (const cont of containers) {
+                       let isConnected = false;
+                       if (cont.NetworkSettings?.Networks?.[network?.Name]) {
+                               isConnected = true;
+                       }
+                       else
+                       if (cont.NetworkSettings?.Networks?.[network?.Id]) {
+                               isConnected = true;
+                       }
+
+                       if (isConnected) {
+                               const containerName = cont.Names?.[0]?.replace(/^\//, '') || cont.Id?.substring(0, 12);
+                               const containerId = cont.Id;
+
+                               containerLinks.push(E('a', {
+                                       href: `${this.dockerman_url}/container/${containerId}`,
+                                       title: containerId,
+                                       style: 'white-space: nowrap;'
+                               }, [containerName]));
+                       }
+               }
+
+               if (!containerLinks.length)
+                       return '-';
+
+               // Join with pipes
+               const out = [];
+               for (let i = 0; i < containerLinks.length; i++) {
+                       out.push(containerLinks[i]);
+                       if (i < containerLinks.length - 1)
+                               out.push(' | ');
+               }
+
+               return E('div', {}, out);
+       },
+
+       statusColor(status) {
+               const s = (status || '').toLowerCase();
+               if (s === 'running') return '#2ecc71'; // green
+               if (s === 'paused') return '#f39c12'; // orange
+               if (s === 'restarting') return '#f39c12'; // orange
+               return '#d9534f'; // red for stopped/other
+       },
+
+       wrapStatusText(text, status, extraStyle = '') {
+               const color = this.statusColor(status);
+               return E('span', { style: `color:${color};${extraStyle || ''}` }, [text]);
+       },
+
+       /**
+        * Show a notification to the user with standardized formatting
+        * @param {string} title - The title of the notification (will be translated if needed)
+        * @param {string|Array<string>} message - Message(s) to display
+        * @param {number} [duration=5000] - Duration in milliseconds
+        * @param {string} [type='info'] - Type: 'success', 'info', 'warning', 'error'
+        */
+       showNotification(title, message, duration = 5000, type = 'info') {
+               const messages = Array.isArray(message) ? message : [message];
+               ui.addTimeLimitedNotification(title, messages, duration, type);
+       },
+
+       /**
+        * Normalize a registry host address by stripping scheme and path
+        * @param {string} address - The registry address to normalize
+        * @returns {string|null} - Normalized hostname or null
+        */
+       normalizeRegistryHost(address) {
+               if (!address) return null;
+
+               let addr = String(address).trim();
+               // make exception for legacy Docker Hub registry https://index.docker.io/v1/
+               if (addr.includes('index.docker.io'))
+                       return addr.toLowerCase();
+               else {
+                       addr = addr.replace(/^[a-z]+:\/\//i, '');
+                       addr = addr.split('/')[0];
+                       addr = addr.replace(/\/$/, '');
+               }
+               if (!addr) return null;
+               return addr.toLowerCase();
+       },
+
+       /**
+        * Ensure registry address has https:// scheme
+        * @param {string} address - The registry address
+        * @param {string} hostFallback - Fallback host if address is empty
+        * @returns {string|null} - Address with scheme or null
+        */
+       ensureRegistryScheme(address, hostFallback) {
+               const addr = String(address || '').trim() || hostFallback;
+               if (!addr) return null;
+               return /^https?:\/\//i.test(addr) ? addr : `https://${addr}`;
+       },
+
+       /**
+        * Encode auth object to base64
+        * @param {Object} obj - Object with username, password, serveraddress
+        * @returns {string|null} - Base64 encoded JSON or null on failure
+        */
+       encodeBase64Json(obj) {
+               const json = JSON.stringify(obj);
+               try {
+                       return btoa(json);
+               } catch (err) {
+                       try {
+                               return btoa(unescape(encodeURIComponent(json)));
+                       } catch (err2) {
+                               console.warn('Failed to encode registry auth', err2?.message || err2);
+                               return null;
+                       }
+               }
+       },
+
+       /**
+        * Extract registry host from image tag
+        * @param {string} tag - The image tag
+        * @returns {string|null} - Registry hostname or null
+        */
+       extractRegistryHostFromImage(tag) {
+               if (!tag) return null;
+
+               let ref = String(tag).trim();
+               ref = ref.replace(/^[a-z]+:\/\//i, '');
+
+               const slashIdx = ref.indexOf('/');
+               const candidate = slashIdx === -1 ? ref : ref.slice(0, slashIdx);
+               if (!candidate) return null;
+
+               const hasDot = candidate.includes('.');
+               const hasPort = /:[0-9]+$/.test(candidate);
+               const isLocal = candidate === 'localhost' || candidate.startsWith('localhost:');
+               if (!hasDot && !hasPort && !isLocal) return null;
+
+               return candidate.toLowerCase();
+       },
+
+       /**
+        * Resolve registry credentials and build auth header
+        * @param {string} imageRef - The image reference
+        * @param {Map} registryAuthMap - Map of registry host to credentials
+        * @returns {string|null} - Base64 encoded auth string or null
+        */
+       resolveRegistryAuth(imageRef, registryAuthMap) {
+               const host = this.extractRegistryHostFromImage(imageRef);
+               if (!host) return null;
+
+               const creds = registryAuthMap.get(host);
+               if (!creds?.username || !creds?.password) return null;
+
+               return this.encodeBase64Json({
+                       username: creds.username,
+                       password: creds.password,
+                       serveraddress: this.ensureRegistryScheme(creds.serveraddress, host)
+               });
+       },
+
+       /**
+        * Load registry auth credentials from UCI config
+        * @returns {Promise<Map>} - Promise resolving to Map of registry host to credentials
+        */
+       loadRegistryAuthMap() {
+               return new Promise((resolve) => {
+                       // Load UCI and extract auth sections
+                       const authMap = new Map();
+                       L.resolveDefault(uci.load('dockerd'), {}).then(() => {
+                               uci.sections('dockerd', 'auth', (section) => {
+                                       const serverRaw = section?.serveraddress;
+                                       const host = this.normalizeRegistryHost(serverRaw);
+                                       if (!host) return;
+
+                                       const username = section?.username || section?.user;
+                                       const password = section?.token || section?.password;
+                                       if (!username || !password) return;
+
+                                       authMap.set(host, {
+                                               username,
+                                               password,
+                                               serveraddress: serverRaw || host,
+                                       });
+                               });
+                               resolve(authMap);
+                       }).catch(() => {
+                               // If loading fails, return empty map
+                               resolve(authMap);
+                       });
+               });
+       },
+
+       /**
+        * Handle Docker API response with unified error checking and user feedback
+        * @param {Object} response - The Docker API response object
+        * @param {string} actionName - Name of the action (e.g., 'Start', 'Remove')
+        * @param {Object} [options={}] - Optional configuration
+        * @param {boolean} [options.showOutput=true] - Whether to insert JSON output
+        * @param {boolean} [options.showSuccess=true] - Whether to show success notification
+        * @param {string} [options.successMessage] - Custom success message
+        * @param {number} [options.successDuration=4000] - Success notification duration
+        * @param {number} [options.errorDuration=7000] - Error notification duration
+        * @param {Function} [options.onSuccess] - Callback on success
+        * @param {Function} [options.onError] - Callback on error
+        * @param {Object} [options.specialCases] - Map of status codes to handlers {304: {message: '...', type: 'notice'}}
+        * @returns {boolean} - true if successful, false otherwise
+        */
+       handleDockerResponse(response, actionName, options = {}) {
+               const {
+                       showOutput = true,
+                       showSuccess = true,
+                       successMessage = _('OK'),
+                       successDuration = 4000,
+                       errorDuration = 7000,
+                       onSuccess = null,
+                       onError = null,
+                       specialCases = {}
+               } = options;
+
+               // Handle special status codes first (e.g., 304 Not Modified)
+               if (specialCases[response?.code]) {
+                       const special = specialCases[response.code];
+                       this.showNotification(
+                               actionName,
+                               special.message || _('No changes needed'),
+                               special.duration || 5000,
+                               special.type || 'notice'
+                       );
+                       if (onSuccess) onSuccess(response);
+                       return true;
+               }
+
+               // Insert output if requested
+               if (showOutput && response?.body != null) {
+                       const outputText = response?.body !== "" 
+                               ? (Array.isArray(response.body) || typeof response.body === 'object' 
+                                       ? JSON.stringify(response.body, null, 2) + '\n' 
+                                       : String(response.body) + '\n')
+                               : `${response?.code} ${_('OK')}\n`;
+                       this.insertOutput(outputText);
+               }
+
+               // Check for errors (HTTP status >= 304)
+               if (response?.code >= 304) {
+                       this.showNotification(
+                               actionName,
+                               response?.body?.message || _('Operation failed'),
+                               errorDuration,
+                               'warning'
+                       );
+                       if (onError) onError(response);
+                       return false;
+               }
+
+               // Success case
+               if (showSuccess) {
+                       this.showNotification(actionName, successMessage, successDuration, 'success');
+               }
+               if (onSuccess) onSuccess(response);
+               return true;
+       },
+
+       async getRegistryAuth(params, actionName) {
+               // Extract registry candidate from params
+               let registryCandidate = null;
+               if (params?.query?.fromImage) {
+                       registryCandidate = params.query.fromImage;
+               } else if (params?.query?.tag) {
+                       registryCandidate = params.query.tag;
+               }
+
+               if (params?.name && actionName === Types['image'].sub['push'].i18n) {
+                       registryCandidate = params.name;
+               }
+
+               // Try to load and inject registry auth if we have a registry candidate
+               if (registryCandidate) {
+                       try {
+                               const authMap = await this.loadRegistryAuthMap();
+                               const auth = this.resolveRegistryAuth(registryCandidate, authMap);
+                               if (auth) {
+                                       if (!params.headers) {
+                                               params.headers = {};
+                                       }
+                                       params.headers['X-Registry-Auth'] = auth;
+                               }
+                       } catch (err) {
+                               // If auth loading fails, proceed without auth
+                       }
+               }
+
+               return params;
+       },
+
+       /**
+        * Execute a Docker API action with consistent error handling and user feedback
+        * Automatically adds X-Registry-Auth header for push/pull operations if credentials exist
+        * @param {Function} apiMethod - The Docker API method to call
+        * @param {Object} params - Parameters to pass to the API method
+        * @param {string} actionName - Display name for the action
+        * @param {Object} [options={}] - Options for handleDockerResponse
+        * @returns {Promise<boolean>} - Promise that resolves to true/false based on success
+        */
+       async executeDockerAction(apiMethod, params, actionName, options = {}) {
+               try {
+                       params = await this.getRegistryAuth(params, actionName);
+
+                       // Execute the API call
+                       const response = await apiMethod(params);
+                       return this.handleDockerResponse(response, actionName, options);
+
+               } catch (err) {
+                       this.showNotification(
+                               actionName,
+                               err?.message || String(err) || _('Unexpected error'),
+                               options.errorDuration || 7000,
+                               'error'
+                       );
+                       if (options.onError) options.onError(err);
+                       return false;
+               }
+       },
+
+       /**
+        * Flexible file/URI transfer with progress tracking and API preference
+        * @param {Object} options - Upload configuration
+        * @param {string} [options.method] - method to use: POST, PUT, etc
+        * @param {string} [options.commandCPath] - controller API endpoint path (e.g. '/images/load')
+        * @param {string} [options.commandDPath] - docker API endpoint path (e.g. '/images/load')
+        * @param {string} [options.commandTitle] - Title for the command modal
+        * @param {string} [options.commandMessage] - Message shown during command
+        * @param {string} [options.successMessage] - Message on successful command
+        * @param {string} [options.pathElementId] - Optional ID of element containing command path
+        * @param {string} [options.defaultPath='/'] - Default path if pathElementId is not provided
+        * @param {Function} [options.getFormData] - Optional function to customize FormData (receives file, path)
+        * @param {Function} [options.onUpdate] - Optional function to report status progress fed back
+        * @param {boolean} [options.noFileUpload] - If true, only a URI is uploaded (no file)
+        */
+       async handleXHRTransfer(options = {}) {
+               const {
+                       q_params = {},
+                       method = 'POST',
+                       commandCPath = null,
+                       commandDPath = null,
+                       commandTitle = null, //_('Uploading…'),
+                       commandMessage = null, //_('Uploading file…'),
+                       successMessage = _('Successful'),
+                       showProgress = true,
+                       pathElementId = null,
+                       defaultPath = '/',
+                       getFormData = null,
+                       onUpdate = null,
+                       noFileUpload = false,
+               } = options;
+
+               const view = this;
+               let commandPath = defaultPath;
+               let params = await this.getRegistryAuth(q_params, commandTitle);
+
+               // Get path from element if specified
+               if (pathElementId) {
+                       commandPath = document.getElementById(pathElementId)?.value || defaultPath;
+                       if (!commandPath || commandPath === '') {
+                               this.showNotification(_('Error'), _('Please specify a path'), 5000, 'error');
+                               return;
+                       }
+               }
+
+               // Build query string if params provided
+               let query_str = '';
+               if (params.query) {
+                       let parts = [];
+                       for (let [key, value] of Object.entries(params.query)) {
+                               if (key != null && value != '') {
+                                       if (Array.isArray(value)) {
+                                               value.map(e => parts.push(`${key}=${e}`));
+                                               continue;
+                                       }
+                                       parts.push(`${key}=${value}`);
+                               }
+                       }
+                       if (parts.length)
+                               query_str = '?' + parts.join('&');
+               }
+
+               // Prefer JS API if available, else fallback to controller
+               let destUrl = `${this.dockerman_url}${commandCPath}${query_str}`;
+               let useRawFile = false;
+
+               // Show progress dialog with progress bar element
+               let progressBar = E('div', { 
+                       'style': 'width:0%; background-color: #0066cc; height: 20px; border-radius: 3px; transition: width 0.3s ease;' 
+               });
+               let msgTxt = E('p', {}, commandMessage);
+               let msgTitle = E('h4', {}, commandTitle);
+               let progressText = E('p', {}, '0%');
+
+               if (showProgress) {
+                       ui.showModal(msgTitle, [
+                               msgTxt,
+                               progressText,
+                               E('div', { 
+                                       'class': 'cbi-progressbar', 
+                                       'style': 'margin: 10px 0; background-color: #e0e0e0; border-radius: 3px; overflow: hidden;' 
+                               }, progressBar)
+                       ]);
+               }
+
+               const xhr = new XMLHttpRequest();
+               xhr.timeout = 0;
+
+               // Track upload progress
+               xhr.upload.addEventListener('progress', (e) => {
+                       if (e.lengthComputable) {
+                               const percentComplete = Math.round((e.loaded / e.total) * 100);
+                               progressBar.style.width = percentComplete + '%';
+                               progressText.textContent = percentComplete + '%';
+                       }
+               });
+
+               // Track progressive response progress
+               let lastIndex = 0;
+               let title = _('Progress');
+               xhr.onprogress = (upd) => {
+                       const chunk = xhr.responseText.slice(lastIndex);
+                       lastIndex = xhr.responseText.length;
+                       const lines = chunk.split('\n').filter(Boolean);
+                       for (const line of lines) {
+                               try {
+                                       const msg = JSON.parse(line);
+                                       const percentComplete = Math.round((msg?.progressDetail?.current / msg?.progressDetail?.total) * 100) || 0;
+                                       if (msg.stream && msg.stream != '\n')
+                                               msgTxt.innerHTML = ansiToHtml(msg?.stream);
+                                       if (msg.status)
+                                               msgTitle.innerHTML = msg?.status
+                                       progressBar.style.width = percentComplete + '%';
+                                       progressText.textContent = percentComplete + '%';
+                                       if (onUpdate) onUpdate(msg);
+                               } catch (e) {}
+                       }
+               };
+
+               xhr.addEventListener('load', () => {
+                       ui.hideModal();
+                       if (xhr.status >= 200 && xhr.status < 300) {
+                               view.showNotification(
+                                       _('Command successful'),
+                                       successMessage,
+                                       4000,
+                                       'success'
+                               );
+                       } else {
+                               let errorMsg = xhr.responseText || `HTTP ${xhr.status}`;
+                               try {
+                                       const json = JSON.parse(xhr.responseText);
+                                       errorMsg = json.error || errorMsg;
+                               } catch (e) {}
+                               view.showNotification(
+                                       _('Command failed'),
+                                       errorMsg,
+                                       7000,
+                                       'error'
+                               );
+                       }
+               });
+
+               xhr.addEventListener('error', () => {
+                       ui.hideModal();
+                       view.showNotification(
+                               _('Command failed'),
+                               _('Network error'),
+                               7000,
+                               'error'
+                       );
+               });
+
+               xhr.addEventListener('abort', () => {
+                       ui.hideModal();
+                       view.showNotification(
+                               _('Command cancelled'),
+                               '',
+                               5000,
+                               'warning'
+                       );
+               });
+
+               if (noFileUpload) {
+                       this.handleURLOnlyForm(xhr, method, params, destUrl);
+               } else {
+                       this.handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile);
+               }
+       },
+
+
+       handleURLOnlyForm(xhr, method, params, destUrl) {
+               const formData = new FormData();
+               formData.append('token', L.env.token);
+               // if (params.name)
+               //      formData.append('name', params.name);
+
+               xhr.open(method, destUrl);
+               xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+               if (params.headers)
+                       for (let [hdr_name, hdr_value] of Object.entries(params.headers)) {
+                               xhr.setRequestHeader(hdr_name, hdr_value);
+                               // smuggle in the X-Registry-Auth header in the form data
+                               formData.append(hdr_name, hdr_value);
+                       }
+               destUrl.includes(L.env.scriptname) ? xhr.send(formData) : xhr.send();
+       },
+
+
+       handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile) {
+               const fileInput = document.createElement('input');
+               fileInput.type = 'file';
+               fileInput.style.display = 'none';
+
+               fileInput.onchange = (ev) => {
+                       const files = ev.target?.files;
+                       if (!files || files.length === 0) {
+                               ui.hideModal();
+                               return;
+                       }
+                       const file = files[0];
+
+                       // Create FormData with file
+                       let formData;
+                       if (getFormData) {
+                               formData = getFormData(file, commandPath);
+                       } else {
+                               formData = new FormData();
+                               /* 'token' is necessary when "post": true is defined for image load endpoint */
+                               formData.append('token', L.env.token);
+                               formData.append('upload-name', file.name);
+                               formData.append('upload-archive', file);
+                       }
+
+                       xhr.open(method, destUrl);
+                       xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+                       
+                       if (useRawFile) {
+                               xhr.setRequestHeader('Content-Type', 'application/x-tar');
+                               xhr.send(file);
+                       } else {
+                               xhr.send(formData);
+                       }
+               };
+
+               fileInput.oncancel = (ev) => {
+                       ui.hideModal();
+                       return;
+               }
+
+               // Trigger file picker
+               document.body.appendChild(fileInput);
+               fileInput.click();
+               document.body.removeChild(fileInput);
+       },
+});
+
+// ANSI color code converter to HTML
+const ansiToHtml = function(text) {
+       if (!text) return '';
+
+       // First, strip out terminal control sequences that aren't color codes
+       // These include cursor positioning, screen clearing, etc.
+       text = text
+               // Strip CSI sequences (cursor movement, screen clearing, etc.)
+               .replace(/\x1B\[[0-9;?]*[A-Za-z]/g, (match) => {
+                       // Keep only SGR (Select Graphic Rendition) sequences ending in 'm'
+                       if (match.endsWith('m')) {
+                               return match;
+                       }
+                       // Strip everything else (cursor positioning, screen clearing, etc.)
+                       return '';
+               })
+               // Strip OSC sequences (window title, etc.)
+               .replace(/\x1B\][^\x07]*\x07/g, '')
+               // Strip other escape sequences
+               .replace(/\x1B[><=]/g, '')
+               // Strip bell character
+               .replace(/\x07/g, '');
+
+       // ANSI color codes mapping
+       const ansiColorMap = {
+               '30': '#000000', // Black
+               '31': '#FF5555', // Red
+               '32': '#55FF55', // Green
+               '33': '#FFFF55', // Yellow
+               '34': '#5555FF', // Blue
+               '35': '#FF55FF', // Magenta
+               '36': '#55FFFF', // Cyan
+               '37': '#FFFFFF', // White
+               '90': '#555555', // Bright Black
+               '91': '#FF8787', // Bright Red
+               '92': '#87FF87', // Bright Green
+               '93': '#FFFF87', // Bright Yellow
+               '94': '#8787FF', // Bright Blue
+               '95': '#FF87FF', // Bright Magenta
+               '96': '#87FFFF', // Bright Cyan
+               '97': '#FFFFFF', // Bright White
+       };
+
+       // Escape HTML special characters
+       const escapeHtml = (str) => {
+               const map = {
+                       '&': '&amp;',
+                       '<': '&lt;',
+                       '>': '&gt;',
+                       '"': '&quot;',
+                       "'": '&#039;'
+               };
+               return str.replace(/[&<>"']/g, m => map[m]);
+       };
+
+       // Split by ANSI escape sequences and process
+       const ansiRegex = /\x1B\[([\d;]*)m/g;
+       let html = '';
+       let currentStyle = {};
+       let lastIndex = 0;
+       let match;
+       let textBuffer = '';
+
+       // Helper to flush current text with current style
+       const flushText = () => {
+               if (textBuffer) {
+                       const escaped = escapeHtml(textBuffer);
+                       if (Object.keys(currentStyle).length > 0) {
+                               let styleStr = '';
+                               if (currentStyle.color) {
+                                       styleStr += `color: ${currentStyle.color};`;
+                               }
+                               if (currentStyle.bgColor) {
+                                       styleStr += `background-color: ${currentStyle.bgColor};`;
+                               }
+                               if (currentStyle.bold) {
+                                       styleStr += 'font-weight: bold;';
+                               }
+                               if (currentStyle.italic) {
+                                       styleStr += 'font-style: italic;';
+                               }
+                               if (currentStyle.underline) {
+                                       styleStr += 'text-decoration: underline;';
+                               }
+                               if (styleStr) {
+                                       html += `<span style="${styleStr}">${escaped}</span>`;
+                               } else {
+                                       html += escaped;
+                               }
+                       } else {
+                               html += escaped;
+                       }
+                       textBuffer = '';
+               }
+       };
+
+       while ((match = ansiRegex.exec(text)) !== null) {
+               // Add text before this escape sequence
+               if (match.index > lastIndex) {
+                       textBuffer += text.substring(lastIndex, match.index);
+               }
+
+               // Flush current text with old style before changing style
+               flushText();
+
+               const codes = match[1] ? match[1].split(';').map(Number) : [0];
+
+               for (const code of codes) {
+                       if (code === 0) {
+                               // Reset all styles
+                               currentStyle = {};
+                       } else if (code === 1) {
+                               // Bold
+                               currentStyle.bold = true;
+                       } else if (code === 3) {
+                               // Italic
+                               currentStyle.italic = true;
+                       } else if (code === 4) {
+                               // Underline
+                               currentStyle.underline = true;
+                       } else if (code >= 30 && code <= 37) {
+                               // Standard foreground color
+                               currentStyle.color = ansiColorMap[code];
+                       } else if (code >= 90 && code <= 97) {
+                               // Bright foreground color
+                               currentStyle.color = ansiColorMap[code];
+                       } else if (code >= 40 && code <= 47) {
+                               // Background color
+                               currentStyle.bgColor = ansiColorMap[code - 10];
+                       }
+               }
+
+               lastIndex = match.index + match[0].length;
+       }
+
+       // Add any remaining text
+       if (lastIndex < text.length) {
+               textBuffer += text.substring(lastIndex);
+       }
+       flushText();
+
+       // Convert newlines and carriage returns to <br/>
+       html = html.replace(/\r\n/g, '<br/>').replace(/\r/g, '<br/>').replace(/\n/g, '<br/>');
+
+       return html;
+};
+
+return L.Class.extend({
+       Types: Types,
+       ActionTypes: ActionTypes,
+       ansiToHtml: ansiToHtml,
+       callMountPoints: callMountPoints,
+       callRcInit: callRcInit,
+       dv: dv,
+       container_changes: container_changes,
+       container_create: container_create,
+       // container_export: container_export, // use controller instead
+       container_info_archive: container_info_archive,
+       container_inspect: container_inspect,
+       container_kill: container_kill,
+       container_list: container_list,
+       container_logs: container_logs,
+       container_pause: container_pause,
+       container_prune: container_prune,
+       container_remove: container_remove,
+       container_rename: container_rename,
+       container_restart: container_restart,
+       container_start: container_start,
+       container_stats: container_stats,
+       container_stop: container_stop,
+       container_top: container_top,
+       container_ttyd_start: container_ttyd_start,
+       container_unpause: container_unpause,
+       container_update: container_update,
+       docker_df: docker_df,
+       docker_events: docker_events,
+       docker_info: docker_info,
+       docker_version: docker_version,
+       // image_build: image_build, // use controller instead
+       image_create: image_create,
+       // image_get: image_get, // use controller instead
+       image_history: image_history,
+       image_inspect: image_inspect,
+       image_list: image_list,
+       image_prune: image_prune,
+       image_push: image_push,
+       image_remove: image_remove,
+       image_tag: image_tag,
+       network_connect: network_connect,
+       network_create: network_create,
+       network_disconnect: network_disconnect,
+       network_inspect: network_inspect,
+       network_list: network_list,
+       network_prune: network_prune,
+       network_remove: network_remove,
+       volume_create: volume_create,
+       volume_inspect: volume_inspect,
+       volume_list: volume_list,
+       volume_prune: volume_prune,
+       volume_remove: volume_remove,
+});
index 4165f90bdc35e17a06ed69860eccd767c2761679..eba6cc41e6e7539d22119e78431c757f20b363c3 100644 (file)
@@ -1,7 +1,12 @@
-<?xml version="1.0" standalone="no"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-       <title>Docker icon</title>
-       <path d="M4.82 17.275c-.684 0-1.304-.56-1.304-1.24s.56-1.243 1.305-1.243c.748 0 1.31.56 1.31 1.242s-.622 1.24-1.305 1.24zm16.012-6.763c-.135-.992-.75-1.8-1.56-2.42l-.315-.25-.254.31c-.494.56-.69 1.553-.63 2.295.06.562.24 1.12.554 1.554-.254.13-.568.25-.81.377-.57.187-1.124.25-1.68.25H.097l-.06.37c-.12 1.182.06 2.42.562 3.54l.244.435v.06c1.5 2.483 4.17 3.6 7.078 3.6 5.594 0 10.182-2.42 12.357-7.633 1.425.062 2.864-.31 3.54-1.676l.18-.31-.3-.187c-.81-.494-1.92-.56-2.85-.31l-.018.002zm-8.008-.992h-2.428v2.42h2.43V9.518l-.002.003zm0-3.043h-2.428v2.42h2.43V6.48l-.002-.003zm0-3.104h-2.428v2.42h2.43v-2.42h-.002zm2.97 6.147H13.38v2.42h2.42V9.518l-.007.003zm-8.998 0H4.383v2.42h2.422V9.518l-.01.003zm3.03 0h-2.4v2.42H9.84V9.518l-.015.003zm-6.03 0H1.4v2.42h2.428V9.518l-.03.003zm6.03-3.043h-2.4v2.42H9.84V6.48l-.015-.003zm-3.045 0H4.387v2.42H6.8V6.48l-.016-.003z" />
-</svg>
+<?xml version="1.0" encoding="UTF-8"?>
+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 756.26 596.9">
+  <defs>
+    <style>
+      .cls-1 {
+        fill: #1d63ed;
+        stroke-width: 0px;
+      }
+    </style>
+  </defs>
+  <path class="cls-1" d="M743.96,245.25c-18.54-12.48-67.26-17.81-102.68-8.27-1.91-35.28-20.1-65.01-53.38-90.95l-12.32-8.27-8.21,12.4c-16.14,24.5-22.94,57.14-20.53,86.81,1.9,18.28,8.26,38.83,20.53,53.74-46.1,26.74-88.59,20.67-276.77,20.67H.06c-.85,42.49,5.98,124.23,57.96,190.77,5.74,7.35,12.04,14.46,18.87,21.31,42.26,42.32,106.11,73.35,201.59,73.44,145.66.13,270.46-78.6,346.37-268.97,24.98.41,90.92,4.48,123.19-57.88.79-1.05,8.21-16.54,8.21-16.54l-12.3-8.27ZM189.67,206.39h-81.7v81.7h81.7v-81.7ZM295.22,206.39h-81.7v81.7h81.7v-81.7ZM400.77,206.39h-81.7v81.7h81.7v-81.7ZM506.32,206.39h-81.7v81.7h81.7v-81.7ZM84.12,206.39H2.42v81.7h81.7v-81.7ZM189.67,103.2h-81.7v81.7h81.7v-81.7ZM295.22,103.2h-81.7v81.7h81.7v-81.7ZM400.77,103.2h-81.7v81.7h81.7v-81.7ZM400.77,0h-81.7v81.7h81.7V0Z"/>
+</svg>
\ No newline at end of file
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js
new file mode 100644 (file)
index 0000000..d131aa4
--- /dev/null
@@ -0,0 +1,137 @@
+'use strict';
+'require form';
+'require fs';
+
+/* 
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return L.view.extend({
+       render() {
+               const m = new form.Map('dockerd', _('Docker - Configuration'),
+                       _('DockerMan is a simple docker manager client for LuCI'));
+
+               let o, t, s, ss;
+
+               s = m.section(form.NamedSection, 'globals', 'section', _('Global settings'));
+
+               t = s.tab('globals', _('Globals'));
+
+               o = s.taboption('globals', form.Value, 'ps_flags',
+                       _('Default ps flags'),
+                       _('Flags passed to docker top (ps). Leave empty to use the built-in default.'));
+               o.placeholder = '-ww';
+               o.rmempty = true;
+               o.optional = true;
+
+               o = s.taboption('globals', form.Value, 'api_version',
+                       _('Api Version'),
+                       _('Lock API endpoint to a specific version (helps guarantee behaviour).') + '<br/>' +
+                       _('Causes errors when a chosen API > Docker endpoint API support.'));
+               o.rmempty = true;
+               o.optional = true;
+               o.value('v1.44');
+               o.value('v1.45');
+               o.value('v1.46');
+               o.value('v1.47');
+               o.value('v1.48');
+               o.value('v1.49');
+               o.value('v1.50');
+               o.value('v1.51');
+               o.value('v1.52');
+
+               // Check if local dockerd is available
+               o = s.taboption('globals', form.DirectoryPicker, 'data_root', _('Docker Root Dir'),
+                       _('For local dockerd socket instances only.'));
+               o.datatype = 'folder';
+               o.default = '/opt/docker';
+               o.root_directory = '/';
+               o.show_hidden = true;
+
+               o = s.taboption('globals', form.Value, 'bip',
+                       _('Default bridge'),
+                       _('Configure the default bridge network'));
+               o.placeholder = '172.17.0.1/16';
+               o.datatype = 'ipaddr';
+
+               o = s.taboption('globals', form.DynamicList, 'registry_mirrors',
+                       _('Registry Mirrors'),
+                       _('It replaces the daemon registry mirrors with a new set of registry mirrors'));
+               o.placeholder = _('Example: ') + 'https://hub-mirror.c.163.com';
+               o.value('https://docker.io');
+               o.value('https://ghcr.io');
+               o.value('https://hub-mirror.c.163.com');
+
+               o = s.taboption('globals', form.ListValue, 'log_level',
+                       _('Log Level'),
+                       _('Set the logging level'));
+               o.value('debug', _('Debug'));
+               o.value('', _('Info')); // default
+               o.value('warn', _('Warning'));
+               o.value('error', _('Error'));
+               o.value('fatal', _('Fatal'));
+               o.rmempty = true;
+
+               o = s.taboption('globals', form.DynamicList, 'hosts',
+                       _('Client connection'),
+                       _('Specifies where the Docker daemon will listen for client connections. default: ') + 'unix:///var/run/docker.sock' + '<br />' + 
+                       _('Note that dockerd no longer listens on IP:port without TLS options after v27.'));
+               o.placeholder = _('Example: tcp://0.0.0.0:2375');
+               o.default = 'unix:///var/run/docker.sock';
+               o.rmempty = true;
+               o.value('unix:///var/run/docker.sock');
+               o.value('tcp://0.0.0.0:2375');
+               o.value('tcp://0.0.0.0:2376');
+               o.value('tcp6://[::]:2375');
+               o.value('tcp6://[::]:2376');
+
+
+               t = s.tab('auth', _('Registry Auth'));
+
+               o = s.taboption('auth', form.SectionValue, '__auth__', form.TableSection, 'auth', null, 
+                       _('Used for push/pull operations on custom registries.') + '<br/>' +
+                       _('Destinations prefixed with a Registry host matching an entry in this table invoke its corresponding credentials.') + '<br/>' +
+                       _('The first match is used.') + '<br/>' +
+                       _('A Token is preferred over a Password.') + '<br/>' +
+                       _('Tokes and Passwords are not encrypted in the uci configuration.'));
+               ss = o.subsection;
+               ss.anonymous = true;
+               ss.nodescriptions = true;
+               ss.addremove = true;
+               ss.sortable = true;
+
+               o = ss.option(form.Value, 'username',
+                       _('User'));
+               o.placeholder = 'jbloggs';
+               o.rmempty = false;
+
+               o = ss.option(form.Value, 'password',
+                       _('Password'));
+               o.placeholder = 'foobar';
+               o.password = true;
+
+               o = ss.option(form.Value, 'serveraddress',
+                       _('Registry'));
+               o.datatype = 'or(hostname,hostport,ipaddr,ipaddrport)';
+               o.placeholder = 'registry.foo.io[:443] | 192.0.2.1[:443]';
+               o.rmempty = false;
+               o.value('container-registry.oracle.com');
+               o.value('registry.docker.io');
+               o.value('gcr.io');
+               o.value('ghcr.io');
+               o.value('quay.io');
+               o.value('registry.gitlab.com');
+               o.value('registry.redhat.io');
+
+               o = ss.option(form.Value, 'token',
+                       _('Token'));
+               o.password = true;
+
+
+               return m.render();
+       }
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js
new file mode 100644 (file)
index 0000000..879a0fb
--- /dev/null
@@ -0,0 +1,1678 @@
+'use strict';
+'require form';
+'require fs';
+'require poll';
+'require uci';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+const dummy_stats = {"read":"2026-01-08T22:57:31.547920715Z","pids_stats":{"current":3},"networks":{"eth0":{"rx_bytes":5338,"rx_dropped":0,"rx_errors":0,"rx_packets":36,"tx_bytes":648,"tx_dropped":0,"tx_errors":0,"tx_packets":8},"eth5":{"rx_bytes":4641,"rx_dropped":0,"rx_errors":0,"rx_packets":26,"tx_bytes":690,"tx_dropped":0,"tx_errors":0,"tx_packets":9}},"memory_stats":{"stats":{"total_pgmajfault":0,"cache":0,"mapped_file":0,"total_inactive_file":0,"pgpgout":414,"rss":6537216,"total_mapped_file":0,"writeback":0,"unevictable":0,"pgpgin":477,"total_unevictable":0,"pgmajfault":0,"total_rss":6537216,"total_rss_huge":6291456,"total_writeback":0,"total_inactive_anon":0,"rss_huge":6291456,"hierarchical_memory_limit":67108864,"total_pgfault":964,"total_active_file":0,"active_anon":6537216,"total_active_anon":6537216,"total_pgpgout":414,"total_cache":0,"inactive_anon":0,"active_file":0,"pgfault":964,"inactive_file":0,"total_pgpgin":477},"max_usage":6651904,"usage":6537216,"failcnt":0,"limit":67108864},"blkio_stats":{},"cpu_stats":{"cpu_usage":{"percpu_usage":[8646879,24472255,36438778,30657443],"usage_in_usermode":50000000,"total_usage":100215355,"usage_in_kernelmode":30000000},"system_cpu_usage":739306590000000,"online_cpus":4,"throttling_data":{"periods":0,"throttled_periods":0,"throttled_time":0}},"precpu_stats":{"cpu_usage":{"percpu_usage":[8646879,24350896,36438778,30657443],"usage_in_usermode":50000000,"total_usage":100093996,"usage_in_kernelmode":30000000},"system_cpu_usage":9492140000000,"online_cpus":4,"throttling_data":{"periods":0,"throttled_periods":0,"throttled_time":0}}};
+const dummy_ps = {"Titles":["UID","PID","PPID","C","STIME","TTY","TIME","CMD"],"Processes":[["root","13642","882","0","17:03","pts/0","00:00:00","/bin/bash"],["root","13735","13642","0","17:06","pts/0","00:00:00","sleep 10"]]};
+const dummy_changes = [{"Path":"/dev","Kind":0},{"Path":"/dev/kmsg","Kind":1},{"Path":"/test","Kind":1}];
+
+// https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Container/operation/ContainerStats
+// Helper function to calculate memory usage percentage
+function calculateMemoryUsage(stats) {
+       if (!stats || !stats.memory_stats) return null;
+       const mem = stats.memory_stats;
+       if (!mem.usage || !mem.limit) return null;
+
+       // used_memory = memory_stats.usage - memory_stats.stats.cache
+       const cache = mem.stats?.cache || 0;
+       const used_memory = mem.usage - cache;
+       const available_memory = mem.limit;
+
+       // Memory usage % = (used_memory / available_memory) * 100.0
+       const percentage = (used_memory / available_memory) * 100.0;
+
+       return {
+               percentage: percentage,
+               used: used_memory,
+               limit: available_memory
+       };
+}
+
+// Helper function to calculate CPU usage percentage
+// Pass previousStats if Docker API doesn't provide complete precpu_stats
+function calculateCPUUsage(stats, previousStats) {
+       if (!stats || !stats.cpu_stats) return null;
+       const cpu = stats.cpu_stats;
+
+       // Try to use precpu_stats from API first, fall back to our stored previous stats
+       let precpu = stats.precpu_stats;
+
+       // If precpu_stats is incomplete, use our manually stored previous stats
+       if (!precpu || !precpu.system_cpu_usage) {
+               if (previousStats && previousStats.cpu_stats) {
+                       // console.log('Using manually stored previous CPU stats');
+                       precpu = previousStats.cpu_stats;
+               } else {
+                       // console.log('No previous CPU stats available yet - waiting for next cycle');
+                       return null;
+               }
+       }
+
+       // If we don't have both cpu_stats and precpu_stats, return null
+       if (!cpu.cpu_usage || !precpu || !precpu.cpu_usage) {
+               // console.log('CPU stats incomplete:', { 
+               //      hasCpu: !!cpu.cpu_usage, 
+               //      hasPrecpu: !!precpu,
+               //      hasPrecpuUsage: !!(precpu && precpu.cpu_usage)
+               // });
+               return null;
+       }
+
+       // Validate we have the required fields
+       const validationChecks = {
+               'cpu.cpu_usage.total_usage': typeof cpu.cpu_usage.total_usage,
+               'precpu.cpu_usage.total_usage': typeof precpu.cpu_usage.total_usage,
+               'cpu.system_cpu_usage': typeof cpu.system_cpu_usage,
+               'precpu.system_cpu_usage': typeof precpu.system_cpu_usage,
+               'cpu_values': {
+                       cpu_total: cpu.cpu_usage.total_usage,
+                       precpu_total: precpu.cpu_usage.total_usage,
+                       cpu_system: cpu.system_cpu_usage,
+                       precpu_system: precpu.system_cpu_usage
+               }
+       };
+
+       // Check if we have valid numeric values for all required fields
+       // Note: precpu_stats may be empty/undefined on first stats call
+       if (typeof cpu.cpu_usage.total_usage !== 'number' || 
+               typeof precpu.cpu_usage.total_usage !== 'number' ||
+               typeof cpu.system_cpu_usage !== 'number' ||
+               typeof precpu.system_cpu_usage !== 'number') {
+               // console.log('CPU stats incomplete - waiting for valid precpu data:', validationChecks);
+               return null;
+       }
+
+       // Also check if precpu data is essentially zero (first call scenario)
+       if (precpu.cpu_usage.total_usage === 0 || precpu.system_cpu_usage === 0) {
+               // console.log('CPU precpu stats are zero - waiting for next stats cycle');
+               return null;
+       }
+
+       // cpu_delta = cpu_stats.cpu_usage.total_usage - precpu_stats.cpu_usage.total_usage
+       const cpu_delta = cpu.cpu_usage.total_usage - precpu.cpu_usage.total_usage;
+
+       // system_cpu_delta = cpu_stats.system_cpu_usage - precpu_stats.system_cpu_usage
+       const system_cpu_delta = cpu.system_cpu_usage - precpu.system_cpu_usage;
+
+       // Validate deltas
+       if (system_cpu_delta <= 0 || cpu_delta < 0) {
+               // console.warn('Invalid CPU deltas:', { 
+               //      cpu_delta, 
+               //      system_cpu_delta,
+               //      cpu_total: cpu.cpu_usage.total_usage,
+               //      precpu_total: precpu.cpu_usage.total_usage,
+               //      system: cpu.system_cpu_usage,
+               //      presystem: precpu.system_cpu_usage
+               // });
+               return null;
+       }
+
+       // number_cpus = length(cpu_stats.cpu_usage.percpu_usage) or cpu_stats.online_cpus
+       const number_cpus = cpu.online_cpus || (cpu.cpu_usage.percpu_usage?.length || 1);
+
+       // CPU usage % = (cpu_delta / system_cpu_delta) * number_cpus * 100.0
+       const percentage = (cpu_delta / system_cpu_delta) * number_cpus * 100.0;
+
+       // console.log('CPU calculation:', { 
+       //      cpu_delta, 
+       //      system_cpu_delta, 
+       //      number_cpus, 
+       //      percentage: percentage.toFixed(2) + '%'
+       // });
+
+       return {
+               percentage: percentage,
+               number_cpus: number_cpus
+       };
+}
+
+// Helper function to create a progress bar
+function createProgressBar(label, percentage, used, total) {
+       const clampedPercentage = Math.min(Math.max(percentage || 0, 0), 100);
+       const color = clampedPercentage > 90 ? '#d9534f' : (clampedPercentage > 70 ? '#f0ad4e' : '#5cb85c');
+
+       return E('div', { 'style': 'margin: 10px 0;' }, [
+               E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 5px;' }, [
+                       E('span', { 'style': 'font-weight: bold;' }, label),
+                       E('span', {}, used && total ? `${used} / ${total}` : `${clampedPercentage.toFixed(2)}%`)
+               ]),
+               E('div', { 
+                       'style': 'width: 100%; height: 20px; background-color: #e9ecef; border-radius: 4px; overflow: hidden;'
+               }, [
+                       E('div', {
+                               'style': `width: ${clampedPercentage}%; height: 100%; background-color: ${color}; transition: width 0.3s ease;`
+                       })
+               ])
+       ]);
+}
+
+
+const ChangeTypes = Object.freeze({
+       0: 'Modified',
+       1: 'Added',
+       2: 'Deleted',
+});
+
+return dm2.dv.extend({
+       load() {
+               const requestPath = L.env.requestpath;
+               const containerId = requestPath[requestPath.length-1] || '';
+               this.psArgs = uci.get('dockerd', 'globals', 'ps_flags') || '-ww';
+
+               // First load container info to check state
+               return dm2.container_inspect({id: containerId})
+                       .then(container => {
+                               if (container.code !== 200) window.location.href = `${this.dockerman_url}/containers`;
+                               const this_container = container.body || {};
+
+                               // Now load other resources, conditionally calling stats/ps/changes only if running
+                               const isRunning = this_container.State?.Status === 'running';
+
+                               return Promise.all([
+                                       this_container,
+                                       dm2.image_list().then(images => {
+                                               return Array.isArray(images.body) ? images.body : [];
+                                       }),
+                                       dm2.network_list().then(networks => {
+                                               return Array.isArray(networks.body) ? networks.body : [];
+                                       }),
+                                       dm2.docker_info().then(info => {
+                                               const numcpus = info.body?.NCPU || 1.0;
+                                               const memory = info.body?.MemTotal || 2**10;
+                                               return {numcpus: numcpus, memory: memory};
+                                       }),
+                                       isRunning ? dm2.container_top({ id: containerId, query: { 'ps_args': this.psArgs || '-ww' } })
+                                               .then(res => {
+                                                       if (res?.code < 300 && res.body) return res.body;
+                                                       else return dummy_ps;
+                                               })
+                                               .catch(() => {}) : Promise.resolve(),
+                                       isRunning ? dm2.container_stats({ id: containerId, query: { 'stream': false, 'one-shot': true } })
+                                               .then(res => {
+                                                       if (res?.code < 300 && res.body) return res.body;
+                                                       else return dummy_stats;
+                                               })
+                                               .catch(() => {}) : Promise.resolve(),
+                                       dm2.container_changes({ id: containerId })
+                                               .then(res => {
+                                                       if (res?.code < 300 && Array.isArray(res.body)) return res.body;
+                                                       else return dummy_changes;
+                                               })
+                                               .catch(() => {}),
+                               ]);
+                       });
+       },
+
+       buildList(array, mapper) {
+               if (!Array.isArray(array)) return [];
+               const out = [];
+               for (const item of array) {
+                       const mapped = mapper(item);
+                       if (mapped || mapped === 0)
+                               out.push(mapped);
+               }
+               return out;
+       },
+
+       buildListFromObject(obj, mapper) {
+               if (!obj || typeof obj !== 'object') return [];
+               const out = [];
+               for (const [k, v] of Object.entries(obj)) {
+                       const mapped = mapper(k, v);
+                       if (mapped || mapped === 0)
+                               out.push(mapped);
+               }
+               return out;
+       },
+
+       getMountsList(this_container) {
+               return this.buildList(this_container?.Mounts, (mount) => {
+                       if (!mount?.Type || !mount?.Destination) return null;
+                       let entry = `${mount.Type}:${mount.Source}:${mount.Destination}`;
+                       if (mount.Mode) entry += `:${mount.Mode}`;
+                       return entry;
+               });
+       },
+
+       getPortsList(this_container) {
+               const portBindings = this_container?.HostConfig?.PortBindings;
+               if (!portBindings || typeof portBindings !== 'object') return [];
+               const ports = [];
+               for (const [containerPort, bindings] of Object.entries(portBindings)) {
+                       if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
+                               ports.push(`${bindings[0].HostPort}:${containerPort}`);
+                       }
+               }
+               return ports;
+       },
+
+       getEnvList(this_container) {
+               return this_container?.Config?.Env || [];
+       },
+
+       getDevicesList(this_container) {
+               return this.buildList(this_container?.HostConfig?.Devices, (device) => {
+                       if (!device?.PathOnHost || !device?.PathInContainer) return null;
+                       let entry = `${device.PathOnHost}:${device.PathInContainer}`;
+                       if (device.CgroupPermissions) entry += `:${device.CgroupPermissions}`;
+                       return entry;
+               });
+       },
+
+       getTmpfsList(this_container) {
+               return this.buildListFromObject(this_container?.HostConfig?.Tmpfs, (path, opts) => `${path}${opts ? ':' + opts : ''}`);
+       },
+
+       getDnsList(this_container) {
+               return this_container?.HostConfig?.Dns || [];
+       },
+
+       getSysctlList(this_container) {
+               return this.buildListFromObject(this_container?.HostConfig?.Sysctls, (key, value) => `${key}:${value}`);
+       },
+
+       getCapAddList(this_container) {
+               return this_container?.HostConfig?.CapAdd || [];
+       },
+
+       getLogOptList(this_container) {
+               return this.buildListFromObject(this_container?.HostConfig?.LogConfig?.Config, (key, value) => `${key}=${value}`);
+       },
+
+       getCNetworksArray(c_networks, networks) {
+               if (!c_networks || typeof c_networks !== 'object') return [];
+               const data = [];
+
+               for (const [name, net] of Object.entries(c_networks)) {
+                       const network = networks.find(n => n.Name === name || n.Id === name);
+                       const netid = !net?.NetworkID ? network?.Id : net?.NetworkID;
+
+                       /* Even if netid is null, proceed: perhaps the network was deleted. If we
+                       display it, the user can disconnect it. */
+                       data.push({
+                               ...net,
+                               _shortId: netid?.substring(0,12) ||  '',
+                               Name: name,
+                               NetworkID: netid,
+                               DNSNames: net?.DNSNames || '',
+                               IPv4Address: net?.IPAMConfig?.IPv4Address || '',
+                               IPv6Address: net?.IPAMConfig?.IPv6Address || '',
+                       });
+               }
+
+               return data;
+       },
+
+       render([this_container, images, networks, cpus_mem, ps_top, stats_data, changes_data]) {
+               const view = this;
+               const containerName = this_container.Name?.substring(1) || this_container.Id || '';
+               const containerIdShort = (this_container.Id || '').substring(0, 12);
+               const c_networks = this_container.NetworkSettings?.Networks || {};
+
+               // Create main container with action buttons
+               const mainContainer = E('div', {});
+
+               const containerStatus = this.getContainerStatus(this_container);
+
+               // Add title and description
+               const header = E('div', { 'class': 'cbi-page' }, [
+                       E('h2', {}, _('Docker - Container')),
+                       E('p', { 'style': 'margin: 10px 0; display: flex; gap: 6px; align-items: center;' }, [
+                               this.wrapStatusText(containerName, containerStatus, 'font-weight:600;'),
+                               E('span', { 'style': 'color:#666;' }, `(${containerIdShort})`)
+                       ]),
+                       E('p', { 'style': 'color: #666;' }, _('Manage and view container configuration'))
+               ]);
+               mainContainer.appendChild(header);
+
+               // Add action buttons section
+               const buttonSection = E('div', { 'class': 'cbi-section', 'style': 'margin-bottom: 20px;' });
+               const buttonContainer = E('div', { 'style': 'display: flex; gap: 10px; flex-wrap: wrap;' });
+
+               // Start button
+               if (containerStatus !== 'running') {
+                       const startBtn = E('button', {
+                               'class': 'cbi-button cbi-button-apply',
+                               'click': (ev) => this.executeAction(ev, 'start', this_container.Id)
+                       }, [_('Start')]);
+                       buttonContainer.appendChild(startBtn);
+               }
+
+               // Restart button
+               if (containerStatus === 'running') {
+                       const restartBtn = E('button', {
+                               'class': 'cbi-button cbi-button-reload',
+                               'click': (ev) => this.executeAction(ev, 'restart', this_container.Id)
+                       }, [_('Restart')]);
+                       buttonContainer.appendChild(restartBtn);
+               }
+
+               // Stop button
+               if (containerStatus === 'running' || containerStatus === 'paused') {
+                       const stopBtn = E('button', {
+                               'class': 'cbi-button cbi-button-reset',
+                               'click': (ev) => this.executeAction(ev, 'stop', this_container.Id)
+                       }, [_('Stop')]);
+                       buttonContainer.appendChild(stopBtn);
+               }
+
+               // Kill button
+               if (containerStatus === 'running') {
+                       const killBtn = E('button', {
+                               'class': 'cbi-button',
+                               'style': 'background-color: #dc3545;',
+                               'click': (ev) => this.executeAction(ev, 'kill', this_container.Id)
+                       }, [_('Kill')]);
+                       buttonContainer.appendChild(killBtn);
+               }
+
+               // Pause/Unpause button
+               if (containerStatus === 'running' || containerStatus === 'paused') {
+                       const isPausedNow = this.container?.State?.Paused === true;
+                       const pauseBtn = E('button', {
+                               'class': 'cbi-button',
+                               'id': 'pause-button',
+                               'click': (ev) => {
+                                       const currentStatus = this.getContainerStatus(this_container);
+                                       this.executeAction(ev, (currentStatus === 'paused' ? 'unpause' : 'pause'), this_container.Id);
+                               }
+                       }, [isPausedNow ? _('Unpause') : _('Pause')]);
+                       buttonContainer.appendChild(pauseBtn);
+               }
+
+               // Duplicate button
+               const duplicateBtn = E('button', {
+                       'class': 'cbi-button cbi-button-add',
+                       'click': (ev) => {
+                               ev.preventDefault();
+                               window.location.href = `${this.dockerman_url}/container_new/duplicate/${this_container.Id}`;
+                       }
+               }, [_('Duplicate/Edit')]);
+               buttonContainer.appendChild(duplicateBtn);
+
+               // Export button
+               const exportBtn = E('button', {
+                       'class': 'cbi-button cbi-button-reload',
+                       'click': (ev) => {
+                               ev.preventDefault();
+                               window.location.href = `${this.dockerman_url}/container/export/${this_container.Id}`;
+                       }
+               }, [_('Export')]);
+               buttonContainer.appendChild(exportBtn);
+
+               // Remove button
+               const removeBtn = E('button', {
+                       'class': 'cbi-button cbi-button-remove',
+                       'click': (ev) => this.executeAction(ev, 'remove', this_container.Id),
+               }, [_('Remove')]);
+               buttonContainer.appendChild(removeBtn);
+
+               // Back button
+               const backBtn = E('button', {
+                       'class': 'cbi-button',
+                       'click': () => window.location.href = `${this.dockerman_url}/containers`,
+               }, [_('Back to Containers')]);
+               buttonContainer.appendChild(backBtn);
+
+               buttonSection.appendChild(buttonContainer);
+               mainContainer.appendChild(buttonSection);
+
+
+               const m = new form.JSONMap({
+                       cont: this_container,
+                       nets: this.getCNetworksArray(c_networks, networks),
+                       hostcfg: this_container.HostConfig || {},
+               }, null);
+               m.submit = false;
+               m.reset = false;
+
+               let s = m.section(form.NamedSection, 'cont', null, _('Container detail'));
+               s.anonymous = true;
+               s.nodescriptions = true;
+               s.addremove = false;
+
+               let o, t, ss;
+
+               t = s.tab('info', _('Info'));
+
+               o = s.taboption('info', form.Value, 'Name', _('Name'));
+
+               o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
+
+               o = s.taboption('info', form.DummyValue, 'Image', _('Image'));
+               o.cfgvalue = (sid) => this.getImageFirstTag(images, this.map.data.data[sid].Image);
+
+               o = s.taboption('info', form.DummyValue, 'Image', _('Image ID'));
+
+               o = s.taboption('info', form.DummyValue, 'status', _('Status'));
+               o.cfgvalue = (sid) => this.map.data.data[sid].State?.Status || '';
+
+               o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
+
+               o = s.taboption('info', form.DummyValue, 'started', _('Finish Time'));
+               o.cfgvalue = () => {
+                       if (this_container.State?.Running)
+                               return this_container.State?.StartedAt || '-';
+                       return this_container.State?.FinishedAt || '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'healthy', _('Health Status'));
+               o.cfgvalue = () => this_container.State?.Health?.Status || '-';
+
+               o = s.taboption('info', form.DummyValue, 'user', _('User'));
+               o.cfgvalue = () => this_container.Config?.User || '-';
+
+               o = s.taboption('info', form.ListValue, 'restart_policy', _('Restart Policy'));
+               o.cfgvalue = () => this_container.HostConfig?.RestartPolicy?.Name || '-';
+               o.value('no', _('No'));
+               o.value('unless-stopped', _('Unless stopped'));
+               o.value('always', _('Always'));
+               o.value('on-failure', _('On failure'));
+
+               o = s.taboption('info', form.DummyValue, 'hostname', _('Host Name'));
+               o.cfgvalue = () => this_container.Config?.Hostname || '-';
+
+               o = s.taboption('info', form.DummyValue, 'command', _('Command'));
+               o.cfgvalue = () => {
+                       const cmd = this_container.Config?.Cmd;
+                       if (Array.isArray(cmd))
+                               return cmd.join(' ');
+                       return cmd || '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'env', _('Env'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const env = this.getEnvList(this_container);
+                       return env.length > 0 ? env.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'ports', _('Ports'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const ports = view.getPortsList(this_container);
+                       return ports.length > 0 ? ports.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'links', _('Links'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const links = this_container.HostConfig?.Links;
+                       return Array.isArray(links) && links.length > 0 ? links.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'devices', _('Devices'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const devices = this.getDevicesList(this_container);
+                       return devices.length > 0 ? devices.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'tmpfs', _('Tmpfs Directories'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const tmpfs = this.getTmpfsList(this_container);
+                       return tmpfs.length > 0 ? tmpfs.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'dns', _('DNS'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const dns = view.getDnsList(this_container);
+                       return dns.length > 0 ? dns.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'sysctl', _('Sysctl Settings'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const sysctl = this.getSysctlList(this_container);
+                       return sysctl.length > 0 ? sysctl.join('<br />') : '-';
+               };
+
+               o = s.taboption('info', form.DummyValue, 'mounts', _('Mounts/Binds'));
+               o.rawhtml = true;
+               o.cfgvalue = () => {
+                       const mounts = view.getMountsList(this_container);
+                       return mounts.length > 0 ? mounts.join('<br />') : '-';
+               };
+
+               // NETWORKS TAB
+               t = s.tab('network', _('Networks'));
+
+               o = s.taboption('network', form.SectionValue, '__net__', form.TableSection, 'nets', null);
+               ss = o.subsection;
+               ss.anonymous = true;
+               ss.nodescriptions = true;
+               ss.addremove = true;
+               ss.addbtntitle = _('Connect') + ' 🔗';
+               ss.delbtntitle = _('Disconnect') + ' ⛓️‍💥';
+
+               o = ss.option(form.DummyValue, 'Name', _('Name'));
+
+               o = ss.option(form.DummyValue, '_shortId', _('ID'));
+               o.cfgvalue = function(section_id, value) {
+                       const name_links = false;
+                       const nets = this.map.data.data[section_id] || {};
+                       return view.parseNetworkLinksForContainer(networks, (Array.isArray(nets) ? nets : [nets]), name_links);
+               };
+
+               o = ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
+
+               o = ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
+
+               o = ss.option(form.DummyValue, 'GlobalIPv6Address', _('Global IPv6 Address'));
+
+               o = ss.option(form.DummyValue, 'MacAddress', _('MAC Address'));
+
+               o = ss.option(form.DummyValue, 'Gateway', _('Gateway'));
+
+               o = ss.option(form.DummyValue, 'IPv6Gateway', _('IPv6 Gateway'));
+
+               o = ss.option(form.DummyValue, 'DNSNames', _('DNS Names'));
+
+               ss.handleAdd = function(ev) {
+                       ev.preventDefault();
+                       view.executeNetworkAction('connect', null, null, this_container);
+               };
+
+               ss.handleRemove = function(section_id, ev) {
+                       const network = this.map.data.data[section_id];
+                       ev.preventDefault();
+                       delete this.map.data.data[section_id];
+                       this.super('handleRemove', [ev]);
+                       view.executeNetworkAction('disconnect', (network.NetworkID || network.Name), network.Name, this_container);
+               };
+
+
+
+               t = s.tab('resources', _('Resources'));
+
+               o = s.taboption('resources', form.SectionValue, '__hcfg__', form.TypedSection, 'hostcfg', null);
+               ss = o.subsection;
+               ss.anonymous = true;
+               ss.nodescriptions = false;
+               ss.addremove = false;
+
+               o = ss.option(form.Value, 'NanoCpus', _('CPUs'));
+               o.cfgvalue = (sid) => view.map.data.data[sid].NanoCpus / (10**9);
+               o.placeholder='1.5';
+               o.datatype = 'ufloat';
+               o.validate = function(section_id, value) {
+                       if (!value) return true;
+                       if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
+                       return true;
+               };
+
+               o = ss.option(form.Value, 'CpuPeriod', _('CPU Period (microseconds)'));
+               o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
+
+               o = ss.option(form.Value, 'CpuQuota', _('CPU Quota (microseconds)'));
+               o.datatype = 'uinteger';
+
+               o = ss.option(form.Value, 'CpuShares', _('CPU Shares Weight'));
+               o.placeholder='1024';
+               o.datatype = 'uinteger';
+
+               o = ss.option(form.Value, 'Memory', _('Memory Limit'));
+               o.cfgvalue = (sid, val) => {
+                       const mem = view.map.data.data[sid].Memory;
+                       return mem ? '%1024.2m'.format(mem) : 0;
+               };
+               o.write = function(sid, val) {
+                       if (!val || val == 0) return 0;
+                       this.map.data.data[sid].Memory = view.parseMemory(val);
+                       return view.parseMemory(val) || 0;
+               };
+               o.validate = function(sid, value) {
+                       if (!value) return true;
+                       if (value > view.memory) return _(`Only ${view.memory} bytes available`);
+                       return true;
+               };
+
+               o = ss.option(form.Value, 'MemorySwap', _('Memory + Swap'));
+               o.cfgvalue = (sid, val) => {
+                       const swap = this.map.data.data[sid].MemorySwap;
+                       return swap ? '%1024.2m'.format(swap) : 0;
+               };
+               o.write = function(sid, val) {
+                       if (!val || val == 0) return 0;
+                       this.map.data.data[sid].MemorySwap = view.parseMemory(val);
+                       return view.parseMemory(val) || 0;
+               };
+
+               o = ss.option(form.Value, 'MemoryReservation', _('Memory Reservation'));
+               o.cfgvalue = (sid, val) => {
+                       const res = this.map.data.data[sid].MemoryReservation;
+                       return res ? '%1024.2m'.format(res) : 0;
+               };
+               o.write = function(sid, val) {
+                       if (!val || val == 0) return 0;
+                       this.map.data.data[sid].MemoryReservation = view.parseMemory(val);
+                       return view.parseMemory(val) || 0;
+               };
+
+               o = ss.option(form.Flag, 'OomKillDisable', _('OOM Kill Disable'));
+
+               o = ss.option(form.Value, 'BlkioWeight', _('Block IO Weight'));
+               o.datatype = 'and(uinteger,min(0),max(1000)';
+
+               o = ss.option(form.DummyValue, 'Privileged', _('Privileged Mode'));
+               o.cfgvalue = (sid, val) => this.map.data.data[sid]?.Privileged ? _('Yes') : _('No');
+
+               o = ss.option(form.DummyValue, 'CapAdd', _('Added Capabilities'));
+               o.cfgvalue = (sid, val) => {
+                       const caps = this.map.data.data[sid]?.CapAdd;
+                       return Array.isArray(caps) && caps.length > 0 ? caps.join(', ') : '-';
+               };
+
+               o = ss.option(form.DummyValue, 'CapDrop', _('Dropped Capabilities'));
+               o.cfgvalue = (sid, val) => {
+                       const caps = this.map.data.data[sid]?.CapDrop;
+                       return Array.isArray(caps) && caps.length > 0 ? caps.join(', ') : '-';
+               };
+
+               o = ss.option(form.DummyValue, 'LogDriver', _('Log Driver'));
+               o.cfgvalue = (sid) => this.map.data.data[sid].LogConfig?.Type || '-';
+
+               o = ss.option(form.DummyValue, 'log_opt', _('Log Options'));
+               o.cfgvalue = () => {
+                       const opts = this.getLogOptList(this_container);
+                       return opts.length > 0 ? opts.join('<br />') : '-';
+               };
+
+               // STATS TAB
+               t = s.tab('stats', _('Stats'));
+
+               function updateStats(stats_data) {
+                       const status = view.getContainerStatus(this_container);
+
+                       if (status !== 'running') {
+                               // If we already have UI elements, clear/update them
+                               if (view.statsTable) {
+                                       const progressBarsSection = document.getElementById('stats-progress-bars');
+                                       if (progressBarsSection) {
+                                               progressBarsSection.innerHTML = '';
+                                               progressBarsSection.appendChild(E('p', {}, _('Container is not running') + ' (' + _('Status') + ': ' + status + ')'));
+                                       }
+                                       try { view.statsTable.update([]); } catch (e) {}
+                               }
+
+                               return E('div', { 'class': 'cbi-section' }, [
+                                       E('p', {}, [
+                                               _('Container is not running') + ' (' + _('Status') + ': ' + status + ')'
+                                       ])
+                               ]);
+                       }
+
+                       const stats = stats_data || dummy_stats;
+
+                       // Calculate usage percentages
+                       const memUsage = calculateMemoryUsage(stats);
+                       const cpuUsage = calculateCPUUsage(stats, view.previousCpuStats);
+
+                       // Store current stats for next calculation
+                       view.previousCpuStats = stats;
+
+                       // Prepare rows
+                       const rows = [
+                               [_('PID Stats'), view.objectToText(stats.pids_stats)],
+                               [_('Net Stats'), view.objectToText(stats.networks)],
+                               [_('Mem Stats'), view.objectToText(stats.memory_stats)],
+                               [_('BlkIO Stats'), view.objectToText(stats.blkio_stats)],
+                               [_('CPU Stats'), view.objectToText(stats.cpu_stats)],
+                               [_('Per CPU Stats'), view.objectToText(stats.precpu_stats)]
+                       ];
+
+                       // If table already exists (polling update), update in-place
+                       if (view.statsTable) {
+                               try {
+                                       view.statsTable.update(rows);
+                               } catch (e) { console.error('Failed to update stats table', e); }
+
+                               // Update progress bars
+                               const progressBarsSection = document.getElementById('stats-progress-bars');
+                               if (progressBarsSection) {
+                                       progressBarsSection.innerHTML = '';
+                                       progressBarsSection.appendChild(E('h3', {}, _('Resource Usage')));
+                                       progressBarsSection.appendChild(
+                                               memUsage ? createProgressBar(
+                                                       _('Memory Usage'),
+                                                       memUsage.percentage,
+                                                       '%1024.2m'.format(memUsage.used),
+                                                       '%1024.2m'.format(memUsage.limit)
+                                               ) : E('div', {}, _('Memory usage data unavailable'))
+                                       );
+                                       progressBarsSection.appendChild(
+                                               cpuUsage ? createProgressBar(
+                                                       _('CPU Usage') + ` (${cpuUsage.number_cpus} CPUs)`,
+                                                       cpuUsage.percentage,
+                                                       null,
+                                                       null
+                                               ) : E('div', {}, _('CPU usage data unavailable'))
+                                       );
+                               }
+
+                               // Update raw JSON field
+                               const statsField = document.getElementById('raw-stats-field');
+                               if (statsField) statsField.textContent = JSON.stringify(stats, null, 2);
+
+                               return true;
+                       }
+
+                       // Create progress bars section (initial render)
+                       const progressBarsSection = E('div', { 
+                               'class': 'cbi-section',
+                               'id': 'stats-progress-bars',
+                               'style': 'margin-bottom: 20px;'
+                       }, [
+                               E('h3', {}, _('Resource Usage')),
+                               memUsage ? createProgressBar(
+                                       _('Memory Usage'),
+                                       memUsage.percentage,
+                                       '%1024.2m'.format(memUsage.used),
+                                       '%1024.2m'.format(memUsage.limit)
+                               ) : E('div', {}, _('Memory usage data unavailable')),
+                               cpuUsage ? createProgressBar(
+                                       _('CPU Usage') + ` (${cpuUsage.number_cpus} CPUs)`,
+                                       cpuUsage.percentage,
+                                       null,
+                                       null
+                               ) : E('div', {}, _('CPU usage data unavailable'))
+                       ]);
+
+                       const statsTable = new L.ui.Table(
+                               [_('Metric'), _('Value')],
+                               { id: 'stats-table' },
+                               E('em', [_('No statistics available')])
+                       );
+
+                       // Store table reference for poll updates
+                       view.statsTable = statsTable;
+
+                       // Initial data
+                       statsTable.update(rows);
+
+                       return E('div', { 'class': 'cbi-section' }, [
+                               progressBarsSection,
+                               statsTable.render(),
+                               E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
+                               E('pre', { 
+                                       style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;', 
+                                       id: 'raw-stats-field' 
+                               }, JSON.stringify(stats, null, 2))
+                       ]);
+               };
+
+               // Create custom table for stats using L.ui.Table
+               o = s.taboption('stats', form.DummyValue, '_stats_table', _('Container Statistics'));
+               o.render = L.bind(() => { return updateStats(stats_data)}, this);
+
+               // PROCESS TAB
+               t = s.tab('ps', _('Processes'));
+
+               // Create custom table for processes using L.ui.Table
+               o = s.taboption('ps', form.DummyValue, '_ps_table', _('Running Processes'));
+               o.render = L.bind(() => {
+                       const status = this.getContainerStatus(this_container);
+
+                       if (status !== 'running') {
+                               return E('div', { 'class': 'cbi-section' }, [
+                                       E('p', {}, [
+                                               _('Container is not running') + ' (' + _('Status') + ': ' + status + ')'
+                                       ])
+                               ]);
+                       }
+
+                       // Use titles from the loaded data, or fallback to default
+                       const titles = (ps_top && ps_top.Titles) ? ps_top.Titles : 
+                               [_('PID'), _('USER'), _('VSZ'), _('STAT'), _('COMMAND')];
+
+                       // Store raw titles (without translation) for comparison in poll
+                       this.psTitles = titles;
+
+                       const psTable = new L.ui.Table(
+                               titles.map(t => _(t)),
+                               { id: 'ps-table' },
+                               E('em', [_('No processes running')])
+                       );
+
+                       // Store table reference and titles for poll updates
+                       this.psTable = psTable;
+                       this.psTitles = titles;
+
+                       // Initial data from dummy_ps
+                       if (ps_top && ps_top.Processes) {
+                               psTable.update(ps_top.Processes);
+                       }
+
+                       return E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'margin-bottom: 10px;' }, [
+                                       E('label', { 'for': 'ps-flags-input', 'style': 'margin-right: 8px;' }, _('ps flags:')),
+                                       E('input', {
+                                               id: 'ps-flags-input',
+                                               'class': 'cbi-input-text',
+                                               'type': 'text',
+                                               'value': this.psArgs || '-ww',
+                                               'placeholder': '-ww',
+                                               'style': 'width: 200px;',
+                                               'input': (ev) => { this.psArgs = ev.target.value || '-ww'; }
+                                       })
+                               ]),
+                               psTable.render(),
+                               E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
+                               E('pre', { 
+                                       style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;', 
+                                       id: 'raw-ps-field' 
+                               }, JSON.stringify(ps_top || dummy_ps, null, 2))
+                       ]);
+               }, this);
+
+               // CHANGES TAB
+               t = s.tab('changes', _('Changes'));
+
+               // Create custom table for changes using L.ui.Table
+               o = s.taboption('changes', form.DummyValue, '_changes_table', _('Filesystem Changes'));
+               o.render = L.bind(() => {
+                       const changesTable = new L.ui.Table(
+                               [_('Kind'), _('Path')],
+                               { id: 'changes-table' },
+                               E('em', [_('No filesystem changes detected')])
+                       );
+
+                       // Store table reference for poll updates
+                       this.changesTable = changesTable;
+
+                       // Initial data
+                       const rows = (changes_data || dummy_changes).map(change => [
+                               ChangeTypes[change?.Kind] || change?.Kind,
+                               change?.Path
+                       ]);
+                       changesTable.update(rows);
+
+                       return E('div', { 'class': 'cbi-section' }, [
+                               changesTable.render(),
+                               E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
+                               E('pre', { 
+                                       style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;', 
+                                       id: 'raw-changes-field' 
+                               }, JSON.stringify(changes_data || dummy_changes, null, 2))
+                       ]);
+               }, this);
+
+
+
+               // FILE TAB
+               t = s.tab('file', _('File'));
+               let fileDiv = null;
+
+               o = s.taboption('file', form.DummyValue, 'json', '_file');
+               o.cfgvalue = (sid, val) => '/';
+               o.render = L.bind(() => {
+                       if (fileDiv) {
+                               return fileDiv;
+                       }
+
+                       fileDiv = E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'margin-bottom: 10px;' }, [
+                                       E('label', { 'style': 'margin-right: 10px;' }, _('Path:')),
+                                       E('input', {
+                                               'type': 'text',
+                                               'id': 'file-path',
+                                               'class': 'cbi-input-text',
+                                               'value': '/',
+                                               'style': 'width: 200px;'
+                                       }),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-positive',
+                                               'style': 'margin-left: 10px;',
+                                               'click': () => this.handleFileUpload(this_container.Id),
+                                       }, _('Upload') + ' ⬆️'),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-neutral',
+                                               'style': 'margin-left: 5px;',
+                                               'click': () => this.handleFileDownload(this_container.Id),
+                                       }, _('Download') + ' ⬇️'),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-neutral',
+                                               'style': 'margin-left: 5px;',
+                                               'click': () => this.handleInfoArchive(this_container.Id),
+                                       }, _('Inspect') + ' 🔎'),
+                               ]),
+                               E('textarea', {
+                                       'id': 'container-file-text',
+                                       'readonly': true,
+                                       'rows': '5',
+                                       'style': 'width: 100%; font-family: monospace; font-size: 12px; padding: 10px; border: 1px solid #ccc;'
+                               }, '')
+                       ]);
+
+                       return fileDiv;
+               }, this);
+
+
+               // INSPECT TAB
+               t = s.tab('inspect', _('Inspect'));
+               let inspectDiv = null;
+
+               o = s.taboption('inspect', form.Button, 'json', _('Container Inspect'));
+               o.render = L.bind(() => {
+                       if (inspectDiv) {
+                               return inspectDiv;
+                       }
+
+                       inspectDiv = E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'margin-bottom: 10px;' }, [
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-neutral',
+                                               'style': 'margin-left: 5px;',
+                                               'click': () => dm2.container_inspect({ id: this_container.Id }).then(response => {
+                                                       const output = document.getElementById('container-inspect-output');
+                                                       output.textContent = JSON.stringify(response.body, null, 2);
+                                                       return;
+                                               }),
+                                       }, _('Inspect') + ' 🔎'),
+                               ]),
+                       ]);
+
+                       return inspectDiv;
+               }, this);
+
+               o = s.taboption('inspect', form.DummyValue, 'json');
+               o.cfgvalue = () => E('pre', { style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;',
+                       id: 'container-inspect-output' }, 
+                       JSON.stringify(this_container, null, 2));
+
+
+               // TERMINAL TAB
+               t = s.tab('console', _('Console'));
+
+               o = s.taboption('console', form.DummyValue, 'console_controls', _('Console Connection'));
+               o.render = L.bind(() => {
+                       const status = this.getContainerStatus(this_container);
+                       const isRunning = status === 'running';
+
+                       if (!isRunning) {
+                               return E('div', { 'class': 'alert-message warning' },
+                                       _('Container is not running. Cannot connect to console.'));
+                       }
+
+                       const consoleDiv = E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'margin-bottom: 15px;' }, [
+                                       E('label', { 'style': 'margin-right: 10px;' }, _('Command:')),
+                                       E('span', { 'id': 'console-command-wrapper' }, [
+                                               new ui.Combobox('/bin/sh', [
+                                                       '/bin/ash',
+                                                       '/bin/bash',
+                                               ], {id: 'console-command' }).render()
+                                       ]),
+                                       E('label', { 'style': 'margin-right: 10px; margin-left: 20px;' }, _('User(-u)')),
+                                       E('input', {
+                                               'type': 'text',
+                                               'id': 'console-uid',
+                                               'class': 'cbi-input-text',
+                                               'placeholder': 'e.g., root or user id',
+                                               'style': 'width: 150px; margin-right: 10px;'
+                                       }),
+                                       E('label', { 'style': 'margin-right: 10px; margin-left: 20px;' }, _('Port:')),
+                                       E('input', {
+                                               'type': 'number',
+                                               'id': 'console-port',
+                                               'class': 'cbi-input-text',
+                                               'value': '7682',
+                                               'min': '1024',
+                                               'max': '65535',
+                                               'style': 'width: 100px; margin-right: 10px;'
+                                       }),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-positive',
+                                               'id': 'console-connect-btn',
+                                               'click': () => this.connectConsole(this_container.Id)
+                                       }, _('Connect')),
+                               ]),
+                               E('div', {
+                                       'id': 'console-frame-container',
+                                       'style': 'display: none; margin-top: 15px;'
+                               }, [
+                                       E('div', { 'style': 'margin-bottom: 10px;' }, [
+                                               E('button', {
+                                                       'class': 'cbi-button cbi-button-negative',
+                                                       'click': () => this.disconnectConsole()
+                                               }, _('Disconnect')),
+                                               E('span', {
+                                                       'id': 'console-status',
+                                                       'style': 'margin-left: 10px; font-style: italic;'
+                                               }, _('Connected to container console'))
+                                       ]),
+                                       E('iframe', {
+                                               'id': 'ttyd-frame',
+                                               'class': 'xterm',
+                                               'src': '',
+                                               'style': 'width: 100%; height: 600px; border: 1px solid #ccc; border-radius: 3px;'
+                                       })
+                               ])
+                       ]);
+
+                       return consoleDiv;
+               }, this);
+
+
+               // LOGS TAB
+               t = s.tab('logs', _('Logs'));
+               let logsDiv = null;
+               let logsLoaded = false;
+
+               o = s.taboption('logs', form.DummyValue, 'log_controls', _('Log Controls'));
+               o.render = L.bind(() => {
+                       if (logsDiv) {
+                               return logsDiv;
+                       }
+
+                       logsDiv = E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'margin-bottom: 10px;' }, [
+                                       E('label', { 'style': 'margin-right: 10px;' }, _('Lines to show:')),
+                                       E('input', {
+                                               'type': 'number',
+                                               'id': 'log-lines',
+                                               'class': 'cbi-input-text',
+                                               'value': '100',
+                                               'min': '1',
+                                               'style': 'width: 80px;'
+                                       }),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-positive',
+                                               'style': 'margin-left: 10px;',
+                                               'click': () => this.loadLogs(this_container.Id)
+                                       }, _('Load Logs')),
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-neutral',
+                                               'style': 'margin-left: 5px;',
+                                               'click': () => this.clearLogs()
+                                       }, _('Clear')),
+                               ]),
+                               E('div', {
+                                       'id': 'container-logs-text',
+                                       'style': 'width: 100%; font-family: monospace; padding: 10px; border: 1px solid #ccc; overflow: auto;',
+                                       'innerHTML': ''
+                               })
+                       ]);
+
+                       return logsDiv;
+               }, this);
+
+               o = s.taboption('logs', form.DummyValue, 'log_display', _('Container Logs'));
+               o.render = L.bind(() => {
+                       // Auto-load logs when tab is first accessed
+                       if (!logsLoaded) {
+                               logsLoaded = true;
+                               this.loadLogs();
+                       }
+                       return E('div');
+               }, this);
+
+               this.map = m;
+
+               // Render the form and add buttons above it
+               return m.render()
+                       .then(fe => {
+                               mainContainer.appendChild(fe);
+
+                               poll.add(L.bind(() => {
+                                       if (this.getContainerStatus(this_container) !== 'running')
+                                               return Promise.resolve();
+
+                                       return dm2.container_changes({ id: this_container.Id })
+                                               .then(L.bind(function(res) {
+                                                       if (res.code < 300 && Array.isArray(res.body)) {
+                                                               // Update changes table using L.ui.Table.update()
+                                                               if (this.changesTable) {
+                                                                       const rows = res.body.map(change => change ? [
+                                                                               ChangeTypes[change?.Kind] || change?.Kind,
+                                                                               change?.Path
+                                                                       ] : []);
+                                                                       this.changesTable.update(rows);
+                                                               }
+
+                                                               // Update the raw JSON field
+                                                               const changesField = document.getElementById('raw-changes-field');
+                                                               if (changesField) {
+                                                                       changesField.textContent = JSON.stringify(res.body, null, 2);
+                                                               }
+                                                       }
+                                               }, this))
+                                               .catch(err => {
+                                                       console.error('Failed to poll container changes', err);
+                                                       return null;
+                                               });
+                               }, this), 5);
+
+                               // Auto-refresh Stats table every 5 seconds (if container is running)
+                               poll.add(L.bind(() => {
+                                       if (this.getContainerStatus(this_container) !== 'running')
+                                               return Promise.resolve();
+
+                                       return dm2.container_stats({ id: this_container.Id, query: { 'stream': false, 'one-shot': true } })
+                                               .then(L.bind(function(res) {
+                                                       if (res.code < 300 && res.body) {
+                                                               return updateStats(res.body);
+                                                       }
+                                               }, this))
+                                               .catch(err => {
+                                                       console.error('Failed to poll container stats', err);
+                                                       return null;
+                                               });
+                               }, this), 5);
+
+                               // Auto-refresh PS table every 5 seconds (if container is running)
+                               poll.add(L.bind(() => {
+                                       if (this.getContainerStatus(this_container) !== 'running')
+                                               return Promise.resolve();
+
+                                       return dm2.container_top({ id: this_container.Id, query: { 'ps_args': this.psArgs || '-ww' } })
+                                               .then(L.bind(function(res) {
+                                                       if (res.code < 300 && res.body && res.body.Processes) {
+                                                               // Check if titles changed - if so, rebuild the table
+                                                               if (res.body.Titles && JSON.stringify(res.body.Titles) !== JSON.stringify(this.psTitles)) {
+                                                                       // Titles changed, need to recreate table
+                                                                       this.psTitles = res.body.Titles;
+                                                                       const psTableEl = document.getElementById('ps-table');
+                                                                       if (psTableEl && psTableEl.parentNode) {
+                                                                               const newTable = new L.ui.Table(
+                                                                                       res.body.Titles.map(t => _(t)),
+                                                                                       { id: 'ps-table' },
+                                                                                       E('em', [_('No processes running')])
+                                                                               );
+                                                                               newTable.update(res.body.Processes);
+                                                                               this.psTable = newTable;
+                                                                               psTableEl.parentNode.replaceChild(newTable.render(), psTableEl);
+                                                                       }
+                                                               } else if (this.psTable) {
+                                                                       // Titles same, just update data
+                                                                       this.psTable.update(res.body.Processes);
+                                                               }
+
+                                                               // Update raw JSON field
+                                                               const psField = document.getElementById('raw-ps-field');
+                                                               if (psField) {
+                                                                       psField.textContent = JSON.stringify(res.body, null, 2);
+                                                               }
+                                                       }
+                                               }, this))
+                                               .catch(err => {
+                                                               console.error('Failed to poll container processes', err);
+                                                               return null;
+                                                       });
+                               }, this), 5);
+
+                               return mainContainer;
+                       });
+       },
+
+       handleSave(ev) {
+               ev?.preventDefault();
+
+               const map = this.map;
+               if (!map)
+                       return Promise.reject(new Error(_('Form is not ready yet.')));
+
+               const listToKv = view.listToKv;
+
+               const get = (opt) => map.data.get('json', 'cont', opt);
+               const getn = (opt) => map.data.get('json', 'nets', opt);
+               const gethc = (opt) => map.data.get('json', 'hostcfg', opt);
+               const toBool = (val) => (val === 1 || val === '1' || val === true);
+               const toInt = (val) => val ? Number.parseInt(val) : undefined;
+               const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
+
+               // First: update properties
+               map.parse()
+                       .then(() => {
+                               const this_container = map.data.get('json', 'cont');
+                               const id = this_container?.Id;
+                               /* In the container edit context, there are not many items we
+                               can change - duplicate the container */
+                               const createBody = {
+
+                                       CpuShares: toInt(gethc('CpuShares')),
+                                       Memory: toInt(gethc('Memory')),
+                                       MemorySwap: toInt(gethc('MemorySwap')),
+                                       MemoryReservation: toInt(gethc('MemoryReservation')),
+                                       BlkioWeight: toInt(gethc('blkio_weight')),
+
+                                       CpuPeriod: toInt(gethc('CpuPeriod')),
+                                       CpuQuota: toInt(gethc('CpuQuota')),
+                                       NanoCPUs: toInt(gethc('NanoCpus') * (10 ** 9)), // unit: 10^-9, input: float
+                                       OomKillDisable: toBool(gethc('OomKillDisable')),
+
+                                       RestartPolicy: { Name: get('restart_policy') || this_container.HostConfig?.RestartPolicy?.Name },
+
+                               };
+
+                               return { id, createBody };
+                       })
+                       .then(({ id, createBody }) => dm2.container_update({ id: id, body: createBody}))
+                       .then((response) => {
+                               if (response?.code >= 300) {
+                                       ui.addTimeLimitedNotification(_('Container update failed'), [response?.body?.message || _('Unknown error')], 7000, 'warning');
+                                       return false;
+                               }
+
+                               const msgTitle = _('Updated');
+                               if (response?.body?.Warnings)
+                                       ui.addTimeLimitedNotification(msgTitle + _(' with warnings'), [response?.body?.Warnings], 5000);
+                               else
+                                       ui.addTimeLimitedNotification(msgTitle, [ _('OK') ], 4000, 'info');
+
+                               if (get('Name') === null)
+                                       setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
+
+                               return true;
+                       })
+                       .catch((err) => {
+                               ui.addTimeLimitedNotification(_('Container update failed'), [err?.message || err], 7000, 'warning');
+                               return false;
+                       });
+
+               // Then: update name (separate operation)
+               return map.parse()
+                       .then(() => {
+                               const this_container = map.data.get('json', 'cont');
+                               const name = this_container.Name || get('Name');
+                               const id = this_container.Id || get('Id');
+
+                               return { id, name };
+                       })
+                       .then(({ id, name }) => dm2.container_rename({ id: id, query: { name: name } }))
+                       .then((response) => {
+                               this.handleDockerResponse(response, _('Container rename'), {
+                                       showOutput: false,
+                                       showSuccess: false
+                               });
+
+                               return setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
+                       })
+                       .catch((err) => {
+                               this.showNotification(_('Container rename failed'), err?.message || String(err), 7000, 'error');
+                               return false;
+                       });
+       },
+
+       handleFileUpload(container_id) {
+               const path = document.getElementById('file-path')?.value || '/';
+
+               const q_params = { path: encodeURIComponent(path) };
+
+               return this.super('handleXHRTransfer', [{
+                       q_params: { query: q_params },
+                       method: 'PUT',
+                       commandCPath: `/container/archive/put/${container_id}/`,
+                       commandDPath: `/containers/${container_id}/archive`,
+                       commandTitle: _('Uploading…'),
+                       commandMessage: _('Uploading file to container…'),
+                       successMessage: _('File uploaded to') + ': ' + path,
+                       pathElementId: 'file-path',
+                       defaultPath: '/'
+               }]);
+       },
+
+       handleFileDownload(container_id) {
+               const path = document.getElementById('file-path')?.value || '/';
+               const view = this;
+
+               if (!path || path === '') {
+                       this.showNotification(_('Error'), _('Please specify a path'), 5000, 'error');
+                       return;
+               }
+
+               // Direct HTTP download bypassing RPC buffering
+               window.location.href = `${this.dockerman_url}/container/archive/get/${container_id}` + `/?path=${encodeURIComponent(path)}`;
+               return;
+       },
+
+       handleInfoArchive(container_id) {
+               const path = document.getElementById('file-path')?.value || '/';
+               const fileTextarea = document.getElementById('container-file-text');
+
+               if (!fileTextarea) return;
+
+               return dm2.container_info_archive({ id: container_id, query: { path: path } })
+                       .then((response) => {
+                               if (response?.code >= 300) {
+                                       fileTextarea.value = _('Path error') + '\n' + (response?.body?.message || _('Unknown error'));
+                                       this.showNotification(_('Error'), [response?.body?.message || _('Path error')], 7000, 'error');
+                                       return false;
+                               }
+
+                               // check response?.headers?.entries?.length in case fetch API is used 
+                               if (!response.headers || response?.headers?.entries?.length == 0) return true;
+
+                               let fileInfo;
+                               try {
+                                       fileInfo = JSON.parse(atob(response?.headers?.get?.('x-docker-container-path-stat') || response?.headers?.['x-docker-container-path-stat']));
+                                       fileTextarea.value = 
+                                               `name: ${fileInfo?.name}\n` +
+                                               `size: ${fileInfo?.size}\n` +
+                                               `mode: ${this.modeToRwx(fileInfo?.mode)}\n` +
+                                               `mtime: ${fileInfo?.mtime}\n` +
+                                               `linkTarget: ${fileInfo?.linkTarget}\n`;
+                               } catch {
+                                       this.showNotification(_('Missing header or CORS interfering'), ['X-Docker-Container-Path-Stat'], 5000, 'notice');
+                               }
+
+                               return true;
+                       })
+                       .catch((err) => {
+                               const errorMsg = err?.message || String(err) || _('Path error');
+                               fileTextarea.value = _('Path error') + '\n' + errorMsg;
+                               this.showNotification(_('Error'), [errorMsg], 7000, 'error');
+                               return false;
+                       });
+       },
+
+       loadLogs(container_id) {
+               const lines = parseInt(document.getElementById('log-lines')?.value || '100');
+               const logsDiv = document.getElementById('container-logs-text');
+
+               if (!logsDiv) return;
+
+               logsDiv.innerHTML = '<em style="color: #999;">' + _('Loading logs…') + '</em>';
+
+               return dm2.container_logs({ id: container_id, query: { tail: lines, stdout: true, stderr: true } })
+                       .then((response) => {
+                               if (response?.code >= 300) {
+                                       logsDiv.innerHTML = '<span style="color: #ff5555;">' + _('Error loading logs:') + '</span><br/>' + 
+                                               (response?.body?.message || _('Unknown error'));
+                                       this.showNotification(_('Error'), response?.body?.message || _('Failed to load logs'), 7000, 'error');
+                                       return false;
+                               }
+
+                               const logText = response?.body || _('No logs available');
+                               // Convert ANSI codes to HTML and set innerHTML
+                               logsDiv.innerHTML = dm2.ansiToHtml(logText);
+                               logsDiv.scrollTop = logsDiv.scrollHeight;
+                               return true;
+                       })
+                       .catch((err) => {
+                               const errorMsg = err?.message || String(err) || _('Failed to load logs');
+                               logsDiv.innerHTML = '<span style="color: #ff5555;">' + _('Error loading logs:') + '</span><br/>' + errorMsg;
+                               this.showNotification(_('Error'), errorMsg, 7000, 'error');
+                               return false;
+                       });
+       },
+
+       clearLogs() {
+               const logsDiv = document.getElementById('container-logs-text');
+               if (logsDiv) {
+                       logsDiv.innerHTML = '';
+               }
+       },
+
+       connectConsole(container_id) {
+               const commandWrapper = document.getElementById('console-command');
+               const selectedItem = commandWrapper?.querySelector('li[selected]');
+               const command = selectedItem?.textContent?.trim() || '/bin/sh';
+               const uid = document.getElementById('console-uid')?.value || '';
+               const port = parseInt(document.getElementById('console-port')?.value || '7682');
+               const view = this;
+
+               const connectBtn = document.getElementById('console-connect-btn');
+               if (connectBtn) connectBtn.disabled = true;
+
+               // Call RPC to start ttyd
+               return dm2.container_ttyd_start({
+                       id: container_id,
+                       cmd: command,
+                       port: port,
+                       uid: uid
+               })
+               .then((response) => {
+                       if (connectBtn) connectBtn.disabled = false;
+
+                       if (response?.code >= 300) {
+                               const errorMsg = response?.body?.error || response?.body?.message || _('Failed to start console');
+                               view.showNotification(_('Error'), errorMsg, 7000, 'error');
+                               return false;
+                       }
+
+                       // Show iframe and set source
+                       const frameContainer = document.getElementById('console-frame-container');
+                       if (frameContainer) {
+                               frameContainer.style.display = 'block';
+                               const ttydFrame = document.getElementById('ttyd-frame');
+                               if (ttydFrame) {
+                                       // Wait for ttyd to fully start and be ready for connections
+                                       // Use a retry pattern to handle timing variations
+                                       const waitForTtydReady = (attempt = 0, maxAttempts = 5, initialDelay = 500) => {
+                                               const delay = initialDelay + (attempt * 200); // Increase delay on retries
+
+                                               setTimeout(() => {
+                                                       const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
+                                                       const ttydUrl = `${protocol}://${window.location.hostname}:${port}`;
+
+                                                       // Test connection with a simple HEAD request
+                                                       fetch(ttydUrl, { method: 'HEAD', mode: 'no-cors' })
+                                                               .then(() => {
+                                                                       // Connection successful, load the iframe
+                                                                       ttydFrame.src = ttydUrl;
+                                                               })
+                                                               .catch(() => {
+                                                                       // Connection failed, retry if we haven't exceeded max attempts
+                                                                       if (attempt < maxAttempts - 1) {
+                                                                               waitForTtydReady(attempt + 1, maxAttempts, initialDelay);
+                                                                       } else {
+                                                                               // Max retries exceeded, load iframe anyway
+                                                                               ttydFrame.src = ttydUrl;
+                                                                               view.showNotification(_('Warning'), _('TTYd may still be starting up'), 5000, 'warning');
+                                                                       }
+                                                               });
+                                               }, delay);
+                                       };
+
+                                       waitForTtydReady();
+                               }
+                       }
+
+                       view.showNotification(_('Success'), _('Console connected'), 3000, 'info');
+                       return true;
+               })
+               .catch((err) => {
+                       if (connectBtn) connectBtn.disabled = false;
+                       const errorMsg = err?.message || String(err) || _('Failed to connect to console');
+                       view.showNotification(_('Error'), errorMsg, 7000, 'error');
+                       return false;
+               });
+       },
+
+       disconnectConsole() {
+               const frameContainer = document.getElementById('console-frame-container');
+               if (frameContainer) {
+                       frameContainer.style.display = 'none';
+                       const ttydFrame = document.getElementById('ttyd-frame');
+                       if (ttydFrame) {
+                               ttydFrame.src = '';
+                       }
+               }
+
+               this.showNotification(_('Info'), _('Console disconnected'), 3000, 'info');
+       },
+
+       executeAction(ev, action, container_id) {
+               ev?.preventDefault();
+
+               const actionMap = Object.freeze({
+                       'start': _('Start'),
+                       'restart': _('Restart'),
+                       'stop': _('Stop'),
+                       'kill': _('Kill'),
+                       'pause': _('Pause'),
+                       'unpause': _('Unpause'),
+                       'remove': _('Remove'),
+               });
+
+               const actionLabel = actionMap[action] || action;
+
+               // Confirm removal
+               if (action === 'remove') {
+                       if (!confirm(_('Remove container?'))) {
+                               return;
+                       }
+               }
+
+               const view = this;
+               const methodName = 'container_' + action;
+               const method = dm2[methodName];
+
+               if (!method) {
+                       view.showNotification(_('Error'), _('Action unavailable: ') + action, 7000, 'error');
+                       return;
+               }
+
+               view.executeDockerAction(
+                       method,
+                       { id: container_id, query: {} },
+                       actionLabel,
+                       {
+                               showOutput: false,
+                               showSuccess: true,
+                               successMessage: actionLabel + _(' completed'),
+                               successDuration: 5000,
+                               onSuccess: () => {
+                                       if (action === 'remove') {
+                                               setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
+                                       } else {
+                                               setTimeout(() => location.reload(), 1000);
+                                       }
+                               }
+                       }
+               );
+       },
+
+       executeNetworkAction(action, networkID, networkName, this_container) {
+               const view = this;
+
+               if (action === 'disconnect') {
+                       if (!confirm(_('Disconnect network "%s" from container?').format(networkName))) {
+                               return;
+                       }
+
+                       view.executeDockerAction(
+                               dm2.network_disconnect,
+                               {
+                                       id: networkID,
+                                       body: { Container: view.containerId, Force: false }
+                               },
+                               _('Disconnect network'),
+                               {
+                                       showOutput: false,
+                                       showSuccess: true,
+                                       successMessage: _('Network disconnected'),
+                                       successDuration: 5000,
+                                       onSuccess: () => {
+                                               setTimeout(() => location.reload(), 1000);
+                                       }
+                               }
+                       );
+               } else if (action === 'connect') {
+                       const newNetworks = this.networks.filter(n => !Object.keys(this_container.NetworkSettings?.Networks || {}).includes(n.Name));
+
+                       if (newNetworks.length === 0) {
+                               view.showNotification(_('Info'), _('No additional networks available to connect'), 5000, 'info');
+                               return;
+                       }
+
+                       // Create modal dialog for selecting network
+                       const networkSelect = E('select', { 
+                               'id': 'network-select',
+                               'class': 'cbi-input-select',
+                               'style': 'width:100%; margin-top:10px;'
+                       }, newNetworks.map(n => {
+                               const subnet0 = n?.IPAM?.Config?.[0]?.Subnet;
+                               const subnet1 = n?.IPAM?.Config?.[1]?.Subnet;
+                               return E('option', { 'value': n.Id }, [`${n.Name}${n?.Driver ? ' | ' + n.Driver : ''}${subnet0 ? ' | ' + subnet0 : ''}${subnet1 ? ' | ' + subnet1 : ''}`]);
+                       }));
+
+                       const ip4Input = E('input', {
+                               'type': 'text',
+                               'id': 'network-ip',
+                               'class': 'cbi-input-text',
+                               'placeholder': 'e.g., 172.18.0.5',
+                               'style': 'width:100%; margin-top:5px;'
+                       });
+
+                       const ip6Input = E('input', {
+                               'type': 'text',
+                               'id': 'network-ip',
+                               'class': 'cbi-input-text',
+                               'placeholder': 'e.g., 2001:db8:1::1',
+                               'style': 'width:100%; margin-top:5px;'
+                       });
+
+                       const modalBody = E('div', { 'class': 'cbi-section' }, [
+                               E('p', {}, _('Select network to connect:')),
+                               networkSelect,
+                               E('label', { 'style': 'display:block; margin-top:10px;' }, _('IP Address (optional):')),
+                               ip4Input,
+                               ip6Input,
+                       ]);
+
+                       ui.showModal(_('Connect Network'), [
+                               modalBody,
+                               E('div', { 'class': 'right' }, [
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-neutral',
+                                               'click': ui.hideModal
+                                       }, _('Cancel')),
+                                       ' ',
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-positive',
+                                               'click': () => {
+                                                       const selectedNetwork = networkSelect.value;
+                                                       const ip4Address = ip4Input.value || '';
+                                                       const ip6Address = ip6Input.value || '';
+
+                                                       if (!selectedNetwork) {
+                                                               view.showNotification(_('Error'), [_('No network selected')], 5000, 'error');
+                                                               return;
+                                                       }
+
+                                                       ui.hideModal();
+
+                                                       const body = { Container: view.containerId };
+                                                       body.EndpointConfig = { IPAMConfig: { IPv4Address: ip4Address } }; //, IPv6Address: ip6Address || null
+
+                                                       view.executeDockerAction(
+                                                               dm2.network_connect,
+                                                               { id: selectedNetwork, body: body },
+                                                               _('Connect network'),
+                                                               {
+                                                                       showOutput: false,
+                                                                       showSuccess: true,
+                                                                       successMessage: _('Network connected'),
+                                                                       successDuration: 5000,
+                                                                       onSuccess: () => {
+                                                                               setTimeout(() => location.reload(), 1000);
+                                                                       }
+                                                               }
+                                                       );
+                                               }
+                                       }, _('Connect'))
+                               ])
+                       ]);
+               }
+       },
+
+       // handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js
new file mode 100644 (file)
index 0000000..11b7da2
--- /dev/null
@@ -0,0 +1,945 @@
+'use strict';
+'require form';
+'require fs';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+/* API v1.52
+
+POST /containers/create no longer supports configuring a container-wide MAC
+address via the container's Config.MacAddress field. A container's MAC address
+can now only be configured via endpoint settings when connecting to a network.
+
+*/
+
+return dm2.dv.extend({
+       load() {
+               const requestPath = L.env.requestpath;
+               const duplicateId = requestPath[requestPath.length-1];
+               const isDuplicate = requestPath[requestPath.length-2] === 'duplicate' && duplicateId;
+
+               const promises = [
+                       dm2.image_list().then(images => {
+                               return images.body || [];
+                       }),
+                       dm2.network_list().then(networks => {
+                               return networks.body || [];
+                       }),
+                       dm2.volume_list().then(volumes => {
+                               return volumes.body?.Volumes || [];
+                       }),
+                       dm2.docker_info().then(info => {
+                               const numcpus = info.body?.NCPU || 1.0;
+                               const memory = info.body?.MemTotal || 2**10;
+                               return {numcpus: numcpus, memory: memory};
+                       }),
+               ];
+
+               if (isDuplicate) {
+                       promises.push(
+                               dm2.container_inspect({ id: duplicateId }).then(container => {
+                                       this.duplicateContainer = container.body || {};
+                                       this.isDuplicate = true;
+                               })
+                       );
+               }
+
+               return Promise.all(promises);
+       },
+
+       render([image_list, network_list, volume_list, cpus_mem]) {
+               this.volumes = volume_list;
+               const view = this; // Capture the view context
+
+               // Load duplicate container config if available
+               let containerData = {container: {}};
+               let pageTitle = _('Create new docker container');
+
+               if (this.isDuplicate && this.duplicateContainer) {
+                       pageTitle = _('Duplicate/Edit Container: %s').format(this.duplicateContainer.Name?.substring(1) || '');
+                       const resolveImageId = (imageRef) => {
+                               if (!imageRef) return null;
+                               const match = (image_list || []).find(img => img.Id === imageRef || (Array.isArray(img.RepoTags) && img.RepoTags.includes(imageRef)));
+                               return match?.Id || null;
+                       };
+                       const c = this.duplicateContainer;
+                       const hostConfig = c.HostConfig || {};
+                       const resolvedImage = resolveImageId(c.Image) || resolveImageId(c.Config?.Image) || c.Image || c.Config?.Image || '';
+                       const builtInNetworks = new Set(['none', 'bridge', 'host']);
+                       const [netnames, nets] = Object.entries(c.NetworkSettings?.Networks || {});
+
+                       containerData.container = {
+                               name: c.Name?.substring(1) || '',
+                               interactive: c.Config?.AttachStdin ? 1 : 0,
+                               tty: c.Config?.Tty ? 1 : 0,
+                               image: resolvedImage,
+                               privileged: hostConfig.Privileged ? 1 : 0,
+                               restart_policy: hostConfig.RestartPolicy?.Name || 'unless-stopped',
+                               network: (() => {
+                                       return (netnames && (netnames.length > 0)) ? netnames[0] : '';
+                               })(),
+                               ipv4: (() => {
+                                       if (builtInNetworks.has(netnames[0])) return '';
+                                       return (nets && (nets.length > 0)) ? nets[0]?.IPAddress || '' : '';
+                               })(),
+                               ipv6: (() => {
+                                       if (builtInNetworks.has(netnames[0])) return '';
+                                       return (nets && (nets.length > 0)) ? nets[0]?.GlobalIPv6Address || '' : '';
+                               })(),
+                               ipv6: (() => {
+                                       if (builtInNetworks.has(netnames[0])) return '';
+                                       return (nets && (nets.length > 0)) ? nets[0]?.LinkLocalIPv6Address || '' : '';
+                               })(),
+                               link: hostConfig.Links || [],
+                               dns: hostConfig.Dns || [],
+                               user: c.Config?.User || '',
+                               env: c.Config?.Env || [],
+                               volume: (hostConfig.Mounts || c.Mounts || []).map(m => {
+                                       let source;
+                                       const destination = m.Destination || m.Target || '';
+                                       let opts = '';
+                                       if (m.Type === 'image') {
+                                               source = '@image';
+                                               if (m.ImageOptions && m.ImageOptions.Subpath)
+                                                       opts = 'subpath=' + m.ImageOptions.Subpath;
+                                       } else if (m.Type === 'tmpfs') {
+                                               source = '@tmpfs';
+                                               const tmpOpts = [];
+                                               if (m.TmpfsOptions) {
+                                                       if (m.TmpfsOptions.SizeBytes) tmpOpts.push('size=' + m.TmpfsOptions.SizeBytes);
+                                                       if (m.TmpfsOptions.Mode) tmpOpts.push('mode=' + m.TmpfsOptions.Mode);
+                                                       if (Array.isArray(m.TmpfsOptions.Options)) {
+                                                               for (const o of m.TmpfsOptions.Options) {
+                                                                       if (Array.isArray(o) && o.length === 2) tmpOpts.push(`${o[0]}=${o[1]}`);
+                                                                       else if (Array.isArray(o) && o.length === 1) tmpOpts.push(o[0]);
+                                                               }
+                                                       }
+                                               }
+                                               opts = tmpOpts.join(',');
+                                       } else if (m.Type === 'volume') {
+                                               source = m.Source || '';
+                                               // opts = m.Mode || '';
+                                       } else {
+                                               source = m.Source || '';
+                                               opts = m.Mode || '';
+                                       }
+                                       return source + ':' + destination + (opts ? ':' + opts : '');
+                               }),
+                               publish: (() => {
+                                       const ports = [];
+                                       for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
+                                               if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
+                                                       const hostPort = bindings[0].HostPort;
+                                                       ports.push(hostPort + ':' + containerPort);
+                                               }
+                                       }
+                                       return ports;
+                               })(),
+                               command: c.Config?.Cmd ? c.Config?.Cmd.join(' ') : '',
+                               hostname: c.Config?.Hostname || '',
+                               publish_all: hostConfig.PublishAllPorts ? 1 : 0,
+                               device: (hostConfig.Devices || []).map(d => d.PathOnHost + ':' + d.PathInContainer + (d.CgroupPermissions ? ':' + d.CgroupPermissions : '')),
+                               tmpfs: (() => {
+                                       const list = [];
+                                       if (hostConfig.Tmpfs && typeof hostConfig.Tmpfs === 'object') {
+                                               for (const [path, opts] of Object.entries(hostConfig.Tmpfs)) {
+                                                       list.push(path + (opts ? ':' + opts : ''));
+                                               }
+                                       }
+                                       return list;
+                               })(),
+                               sysctl: (() => {
+                                       const list = [];
+                                       if (hostConfig.Sysctls && typeof hostConfig.Sysctls === 'object') {
+                                               for (const [key, value] of Object.entries(hostConfig.Sysctls)) {
+                                                       list.push(key + ':' + value);
+                                               }
+                                       }
+                                       return list;
+                               })(),
+                               cap_add: hostConfig.CapAdd || [],
+                               cpus: hostConfig.NanoCPUs ? (hostConfig.NanoCPUs / (10 ** 9)).toFixed(3) : '',
+                               cpu_shares: hostConfig.CpuShares || '',
+                               cpu_period: hostConfig.CpuPeriod || '',
+                               cpu_quota: hostConfig.CpuQuota || '',
+                               memory: hostConfig.Memory || '',
+                               memory_reservation: hostConfig.MemoryReservation || '',
+                               blkio_weight: hostConfig.BlkioWeight || '',
+                               log_opt: (() => {
+                                       const list = [];
+                                       const logConfig = hostConfig.LogConfig?.Config;
+                                       if (logConfig && typeof logConfig === 'object') {
+                                               for (const [key, value] of Object.entries(logConfig)) {
+                                                       list.push(key + '=' + value);
+                                               }
+                                       }
+                                       return list;
+                               })(),
+                       };
+               }
+
+               // stuff JSONMap with container config
+               const m = new form.JSONMap(containerData, _('Docker - Containers'));
+               m.submit = true;
+               m.reset = true;
+
+               let s = m.section(form.NamedSection, 'container', pageTitle);
+               s.anonymous = true;
+               s.nodescriptions = true;
+               s.addremove = false;
+
+               let o;
+
+               o = s.option(form.Value, 'name', _('Container Name'),
+                       _('Name of the container that can be selected during container creation'));
+               o.rmempty = true;
+
+               o = s.option(form.Flag, 'interactive', _('Interactive (-i)'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+
+               o = s.option(form.Flag, 'tty', _('TTY (-t)'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+
+               o = s.option(form.ListValue, 'image', _('Docker Image'));
+               o.rmempty = true;
+               for (const image of image_list) {
+                       o.value(image.Id, image?.RepoTags?.[0]);
+               }
+
+               o = s.option(form.Flag, 'pull', _('Always pull image first'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+
+               o = s.option(form.Flag, 'privileged', _('Privileged'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+
+               o = s.option(form.ListValue, 'restart_policy', _('Restart Policy'));
+               o.rmempty = true;
+               o.default = 'unless-stopped';
+               o.value('no', _('No'));
+               o.value('unless-stopped', _('Unless stopped'));
+               o.value('always', _('Always'));
+               o.value('on-failure', _('On failure'));
+
+               o = s.option(form.ListValue, 'network', _('Networks'));
+               o.rmempty = true;
+               this.buildNetworkListValues(network_list, o);
+
+               function not_with_a_docker_net(section_id, value) {
+                       if (!value || value === "") return true;
+                       const builtInNetworks = new Set(['none', 'bridge', 'host']);
+                       let dnet = this.section.getOption('network').getUIElement(section_id).getValue();
+                       const disallowed = builtInNetworks.has(dnet);
+                       if (disallowed) return _('Only for user-defined networks');
+               };
+
+               o = s.option(form.Value, 'ipv4', _('IPv4 Address'));
+               o.rmempty = true;
+               o.datatype = 'ip4addr';
+               o.validate = not_with_a_docker_net;
+
+               o = s.option(form.Value, 'ipv6', _('IPv6 Address'));
+               o.rmempty = true;
+               o.datatype = 'ip6addr';
+               o.validate = not_with_a_docker_net;
+
+               o = s.option(form.Value, 'ipv6_lla', _('IPv6 Link-Local Address'));
+               o.rmempty = true;
+               o.datatype = 'ip6ll';
+               o.validate = not_with_a_docker_net;
+
+               o = s.option(form.DynamicList, 'link', _('Links with other containers'));
+               o.rmempty = true;
+               o.placeholder='container_name:alias';
+
+               o = s.option(form.DynamicList, 'dns', _('Set custom DNS servers'));
+               o.rmempty = true;
+               o.placeholder='8.8.8.8';
+
+               o = s.option(form.Value, 'user', _('User(-u)'),
+                       _('The user that commands are run as inside the container. (format: name|uid[:group|gid])'));
+               o.rmempty = true;
+               o.placeholder='1000:1000';
+
+               o = s.option(form.DynamicList, 'env', _('Environmental Variable(-e)'),
+                       _('Set environment variables inside the container'));
+               o.rmempty = true;
+               o.placeholder='TZ=Europe/Paris';
+
+               o = s.option(form.DummyValue, 'volume', _('Mount(--mount)'),
+                       _('Bind mount a volume'));
+               o.rmempty = true;
+               o.cfgvalue = () => {
+                       const c_volumes = view.map.data.get('json', 'container', 'volume') || [];
+
+                       const showVolumeModal = (index, initialEntry) => {
+                               let typeSelect, bindPicker, bindSourceField, volumeNameInput, volumeSourceField, pathInput, pathField, optionsDropdown, optionsField, subpathInput;
+                               let tmpfsSizeInput, tmpfsModeInput, tmpfsOptsInput, tmpfsSizeField, tmpfsModeField, tmpfsOptsField;
+                               const isEdit = index !== null;
+                               const modalTitle = isEdit ? _('Edit Mount') : _('Add Mount');
+
+                                       // Parse existing entry if editing and infer type from volumes list, image, or tmpfs
+                                       let initialType = 'bind', initialSource = '', initialPath = '', initialOptions = '';
+                                       if (isEdit && initialEntry) {
+                                               const parts = (typeof initialEntry === 'string' ? initialEntry : '').split(':');
+                                               initialSource = parts[0] || '';
+                                               initialPath = parts[1] || '';
+                                               initialOptions = parts[2] || '';
+                                               // Infer type: tmpfs, volume, image, else bind
+                                               const isTmpfs = (initialSource === '@tmpfs');
+                                               const isVolume = (volume_list || []).some(v => v.Name === initialSource || v.Id === initialSource);
+                                               const isImage = (initialSource === '@image');
+                                               initialType = isTmpfs ? 'tmpfs' : (isVolume ? 'volume' : (isImage ? 'image' : 'bind'));
+                               }
+
+                               const existingOptions = (typeof initialOptions === 'string' ? initialOptions : '').split(',').map(o => o.trim()).filter(Boolean);
+
+                               // Type-specific options for dropdowns
+                               const bindOptions = {
+                                       'ro': _('Read-only (ro)'),
+                                       'rw': _('Read-write (rw)'),
+                                       'private': _('Propagation: private'),
+                                       'rprivate': _('Propagation: rprivate'),
+                                       'shared': _('Propagation: shared'),
+                                       'rshared': _('Propagation: rshared'),
+                                       'slave': _('Propagation: slave'),
+                                       'rslave': _('Propagation: rslave')
+                               };
+
+                               const volumeOptions = {
+                                       // 'ro': _('Read-only (ro)'),
+                                       // 'rw': _('Read-write (rw)'),
+                                       'nocopy': _('No copy (nocopy)')
+                               };
+
+                               const getOptionsForType = (type) => type === 'bind' ? bindOptions : volumeOptions;
+
+                               const namesListId = 'volname-list-' + Math.random().toString(36).substr(2, 9);
+
+                               // Create dropdown for options - updates based on type
+                               optionsDropdown = new ui.Dropdown(existingOptions, getOptionsForType(initialType), {
+                                       id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
+                                       multiple: true,
+                                       optional: true,
+                                       display_items: 2,
+                                       placeholder: _('Select options...')
+                               });
+
+                               const createField = (label, input) => {
+                                       return E('div', { 'class': 'cbi-value' }, [
+                                               E('label', { 'class': 'cbi-value-title' }, label),
+                                               E('div', { 'class': 'cbi-value-field' }, Array.isArray(input) ? input : [input])
+                                       ]);
+                               };
+
+                               // Type select
+                               const typeOptions = [
+                                       E('option', { value: 'bind' }, _('Bind (host directory)')),
+                                       E('option', { value: 'volume' }, _('Volume (named)')),
+                                       E('option', { value: 'image' }, _('Image (from image)')),
+                                       E('option', { value: 'tmpfs' }, _('Tmpfs'))
+                               ];
+                               typeSelect = E('select', { 'class': 'cbi-input-select' }, typeOptions);
+                               typeSelect.value = initialType;
+
+                               // Bind directory picker using ui.FileUpload
+                               bindPicker = new ui.FileUpload(initialType === 'bind' ? initialSource : '', {
+                                       browser: false,
+                                       directory_select: true,
+                                       directory_create: false,
+                                       enable_upload: false,
+                                       enable_remove: false,
+                                       enable_download: false,
+                                       root_directory: '/',
+                                       show_hidden: true
+                               });
+
+                               // Volume name input with datalist
+                               volumeNameInput = E('input', {
+                                       'type': 'text',
+                                       'class': 'cbi-input-text',
+                                       'placeholder': _('Enter volume name or pick existing'),
+                                       'list': namesListId,
+                                       'value': initialType === 'volume' ? initialSource : ''
+                               });
+                               volumeSourceField = createField(_('Volume Name'), [
+                                       E('div', { 'style': 'position: relative;' }, [
+                                               volumeNameInput,
+                                               E('span', { 'style': 'pointer-events: none;' }, '▼')
+                                       ]),
+                                       E('datalist', { 'id': namesListId }, [
+                                               ...volume_list.map(vol => E('option', { 'value': vol.Name }, vol.Name))
+                                       ])
+                               ]);
+
+                               // Tmpfs inputs - pre-populate if editing
+                               let tmpfsSizeVal = '', tmpfsModeVal = '', tmpfsOptsVal = '';
+                               if (initialType === 'tmpfs' && existingOptions.length) {
+                                       const rest = [];
+                                       existingOptions.forEach(o => {
+                                               if (o.startsWith('size=')) tmpfsSizeVal = o.slice('size='.length);
+                                               else if (o.startsWith('mode=')) tmpfsModeVal = view.modeToRwx(o.slice('mode='.length));
+                                               else rest.push(o);
+                                       });
+                                       tmpfsOptsVal = rest.join(',');
+                               }
+
+                               tmpfsSizeField = createField(_('Size'),
+                                       tmpfsSizeInput = E('input', {
+                                               'class': 'cbi-input-text',
+                                               'placeholder': '128m',
+                                               'value': tmpfsSizeVal
+                                       })
+                               );
+                               tmpfsModeField = createField(_('Mode'),
+                                       tmpfsModeInput = E('input', {
+                                               'class': 'cbi-input-text',
+                                               'placeholder': 'rwxr-xr-x or 1770',
+                                               'value': tmpfsModeVal
+                                       })
+                               );
+                               tmpfsOptsField = createField(_('tmpfs Options'),
+                                       tmpfsOptsInput = E('input', {
+                                               'type': 'text',
+                                               'class': 'cbi-input-text',
+                                               'placeholder': 'nr_blocks=blocks,...',
+                                               'value': tmpfsOptsVal
+                                       })
+                               );
+
+                               // Render bindPicker and show modal
+                               Promise.resolve(bindPicker.render()).then(bindPickerNode => {
+                                       bindSourceField = createField(_('Host Directory'), bindPickerNode);
+
+                                       const updateOptions = (selectedType) => {
+                                               optionsField.querySelector('.cbi-value-field').innerHTML = '';
+                                               if (selectedType === 'image') {
+                                                       // For image mounts, show a Subpath text input (only option)
+                                                       subpathInput = E('input', {
+                                                               'type': 'text',
+                                                               'class': 'cbi-input-text',
+                                                               'placeholder': _('/path/in/image'),
+                                                               'value': (initialType === 'image' && existingOptions.find(o => o.startsWith('subpath='))) ? existingOptions.find(o => o.startsWith('subpath=')).slice('subpath='.length) : ''
+                                                       });
+                                                       optionsField.querySelector('.cbi-value-title').textContent = _('Subpath');
+                                                       optionsField.querySelector('.cbi-value-field').appendChild(subpathInput);
+                                               } else if (selectedType === 'tmpfs') {
+                                                       // Tmpfs fields are shown as main fields, hide options field
+                                                       optionsField.style.display = 'none';
+                                               } else {
+                                                       optionsField.querySelector('.cbi-value-title').textContent = _('Options');
+                                                       // Recreate dropdown with new options
+                                                       const currentValue = optionsDropdown.getValue();
+                                                       optionsDropdown = new ui.Dropdown(currentValue, getOptionsForType(selectedType), {
+                                                               id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
+                                                               multiple: true,
+                                                               optional: true,
+                                                               display_items: 2,
+                                                               placeholder: _('Select options...')
+                                                       });
+                                                       optionsField.querySelector('.cbi-value-field').appendChild(optionsDropdown.render());
+                                                       optionsField.style.display = '';
+                                               }
+                                       };
+
+                                       const toggleSources = () => {
+                                               const isBind = typeSelect.value === 'bind';
+                                               const isVolume = typeSelect.value === 'volume';
+                                               const isImage = typeSelect.value === 'image';
+                                               const isTmpfs = typeSelect.value === 'tmpfs';
+                                               bindSourceField.style.display = isBind ? '' : 'none';
+                                               volumeSourceField.style.display = isVolume ? '' : 'none';
+                                               pathField.style.display = isImage ? 'none' : '';
+                                               tmpfsSizeField.style.display = isTmpfs ? '' : 'none';
+                                               tmpfsModeField.style.display = isTmpfs ? '' : 'none';
+                                               tmpfsOptsField.style.display = isTmpfs ? '' : 'none';
+                                               updateOptions(typeSelect.value);
+                                       };
+
+                                       optionsField = createField(_('Options'), optionsDropdown.render());
+
+                                       ui.showModal(modalTitle, [
+                                               createField(_('Type'), typeSelect),
+                                               bindSourceField,
+                                               volumeSourceField,
+                                               pathField = createField(_('Mount Path'),
+                                                       pathInput = E('input', {
+                                                               'type': 'text',
+                                                               'class': 'cbi-input-text',
+                                                               'placeholder': _('/mnt/path'),
+                                                               'value': initialPath
+                                                       })
+                                               ),
+                                               tmpfsSizeField,
+                                               tmpfsModeField,
+                                               tmpfsOptsField,
+                                               optionsField,
+                                               E('div', { 'class': 'right' }, [
+                                                       E('button', {
+                                                               'class': 'cbi-button',
+                                                               'click': ui.hideModal
+                                                       }, [_('Cancel')]),
+                                                       ' ',
+                                                       E('button', {
+                                                               'class': 'cbi-button cbi-button-positive',
+                                                               'click': ui.createHandlerFn(view, () => {
+                                                                       const selectedType = typeSelect.value;
+                                                                       const sourcePath = selectedType === 'bind'
+                                                                               ? (bindPicker.getValue() || '').trim()
+                                                                               : (selectedType === 'volume'
+                                                                                       ? (volumeNameInput.value || '').trim()
+                                                                                       : (selectedType === 'tmpfs' ? '@tmpfs' : '@image'));
+                                                                       const subpathVal = (selectedType === 'image') ? (subpathInput?.value || '').trim() : '';
+                                                                       const mountPath = (selectedType === 'image') ? subpathVal : pathInput.value.trim();
+                                                                       let selectedOptions;
+                                                                       if (selectedType === 'image') {
+                                                                               selectedOptions = subpathVal ? ('subpath=' + subpathVal) : '';
+                                                                       } else if (selectedType === 'tmpfs') {
+                                                                               const opts = [];
+                                                                               const sizeValRaw = (tmpfsSizeInput?.value || '').trim();
+                                                                               const modeValRaw = (tmpfsModeInput?.value || '').trim();
+                                                                               const extraVal = (tmpfsOptsInput?.value || '').trim();
+                                                                               const parsedSize = sizeValRaw ? view.parseMemory(sizeValRaw) : undefined;
+                                                                               const parsedMode = view.rwxToMode(modeValRaw);
+                                                                               if (parsedSize) opts.push('size=' + parsedSize);
+                                                                               else if (sizeValRaw) opts.push('size=' + sizeValRaw); // fallback if parse fails
+                                                                               if (parsedMode !== undefined) opts.push('mode=' + parsedMode);
+                                                                               if (extraVal) opts.push(...extraVal.split(',').map(o => o.trim()).filter(Boolean));
+                                                                               selectedOptions = opts.join(',');
+                                                                       } else {
+                                                                               selectedOptions = optionsDropdown.getValue().join(',');
+                                                                       }
+
+                                                                       if (!sourcePath) {
+                                                                               ui.addTimeLimitedNotification(null, [_('Please choose a directory or enter a volume name')], 3000, 'warning');
+                                                                               return;
+                                                                       }
+
+                                                                       if (selectedType !== 'image' && !mountPath) {
+                                                                               ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
+                                                                               return;
+                                                                       }
+                                                                       if (selectedType === 'image' && !subpathVal) {
+                                                                               ui.addTimeLimitedNotification(null, [_('Please enter a subpath')], 3000, 'warning');
+                                                                               return;
+                                                                       }
+                                                                       if (selectedType === 'tmpfs' && !mountPath) {
+                                                                               ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
+                                                                               return;
+                                                                       }
+
+                                                                       ui.hideModal();
+
+                                                                       const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
+                                                                       const volumeEntry = selectedOptions ? (sourcePath + ':' + mountPath + ':' + selectedOptions) : (sourcePath + ':' + mountPath);
+                                                                       let updatedVolumes;
+                                                                       if (isEdit) {
+                                                                               updatedVolumes = [...currentVolumes];
+                                                                               updatedVolumes[index] = volumeEntry;
+                                                                       } else {
+                                                                               updatedVolumes = Array.isArray(currentVolumes) ? [...currentVolumes, volumeEntry] : [volumeEntry];
+                                                                       }
+                                                                       view.map.data.set('json', 'container', 'volume', updatedVolumes);
+
+                                                                       return view.map.render();
+                                                               })
+                                                       }, [isEdit ? _('Update') : _('Add')])
+                                               ])
+                                       ]);
+
+                                       toggleSources();
+                                       typeSelect.addEventListener('change', toggleSources);
+                               });
+                       };
+
+
+                       return E('div', { 'class': 'cbi-dynlist' }, [
+                               ...(c_volumes.length > 0 ? c_volumes.map((v, idx) => E('div', {
+                                       'class': 'cbi-dynlist-item',
+                                       'style': 'display: flex; justify-content: space-between; align-items: center; padding: 8px 5px; margin-bottom: 8px; gap: 10px;'
+                               }, [
+                                       E('span', {
+                                               'style': 'cursor: pointer; flex: 1;',
+                                               'click': ui.createHandlerFn(view, () => {
+                                                       showVolumeModal(idx, v);
+                                               })
+                                       }, v),
+                                       E('button', {
+                                               'style': 'padding: 5px; color: #c44;',
+                                               'class': 'cbi-button-negative remove',
+                                               'title': _('Delete this volume mount'),
+                                               'click': ui.createHandlerFn(view, () => {
+                                                       const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
+                                                       const updatedVolumes = currentVolumes.filter((_, i) => i !== idx);
+                                                       view.map.data.set('json', 'container', 'volume', updatedVolumes);
+                                                       return view.map.render();
+                                               })
+                                       }, ['✕'])
+                               ])) : [E('div', { 'style': 'padding: 5px; color: #999;' }, _('No volumes available'))]),
+                               E('button', {
+                                       'class': 'cbi-button',
+                                       'click': ui.createHandlerFn(view, () => {
+                                               showVolumeModal(null, null);
+                                       })
+                               }, [_('Add Mount')])
+                       ]);
+               };
+               o.rmempty = true;
+
+               o = s.option(form.DynamicList, 'publish', _('Exposed Ports(-p)'),
+                       _("Publish container's port(s) to the host"));
+               o.rmempty = true;
+               o.placeholder='2200:22/tcp';
+
+               o = s.option(form.Value, 'command', _('Run command'));
+               o.rmempty = true;
+               o.placeholder='/bin/sh init.sh';
+
+               o = s.option(form.Flag, 'advanced', _('Advanced'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+
+               o = s.option(form.Value, 'hostname', _('Host Name'),
+                       _('The hostname to use for the container'));
+               o.rmempty = true;
+               o.placeholder='/bin/sh init.sh';
+               o.depends('advanced', 1);
+
+               o = s.option(form.Flag, 'publish_all', _('Exposed All Ports(-P)'),
+                       _("Allocates an ephemeral host port for all of a container's exposed ports"));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+               o.depends('advanced', 1);
+
+               o = s.option(form.DynamicList, 'device', _('Device(--device)'),
+                       _('Add host device to the container'));
+               o.rmempty = true;
+               o.placeholder='/dev/sda:/dev/xvdc:rwm';
+               o.depends('advanced', 1);
+
+               o = s.option(form.DynamicList, 'tmpfs', _('Tmpfs(--tmpfs)'),
+                       _('Mount tmpfs directory'));
+               o.rmempty = true;
+               o.placeholder='/run:rw,noexec,nosuid,size=65536k';
+               o.depends('advanced', 1);
+
+               o = s.option(form.DynamicList, 'sysctl', _('Sysctl(--sysctl)'),
+                       _('Sysctls (kernel parameters) options'));
+               o.rmempty = true;
+               o.placeholder='net.ipv4.ip_forward=1';
+               o.depends('advanced', 1);
+
+               o = s.option(form.DynamicList, 'cap_add', _('CAP-ADD(--cap-add)'),
+                       _('A list of kernel capabilities to add to the container'));
+               o.rmempty = true;
+               o.placeholder='NET_ADMIN';
+               o.depends('advanced', 1);
+
+               o = s.option(form.Value, 'cpus', _('CPUs'),
+                       _('Number of CPUs. Number is a fractional number. 0.000 means no limit'));
+               o.rmempty = true;
+               o.placeholder='1.5';
+               o.datatype = 'ufloat';
+               o.depends('advanced', 1);
+               o.validate = function(section_id, value) {
+                       if (!value) return true;
+                       if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
+                       return true;
+               };
+
+               o = s.option(form.Value, 'cpu_period', _('CPU Period'),
+                       _('The length of a CPU period in microseconds'));
+               o.rmempty = true;
+               o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
+               o.depends('advanced', 1);
+
+               o = s.option(form.Value, 'cpu_quota', _('CPU Quota'),
+                       _('Microseconds of CPU time that the container can get in a CPU period'));
+               o.rmempty = true;
+               o.datatype = 'uinteger';
+               o.depends('advanced', 1);
+
+               o = s.option(form.Value, 'cpu_shares', _('CPU Shares Weight'),
+                       _('CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024'));
+               o.rmempty = true;
+               o.placeholder='1024';
+               o.datatype = 'uinteger';
+               o.depends('advanced', 1);
+
+               o = s.option(form.Value, 'memory', _('Memory'),
+                       _('Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M'));
+               o.rmempty = true;
+               o.placeholder = '128m';
+               o.depends('advanced', 1);
+               o.write = function(section_id, value) {
+                       if (!value || value == 0) return 0;
+                       this.map.data.data[section_id].memory = view.parseMemory(value);;
+                       return view.parseMemory(value);
+               };
+               o.validate = function(section_id, value) {
+                       if (!value) return true;
+                       if (value > view.memory) return _(`Only ${view.memory} bytes available`);
+                       return true;
+               };
+
+               o = s.option(form.Value, 'memory_reservation', _('Memory Reservation'));
+               o.depends('advanced', 1);
+               o.placeholder = '128m';
+               o.cfgvalue = (sid, val) => {
+                       const res = view.map.data.data[sid].memory_reservation;
+                       return res ? '%1024.2m'.format(res) : 0;
+               };
+               o.write = function(section_id, value) {
+                       if (!value || value == 0) return 0;
+                       this.map.data.data[section_id].memory_reservation = view.parseMemory(value);;
+                       return view.parseMemory(value);
+               };
+
+               o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'),
+                       _('Block IO weight (relative weight) accepts a weight value between 10 and 1000.'));
+               o.rmempty = true;
+               o.placeholder='500';
+               o.datatype = 'and(uinteger,min(10),max(1000))';
+               o.depends('advanced', 1);
+
+               o = s.option(form.DynamicList, 'log_opt', _('Log driver options'),
+                       _('The logging configuration for this container'));
+               o.rmempty = true;
+               o.placeholder='max-size=1m';
+               o.depends('advanced', 1);
+
+
+               this.map = m;
+
+               return m.render();
+
+       },
+
+       handleSave(ev) {
+               ev?.preventDefault();
+               const view = this; // Capture the view context
+               const map = this.map;
+               if (!map)
+                       return Promise.reject(new Error(_('Form is not ready yet.')));
+
+               const listToKv = view.listToKv;
+
+               const toBool = (val) => (val === 1 || val === '1' || val === true);
+               const toInt = (val) => val ? Number.parseInt(val) : undefined;
+               const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
+
+               return map.parse()
+                       .then(() => {
+                               const get = (opt) => map.data.get('json', 'container', opt);
+                               const name = get('name');
+                               const pull = toBool(get('pull'));
+                               const network = get('network');
+                               const publish = get('publish');
+                               const command = get('command');
+                               const publish_all = toBool(get('publish_all'));
+                               const device = get('device');
+                               const tmpfs = get('tmpfs');
+                               const sysctl = get('sysctl');
+                               const log_opt = get('log_opt');
+
+                               const createBody = {
+                                       Hostname: get('hostname'),
+                                       User: get('user'),
+                                       AttachStdin: toBool(get('interactive')),
+                                       Tty: toBool(get('tty')),
+                                       OpenStdin: toBool(get('interactive')),
+                                       Env: get('env'),
+                                       Cmd: command ? command.split(' ') : null,
+                                       Image: get('image'),
+                                       HostConfig: {
+                                               CpuShares: toInt(get('cpu_shares')),
+                                               Memory: toInt(get('memory')),
+                                               MemoryReservation: toInt(get('memory_reservation')),
+                                               BlkioWeight: toInt(get('blkio_weight')),
+                                               CapAdd: get('cap_add'),
+                                               CpuPeriod: toInt(get('cpu_period')),
+                                               CpuQuota: toInt(get('cpu_quota')),
+                                               NanoCPUs: toFloat(get('cpus')) * (10 ** 9),
+                                               Devices: device ? device
+                                                       .filter(d => d && typeof d === 'string' && d.trim().length > 0)
+                                                       .map(d => {
+                                                       const parts = d.split(':');
+                                                       return {
+                                                               PathOnHost: parts[0],
+                                                               PathInContainer: parts[1] || parts[0],
+                                                               CgroupPermissions: parts[2] || 'rwm'
+                                                       };
+                                               }) : undefined,
+                                               LogConfig: log_opt ? {
+                                                       Type: 'json-file',
+                                                       Config: listToKv(log_opt)
+                                               } : undefined,
+                                               NetworkMode: network,
+                                               PortBindings: publish ? Object.fromEntries(
+                                                       (Array.isArray(publish) ? publish : [publish])
+                                                       .filter(p => p && typeof p === 'string' && p.trim().length > 0)
+                                                       .map(p => {
+                                                               const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
+                                                               if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
+                                                               return null;
+                                                       }).filter(Boolean)
+                                               ) : undefined,
+                                               Mounts: undefined,
+                                               Links: get('link'),
+                                               Privileged: toBool(get('privileged')),
+                                               PublishAllPorts: toBool(get('publish_all')),
+                                               RestartPolicy: { Name: get('restart_policy') },
+                                               Dns: get('dns'),
+                                               Tmpfs: tmpfs ? Object.fromEntries(
+                                                       (Array.isArray(tmpfs) ? tmpfs : [tmpfs])
+                                                       .filter(t => t && typeof t === 'string' && t.trim().length > 0)
+                                                       .map(t => {
+                                                               const parts = t.split(':');
+                                                               return [parts[0], parts[1] || ''];
+                                                       })
+                                               ) : undefined,
+                                               Sysctls: sysctl ? listToKv(sysctl) : undefined,
+                                       },
+                                       NetworkingConfig: {
+                                               EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } },
+                                       }
+                               };
+
+                               // Parse volume entries and populate Mounts
+                               const volumeEntries = get('volume') || [];
+                               const volumeNames = new Set((view.volumes || []).map(v => v.Name));
+                               const volumeIds = new Set((view.volumes || []).map(v => v.Id));
+                               const mounts = [];
+                               for (const entry of volumeEntries) {
+                                       let [source, target, options] = (typeof entry === 'string' ? entry : '')?.split(':')?.map(e => e && e.trim() || '');
+                                       if (!options) options = '';
+
+                                       // Validate source and target are not empty
+                                       if (!source || !target) {
+                                               console.warn('Invalid volume entry (empty source or target):', entry);
+                                               continue;
+                                       }
+
+                                       // Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind
+                                       let type = 'bind';
+                                       if (source === '@image') {
+                                               type = 'image';
+                                       } else if (source === '@tmpfs') {
+                                               type = 'tmpfs';
+                                       } else if (volumeNames.has(source) || volumeIds.has(source)) {
+                                               type = 'volume';
+                                       }
+
+                                       const mount = {
+                                               Type: type,
+                                               Source: source,
+                                               Target: target,
+                                               ReadOnly: options.split(',').includes('ro')
+                                       };
+
+                                       // Add type-specific options
+                                       if (type === 'bind') {
+                                               const bindOptions = {};
+                                               const propagation = options.split(',').find(opt => 
+                                                       ['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt)
+                                               );
+                                               if (propagation) bindOptions.Propagation = propagation;
+                                               if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions;
+                                       } else if (type === 'volume') {
+                                               const volumeOptions = {};
+                                               if (options.includes('nocopy')) volumeOptions.NoCopy = true;
+                                               if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions;
+                                       } else if (type === 'image') {
+                                               const imageOptions = {};
+                                               const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath='));
+                                               if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length);
+                                               if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions;
+                                               // Image source is implied by selected container image
+                                               mount.Source = createBody.Image;
+                                       } else if (type === 'tmpfs') {
+                                               const tmpfsOptions = {};
+                                               const optsList = options.split(',').map(o => o.trim()).filter(Boolean);
+                                               for (const opt of optsList) {
+                                                       if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length));
+                                                       else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length));
+                                                       else {
+                                                               if (!tmpfsOptions.Options) tmpfsOptions.Options = [];
+                                                               const kv = opt.split('=');
+                                                               if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]);
+                                                               else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]);
+                                                       }
+                                               }
+                                               mount.Source = '';
+                                               if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions;
+                                       }
+
+                                       mounts.push(mount);
+                               }
+                               createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined;
+
+                               // Clean up undefined values
+                               Object.keys(createBody.HostConfig).forEach(key => {
+                                       if (createBody.HostConfig[key] === undefined)
+                                               delete createBody.HostConfig[key];
+                               });
+
+                               if (!name)
+                                       return Promise.reject(new Error(_('No name specified.')));
+
+                               return { name, createBody };
+                       })
+                       .then(({ name, createBody }) => view.executeDockerAction(
+                               dm2.container_create,
+                               { query: { name: name }, body: createBody },
+                               _('Create container'),
+                               {
+                                       showOutput: false,
+                                       showSuccess: false,
+                                       onSuccess: (response) => {
+                                               const isDuplicate = view.isDuplicate && view.duplicateContainer;
+                                               const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created');
+                                               const msgText = isDuplicate ? 
+                                                       _('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) : 
+                                                       _('New container has been created.');
+
+                                               if (response?.body?.Warnings) {
+                                                       view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning');
+                                               } else {
+                                                       view.showNotification(msgTitle, msgText, 4000, 'success');
+                                               }
+                                               window.location.href = `${this.dockerman_url}/containers`;
+                                       }
+                               }
+                       ))
+                       .catch((err) => {
+                               view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error');
+                               return false;
+                       });
+       },
+
+       handleSaveApply: null,
+       handleReset: null,
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js
new file mode 100644 (file)
index 0000000..301888b
--- /dev/null
@@ -0,0 +1,360 @@
+'use strict';
+'require form';
+'require fs';
+'require poll';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+/* API v1.52:
+
+GET /containers/{id}/json: the NetworkSettings no longer returns the deprecated
+ Bridge, HairpinMode, LinkLocalIPv6Address, LinkLocalIPv6PrefixLen,
+ SecondaryIPAddresses, SecondaryIPv6Addresses, EndpointID, Gateway,
+ GlobalIPv6Address, GlobalIPv6PrefixLen, IPAddress, IPPrefixLen, IPv6Gateway,
+ and MacAddress fields. These fields were deprecated in API v1.21 (docker
+ v1.9.0) but kept around for backward compatibility.
+
+*/
+
+return dm2.dv.extend({
+       load() {
+               return Promise.all([
+                       dm2.container_list({query: {all: true}}),
+                       dm2.image_list({query: {all: true}}),
+                       dm2.network_list({query: {all: true}}),
+               ]);
+       },
+
+       render([containers, images, networks]) {
+               if (containers?.code !== 200) {
+                       return E('div', {}, [ containers?.body?.message ]);
+               }
+
+               let container_list = containers.body;
+               let network_list = networks.body;
+               let image_list = images.body;
+
+               const view = this;
+               let containerTable;
+
+
+               const m = new form.JSONMap({container: view.getContainersTable(container_list, image_list, network_list), prune: {}},
+                       _('Docker - Containers'),
+                       _('This page displays all docker Containers that have been created on the connected docker host.') + '<br />' +
+                       _('Note: docker provides no container import facility.'));
+               m.submit = false;
+               m.reset = false;
+
+               let s, o;
+
+
+               let pollPending = null;
+               let conSec = null;
+               const calculateTotals = () => {
+                       return {
+                               running_total: Array.isArray(container_list) ?
+                                       container_list.filter(c => c?.State === 'running').length : 0,
+                               paused_total: Array.isArray(container_list) ?
+                                       container_list.filter(c => c?.State === 'paused').length : 0,
+                               stopped_total: Array.isArray(container_list) ?
+                                       container_list.filter(c => ['exited', 'created'].includes(c?.State)).length : 0
+                       };
+               };
+
+               const refresh = () => {
+                       if (pollPending) return pollPending;
+                       pollPending = view.load().then(([containers2, images2, networks2]) => {
+                               image_list = images2.body;
+                               container_list = containers2.body;
+                               network_list = networks2.body;
+                               m.data = new m.data.constructor({ container: view.getContainersTable(container_list, image_list, network_list), prune: {} });
+
+                               const totals = calculateTotals();
+                               if (conSec) {
+                                       conSec.footer = [
+                                               `${_('Total')} ${container_list.length}`,
+                                               [
+                                                       `${_('Running')} ${totals.running_total}`,
+                                                       E('br'),
+                                                       `${_('Paused')} ${totals.paused_total}`,
+                                                       E('br'),
+                                                       `${_('Stopped')} ${totals.stopped_total}`,
+                                               ],
+                                               '',
+                                               '',
+                                       ];
+                               }
+                               
+                               return m.render();
+                       }).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
+                       return pollPending;
+               };
+
+               s = m.section(form.TableSection, 'prune', _('Containers overview'), null);
+               s.addremove = false;
+               s.anonymous = true;
+
+               const prune = s.option(form.Button, '_prune', null);
+               prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
+               prune.inputstyle = 'negative';
+               prune.onclick = L.bind(function(section_id, ev) {
+                       return this.super('handleXHRTransfer', [{
+                               q_params: {  },
+                               commandCPath: '/containers/prune',
+                               commandDPath: '/containers/prune',
+                               commandTitle: dm2.ActionTypes['prune'].i18n,
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch { }
+                               },
+                               noFileUpload: true,
+                       }]);
+               }, this);
+
+               const totals = calculateTotals();
+               let running_total = totals.running_total;
+               let paused_total = totals.paused_total;
+               let stopped_total = totals.stopped_total;
+
+               conSec = m.section(form.TableSection, 'container');
+               conSec.anonymous = true;
+               conSec.nodescriptions = true;
+               conSec.addremove = true;
+               conSec.sortable = true;
+               conSec.filterrow = true;
+               conSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
+               conSec.footer = [
+                       `${_('Total')} ${container_list.length}`,
+                       [
+                               `${_('Running')} ${running_total}`,
+                               E('br'),
+                               `${_('Paused')} ${paused_total}`,
+                               E('br'),
+                               `${_('Stopped')} ${stopped_total}`,
+                       ],
+                       '',
+                       '',
+               ];
+
+               conSec.handleAdd = function(section_id, ev) {
+                       window.location.href = `${view.dockerman_url}/container_new`;
+               };
+
+               conSec.renderRowActions = function(sid) {
+                       const cont = this.map.data.data[sid];
+                       return view.buildContainerActions(cont);
+               }
+
+               o = conSec.option(form.DummyValue, 'cid', _('Container'));
+               o = conSec.option(form.DummyValue, 'State', _('State'));
+               o = conSec.option(form.DummyValue, 'Networks', _('Networks'));
+               o.rawhtml = true;
+               // o = conSec.option(form.DummyValue, 'Ports', _('Ports'));
+               // o.rawhtml = true;
+               o = conSec.option(form.DummyValue, 'Command', _('Command'));
+               o.width = 200;
+               o = conSec.option(form.DummyValue, 'Created', _('Created'));
+
+               poll.add(L.bind(() => { refresh(); }, this), 10);
+
+               this.insertOutputFrame(conSec, m);
+               return m.render();
+
+       },
+
+       buildContainerActions(cont, idx) {
+               const view = this;
+               const isRunning = cont?.State === 'running';
+               const isPaused = cont?.State === 'paused';
+               const btns = [
+                       E('button', {
+                               'class': 'cbi-button view',
+                               'title': dm2.ActionTypes['inspect'].i18n,
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_inspect,
+                                       {id: cont.Id},
+                                       dm2.ActionTypes['inspect'].i18n,
+                                       {showOutput: true, showSuccess: false}
+                               )
+                       }, [dm2.ActionTypes['inspect'].e]),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-positive edit',
+                               'title': _('Edit this container'),
+                               'click': () => window.location.href = `${view.dockerman_url}/container/${cont?.Id}`
+                       }, [dm2.ActionTypes['edit'].e]),
+
+                       (() => {
+                               const icon = isRunning
+                                       ? dm2.Types['container'].sub['pause'].e
+                                       : (isPaused 
+                                               ? dm2.Types['container'].sub['unpause'].e
+                                               : dm2.Types['container'].sub['start'].e);
+                               const title = isRunning
+                                       ? _('Pause this container')
+                                       : (isPaused ? _('Unpause this container') : _('Start this container'));
+                               const handler = isRunning
+                                       ? () => view.executeDockerAction(
+                                                       dm2.container_pause,
+                                                       {id: cont.Id},
+                                                       dm2.Types['container'].sub['pause'].i18n,
+                                                       {showOutput: true, showSuccess: false}
+                                               )
+                                       : (isPaused ? () => view.executeDockerAction(
+                                                       dm2.container_unpause,
+                                                       {id: cont.Id},
+                                                       dm2.Types['container'].sub['unpause'].i18n,
+                                                       {showOutput: true, showSuccess: false}
+                                               ) : () => view.executeDockerAction(
+                                                       dm2.container_start,
+                                                       {id: cont.Id},
+                                                       dm2.Types['container'].sub['start'].i18n,
+                                                       {showOutput: true, showSuccess: false}
+                                               ));
+                               const btnClass = isRunning ? 'cbi-button cbi-button-neutral' : 'cbi-button cbi-button-positive start';
+
+                               return E('button', {
+                                       'class': btnClass,
+                                       'title': title,
+                                       'click': handler,
+                               }, [icon]);
+                       })(),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-neutral restart',
+                               'title': _('Restart this container'),
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_restart,
+                                       {id: cont.Id},
+                                       _('Restart'),
+                                       {showOutput: true, showSuccess: false}
+                               )
+                       }, [dm2.Types['container'].sub['restart'].e]),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-neutral stop',
+                               'title': _('Stop this container'),
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_stop,
+                                       {id: cont.Id},
+                                       dm2.Types['container'].sub['stop'].i18n,
+                                       {showOutput: true, showSuccess: false}
+                               ),
+                               'disabled' : !(isRunning || isPaused) ? true : null
+                       }, [dm2.Types['container'].sub['stop'].e]),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-negative kill',
+                               'title': _('Kill this container'),
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_kill,
+                                       {id: cont.Id},
+                                       dm2.Types['container'].sub['kill'].i18n,
+                                       {showOutput: true, showSuccess: false}
+                               ),
+                               'disabled' : !(isRunning || isPaused) ? true : null
+                       }, [dm2.Types['container'].sub['kill'].e]),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-neutral export',
+                               'title': _('Export this container'),
+                               'click': () => {
+                                       window.location.href = `${view.dockerman_url}/container/export/${cont.Id}`;
+                               }
+                       }, [dm2.Types['container'].sub['export'].e]),
+
+                       E('div', {
+                               'style': 'width: 20px',
+                               // Some safety margin for mis-clicks
+                       }, [' ']),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-negative remove',
+                               'title': dm2.ActionTypes['remove'].i18n,
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_remove,
+                                       {id: cont.Id, query: { force: false }},
+                                       dm2.ActionTypes['remove'].i18n,
+                                       {showOutput: true, showSuccess: false}
+                               )
+                       }, [dm2.ActionTypes['remove'].e]),
+
+                       E('button', {
+                               'class': 'cbi-button cbi-button-negative important remove',
+                               'title': dm2.ActionTypes['force_remove'].i18n,
+                               'click': () => view.executeDockerAction(
+                                       dm2.container_remove,
+                                       {id: cont.Id, query: { force: true }},
+                                       _('Force Remove'),
+                                       {showOutput: true, showSuccess: false}
+                               )
+                       }, [dm2.ActionTypes['force_remove'].e]),
+               ];
+
+               return E('td', { 
+                       'class': 'td',
+               }, E('div', btns));
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+       getContainersTable(containers, image_list, network_list) {
+               const data = [];
+
+               for (const cont of Array.isArray(containers) ? containers : []) {
+
+                       // build Container ID: xxxxxxx image: xxxx
+                       const names = Array.isArray(cont?.Names) ? cont.Names : [];
+                       const cleanedNames = names
+                               .map(n => (typeof n === 'string' ? n.substring(1) : ''))
+                               .filter(Boolean)
+                               .join(', ');
+                       const statusColorName = this.wrapStatusText(cleanedNames, cont.State, 'font-weight:600;');
+                       const imageName = this.getImageFirstTag(image_list, cont.ImageID);
+                       const shortId = (cont?.Id || '').substring(0, 12);
+
+                       const cid = E('div', {}, [
+                                       E('a', { href: `container/${cont.Id}`, title: dm2.ActionTypes['edit'].i18n }, [
+                                               statusColorName,
+                                               E('div', { 'style': 'font-size: 0.9em; font-family: monospace; ' }, [`ID: ${shortId}`]),
+                                       ]),
+                               E('div', { 'style': 'font-size: 0.85em;' }, [`${dm2.Types['image'].i18n}: ${imageName}`]),
+                       ])
+
+                       // Just push plain data objects without UCI metadata
+                       data.push({
+                               ...cont,
+                               cid: cid,
+                               _shortId: (cont?.Id || '').substring(0, 12),
+                               Networks: this.parseNetworkLinksForContainer(network_list, cont?.NetworkSettings?.Networks || {}, true),
+                               Created: this.buildTimeString(cont?.Created) || '',
+                               Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
+                                               ? cont.Ports.map(p => {
+                                                       const ip = p.IP || '';
+                                                       const pub = p.PublicPort || '';
+                                                       const priv = p.PrivatePort || '';
+                                                       const type = p.Type || '';
+                                                       return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
+                                               }).join('<br/>')
+                                               : '',
+                       });
+               }
+
+               return data;
+       },
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js
new file mode 100644 (file)
index 0000000..198cb8a
--- /dev/null
@@ -0,0 +1,327 @@
+'use strict';
+'require form';
+'require fs';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+LICENSE: GPLv2.0
+*/
+
+
+/* API v1.52
+
+GET /events supports content-type negotiation and can produce either
+ application/x-ndjson (Newline delimited JSON object stream) or
+ application/json-seq (RFC7464).
+
+application/x-ndjson:
+
+{"some":"thing\n"}
+{"some2":"thing2\n"}
+...
+
+application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
+
+␞{"some":"thing\n"}␊
+␞{"some2":"thing2\n"}␊
+...
+
+*/
+
+return dm2.dv.extend({
+       load() {
+               const now = Math.floor(Date.now() / 1000);
+
+               return Promise.all([
+                       dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
+               ]);
+       },
+
+       render([events]) {
+               if (events?.code !== 200) {
+                       return E('div', {}, [ events?.body?.message ]);
+               }
+
+               this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : '';
+               const event_list = events?.body || [];
+               const view = this;
+
+               const mainContainer = E('div', { 'class': 'cbi-map' }, [
+                       E('h2', {}, [_('Docker - Events')])
+               ]);
+
+               // Filters
+               const now = new Date();
+               const nowIso = now.toISOString().slice(0, 16);
+               const filtersSection = E('div', { 'class': 'cbi-section' }, [
+                       E('div', { 'class': 'cbi-section-node' }, [
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Type')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               E('select', {
+                                                       'id': 'event-type-filter',
+                                                       'class': 'cbi-input-select',
+                                                       'change': () => {
+                                                               view.updateSubtypeFilter(this.value);
+                                                               view.renderEventsTable(event_list);
+                                                       }
+                                               }, [
+                                                       E('option', { 'value': '' }, _('All Types')),
+                                                       ...Object.keys(dm2.Types).map(type => 
+                                                               E('option', { 'value': type }, `${dm2.Types[type].e} ${dm2.Types[type].i18n}`)
+                                                       )
+                                               ])
+                                       ])
+                               ]),
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Subtype')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               E('select', {
+                                                       'id': 'event-subtype-filter',
+                                                       'class': 'cbi-input-select',
+                                                       'disabled': true,
+                                                       'change': () => {
+                                                               view.renderEventsTable(event_list);
+                                                       }
+                                               }, [
+                                                       E('option', { 'value': '' }, _('Select Type First'))
+                                               ])
+                                       ])
+                               ]),
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('From')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               E('input', {
+                                                       'id': 'event-from-date',
+                                                       'type': 'datetime-local',
+                                                       'value': '1970-01-01T00:00',
+                                                       'step': 60,
+                                                       'style': 'width: 180px;',
+                                                       'change': () => { view.renderEventsTable(event_list); }
+                                               }),
+                                               E('button', {
+                                                       'type': 'button',
+                                                       'class': 'cbi-button',
+                                                       'style': 'margin-left: 8px;',
+                                                       'click': () => {
+                                                               const now = new Date();
+                                                               const iso = now.toISOString().slice(0,16);
+                                                               document.getElementById('event-from-date').value = iso;
+                                                               view.renderEventsTable(event_list);
+                                                       }
+                                               }, _('Now')),
+                                               E('button', {
+                                                       'type': 'button',
+                                                       'class': 'cbi-button',
+                                                       'style': 'margin-left: 8px;',
+                                                       'click': () => {
+                                                               const unixzero = new Date(0);
+                                                               const iso = unixzero.toISOString().slice(0,16);
+                                                               document.getElementById('event-from-date').value = iso;
+                                                               view.renderEventsTable(event_list);
+                                                       }
+                                               }, _('0'))
+                                       ])
+                               ]),
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('To')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               E('input', {
+                                                       'id': 'event-to-date',
+                                                       'type': 'datetime-local',
+                                                       'value': nowIso,
+                                                       'step': 60,
+                                                       'style': 'width: 180px;',
+                                                       'change': () => { view.renderEventsTable(event_list); }
+                                               }),
+                                               E('button', {
+                                                       'type': 'button',
+                                                       'class': 'cbi-button',
+                                                       'style': 'margin-left: 8px;',
+                                                       'click': () => {
+                                                               const now = new Date();
+                                                               const iso = now.toISOString().slice(0,16);
+                                                               document.getElementById('event-to-date').value = iso;
+                                                               view.renderEventsTable(event_list);
+                                                       }
+                                               }, _('Now'))
+                                       ])
+                               ])
+                       ])
+               ]);
+               mainContainer.appendChild(filtersSection);
+
+               this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' });
+               mainContainer.appendChild(this.tableSection);
+
+               this.renderEventsTable(event_list);
+
+               mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null));
+
+               return mainContainer;
+       },
+
+       renderEventsTable(event_list) {
+               const view = this;
+
+               // Get filter values
+               const typeFilter = document.getElementById('event-type-filter')?.value || '';
+               const subtypeFilter = document.getElementById('event-subtype-filter')?.value || '';
+
+               // Build filters object for docker_events API
+               const filters = {};
+               if (typeFilter) {
+                       filters.type = [typeFilter];
+               }
+               if (subtypeFilter) {
+                       filters.event = [subtypeFilter];
+               }
+
+               // Show loading indicator
+               this.tableSection.innerHTML = '';
+
+               // Query docker events with filters and date range
+               const fromInput = document.getElementById('event-from-date');
+               const toInput = document.getElementById('event-to-date');
+               let since = '0';
+               let until = Math.floor(Date.now() / 1000).toString();
+               if (fromInput && fromInput.value) {
+                       const fromDate = new Date(fromInput.value);
+                       if (!isNaN(fromDate.getTime())) {
+                               since = Math.floor(fromDate.getTime() / 1000).toString();
+                               since = since < 0 ? 0 : since;
+                       }
+               }
+               if (toInput && toInput.value) {
+                       const toDate = new Date(toInput.value);
+                       if (!isNaN(toDate.getTime())) {
+                               const now = Date.now() / 1000;
+                               until = Math.floor(toDate.getTime() / 1000).toString();
+                               until = until > now ? now : until;
+                       }
+               }
+               const queryParams = { since, until };
+               if (Object.keys(filters).length > 0) {
+                       // docker pre v27: filters => docker *streams* events. v27, send events in body.
+                       // Some older dockerd endpoints don't like encoded filter params, even if we can't stream.
+                       queryParams.filters = JSON.stringify(filters);
+               }
+
+               event_list = new Set();
+               view.outputText = '';
+               let eventsTable = null;
+
+               function updateTable() {
+                       const ev_array = Array.from(event_list.keys());
+                       const rows = ev_array.map(event => {
+                               const type = event.Type;
+                               const typeInfo = dm2.Types[type];
+                               const typeDisplay = typeInfo ? `${typeInfo.e} ${typeInfo.i18n}` : type;
+                               const actionParts = event.Action?.split(':') || [];
+                               const action = actionParts.length > 0 ? actionParts[0] : '';
+                               const action_sub = actionParts.length > 1 ? actionParts[1] : null;
+                               const actionInfo = typeInfo?.sub?.[action];
+                               const actionDisplay = actionInfo ? `${actionInfo.e} ${actionInfo.i18n}${action_sub ? ':'+action_sub : ''}` : action;
+                               return [
+                                       view.buildTimeString(event.time),
+                                       typeDisplay,
+                                       actionDisplay,
+                                       view.objectToText(event.Actor),
+                                       event.scope || ''
+                               ];
+                       });
+
+                       const output = JSON.stringify(ev_array, null, 2);
+                       view.outputText = output + '\n';
+                       view.insertOutput(view.outputText);
+
+                       if (!eventsTable) {
+                               eventsTable = new L.ui.Table(
+                                       [_('Time'), _('Type'), _('Action'), _('Actor'), _('Scope')],
+                                       { id: 'events-table', style: 'width: 100%; table-layout: auto;' },
+                                       E('em', [_('No events found')])
+                               );
+                               view.tableSection.innerHTML = '';
+                               view.tableSection.appendChild(eventsTable.render());
+                       }
+                       eventsTable.update(rows);
+               }
+
+               view.tableSection.innerHTML = '';
+
+               /* Partial transfers work but XHR times out waiting, even with xhr.timeout = 0 */
+               // view.handleXHRTransfer({
+               //      q_params:{ query: queryParams },
+               //      commandCPath: '/docker/events',
+               //      commandDPath: '/events',
+               //      commandTitle: dm2.ActionTypes['prune'].i18n,
+               //      showProgress: false,
+               //      onUpdate: (msg) => {
+               //              try {
+               //                      if(msg.error)
+               //                              ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
+
+               //                      event_list.add(msg);
+               //                      updateTable();
+
+               //                      const output = JSON.stringify(msg, null, 2) + '\n';
+               //                      view.insertOutput(output);
+               //              } catch {
+
+               //              }
+               //      },
+               //      noFileUpload: true,
+               // });
+
+               view.executeDockerAction(
+                       dm2.docker_events,
+                       { query: queryParams },
+                       _('Load Events'),
+                       {
+                               showOutput: false,
+                               showSuccess: false,
+                               onSuccess: (response) => {
+                                       if (response.body)
+                                               event_list = Array.isArray(response.body) ? new Set(response.body) : new Set([response.body]);
+                                       updateTable();
+                               },
+                               onError: (err) => {
+                                       view.tableSection.innerHTML = '';
+                                       view.tableSection.appendChild(E('em', { 'style': 'color: red;' }, _('Failed to load events: %s').format(err?.message || err)));
+                               }
+                       }
+               );
+       },
+
+       updateSubtypeFilter(selectedType) {
+               const subtypeSelect = document.getElementById('event-subtype-filter');
+               if (!subtypeSelect) return;
+
+               // Clear existing options
+               subtypeSelect.innerHTML = '';
+
+               if (!selectedType || !dm2.Types[selectedType] || !dm2.Types[selectedType].sub) {
+                       subtypeSelect.disabled = true;
+                       subtypeSelect.appendChild(E('option', { 'value': '' }, _('Select Type First')));
+                       return;
+               }
+
+               // Enable and populate with subtypes
+               subtypeSelect.disabled = false;
+               subtypeSelect.appendChild(E('option', { 'value': '' }, _('All Subtypes')));
+
+               const subtypes = dm2.Types[selectedType].sub;
+               for (const action in subtypes) {
+                       subtypeSelect.appendChild(
+                               E('option', { 'value': action }, `${subtypes[action].e} ${subtypes[action].i18n}`)
+                       );
+               }
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js
new file mode 100644 (file)
index 0000000..62704e9
--- /dev/null
@@ -0,0 +1,724 @@
+'use strict';
+'require form';
+'require fs';
+'require poll';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return dm2.dv.extend({
+       load() {
+               return Promise.all([
+                       dm2.image_list(),
+                       dm2.container_list({query: {all: true}}),
+               ])
+       },
+
+       render([images, containers]) {
+               if (images?.code !== 200) {
+                       return E('div', {}, [ images.body.message ]);
+               }
+
+               let image_list = this.getImagesTable(images.body);
+               let container_list = containers.body;
+               const view = this; // Capture the view context
+               view.selectedImages = {};
+
+               let s, o;
+               const m = new form.JSONMap({image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {}},
+                       _('Docker - Images'),
+                       _('On this page all images are displayed that are available on the system and with which a container can be created.'));
+               m.submit = false;
+               m.reset = false;
+
+               let pollPending = null;
+               let imgSec = null;
+               const calculateSizeTotal = () => {
+                       return Array.isArray(image_list) ? image_list.map(c => c?.Size).reduce((acc, e) => acc + e, 0) : 0;
+               };
+
+               const refresh = () => {
+                       if (pollPending) return pollPending;
+                       pollPending = view.load().then(([images2, containers2]) => {
+                               image_list = view.getImagesTable(images2.body);
+                               container_list = containers2.body;
+                               m.data = new m.data.constructor({ image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {} });
+
+                               const size_total = calculateSizeTotal();
+                               if (imgSec) {
+                                       imgSec.footer = [
+                                               '',
+                                               `${_('Total')} ${image_list.length}`,
+                                               '',
+                                               `${'%1024mB'.format(size_total)}`,
+                                       ];
+                               }
+
+                               return m.render();
+                       }).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
+                       return pollPending;
+               };
+
+               // Pull image
+
+               s = m.section(form.TableSection, 'pull',  dm2.Types['image'].sub['pull'].i18n,
+                       _('By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry.'));
+               s.anonymous = true;
+               s.addremove = false;
+
+               const splitImageTag = (value) => {
+                       const input = String(value || '').trim();
+                       if (!input || input.includes(' ')) return { name: '', tag: 'latest' };
+
+                       const lastSlash = input.lastIndexOf('/');
+                       const lastColon = input.lastIndexOf(':');
+                       if (lastColon > lastSlash) {
+                               return {
+                                       name: input.slice(0, lastColon) || input,
+                                       tag: input.slice(lastColon + 1) || 'latest'
+                               };
+                       }
+
+                       return { name: input, tag: 'latest' };
+               };
+
+               let tagOpt = s.option(form.Value, '_image_tag_name');
+               tagOpt.placeholder = "[registry.io[:443]/]foobar/product:latest";
+
+               o = s.option(form.Button, '_pull');
+               o.inputtitle = `${dm2.Types['image'].sub['pull'].i18n} ${dm2.Types['image'].sub['pull'].e}`; // _('Pull') + ' ☁️⬇️'
+               o.inputstyle = 'add';
+               o.onclick = L.bind(function(ev, btn) {
+                       const raw = tagOpt.formvalue('pull') || '';
+                       const input = String(raw).trim();
+                       if (!input) {
+                               ui.addTimeLimitedNotification(dm2.Types['image'].sub['pull'].i18n, _('Please enter an image tag'), 4000, 'warning');
+                               return false;
+                       }
+
+                       const { name, tag: ver } = splitImageTag(input);
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: { query: { fromImage: name, tag: ver } },
+                               commandCPath: `/images/create`,
+                               commandDPath: `/images/create`,
+                               commandTitle: dm2.Types['image'].sub['pull'].i18n,
+                               successMessage: _('Image create completed'),
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               onSuccess: () => refresh(),
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.image_create,
+                       //      { query: { fromImage: name, tag: ver } },
+                       //      dm2.Types['image'].sub['pull'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('Image create completed')
+                       //      }
+                       // );
+               }, this);
+
+               // Push image
+
+               s = m.section(form.TableSection, 'push',  dm2.Types['image'].sub['push'].i18n,
+                       _('Push an image to a registry. Select an image tag from all available tags on the system.'));
+               s.anonymous = true;
+               s.addremove = false;
+
+               // Build a list of all available tags across all images
+               const allImageTags = [];
+               for (const image of image_list) {
+                       const tags = Array.isArray(image.RepoTags) ? image.RepoTags : [];
+                       for (const tag of tags) {
+                               if (tag && tag !== '<none>:<none>') {
+                                       allImageTags.push(tag);
+                               }
+                       }
+               }
+
+               let pushTagOpt = s.option(form.Value, '_image_tag_push');
+               pushTagOpt.placeholder = _('Select image tag');
+               if (allImageTags.length === 0) {
+                       pushTagOpt.value('', _('No image tags available'));
+               } else {
+                       // Add all unique tags to the dropdown
+                       const uniqueTags = [...new Set(allImageTags)].sort();
+                       for (const tag of uniqueTags) {
+                               pushTagOpt.value(tag, tag);
+                       }
+               }
+
+               o = s.option(form.Button, '_push');
+               o.inputtitle = `${dm2.Types['image'].sub['push'].i18n} ${dm2.Types['image'].sub['push'].e}`; // _('Push') + ' ☁️⬆️'
+               o.inputstyle = 'add';
+               o.onclick = L.bind(function(ev, btn) {
+                       const selected = pushTagOpt.formvalue('push') || '';
+                       if (!selected) {
+                               ui.addTimeLimitedNotification(dm2.Types['image'].sub['push'].i18n, _('Please select an image tag to push'), 4000, 'warning');
+                               return false;
+                       }
+
+                       const { name, tag: ver } = splitImageTag(selected);
+
+                       return this.super('handleXHRTransfer', [{
+                               // Pass name in q_params to trigger building X-Registry-Auth header
+                               q_params: { name: name, query: { tag: ver } },
+                               commandCPath: `/images/push/${name}`,
+                               commandDPath: `/images/${name}/push`,
+                               commandTitle: dm2.Types['image'].sub['push'].i18n,
+                               successMessage: _('Image push completed'),
+                               onSuccess: () => refresh(),
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.image_push,
+                       //      { name: name, query: { tag: ver} },
+                       //      dm2.Types['image'].sub['push'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('Image push completed')
+                       //      }
+                       // );
+               }, this);
+
+
+               s = m.section(form.TableSection, 'build',  dm2.ActionTypes['build'].i18n,
+                       _('Build an image.') + ' ' + _('git repositories require git installed on the docker host.'));
+               s.anonymous = true;
+               s.addremove = false;
+
+               let buildOpt = s.option(form.Value, '_image_build_uri');
+               buildOpt.placeholder = "https://host/foo/bar.git | https://host/foobar.tar";
+
+               let buildTagOpt = s.option(form.Value, '_image_build_tag');
+               buildTagOpt.placeholder = 'repository:tag';
+
+               o = s.option(form.Button, '_build');
+               o.inputtitle = `${dm2.ActionTypes['build'].i18n} ${dm2.ActionTypes['build'].e}`; // _('Build') + ' 🏗️'
+               o.inputstyle = 'add';
+               o.onclick = L.bind(function(ev, btn) {
+                       const uri = buildOpt.formvalue('build') || '';
+                       const t = buildTagOpt.formvalue('build') || '';
+
+                       const q_params = { q: encodeURIComponent('false'), t: t };
+                       if (uri) q_params.remote = encodeURIComponent(uri);
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: { query: q_params },
+                               commandCPath: '/images/build',
+                               commandDPath: '/build',
+                               commandTitle: dm2.ActionTypes['build'].i18n,
+                               successMessage: _('Image loaded successfully'),
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               onSuccess: () => refresh(),
+                               noFileUpload: !!uri,
+                       }]);
+               }, this);
+
+               o = s.option(form.Button, '_delete_cache', null);
+               o.inputtitle = `${dm2.ActionTypes['clean'].i18n} ${dm2.ActionTypes['clean'].e}`;
+               o.inputstyle = 'negative';
+               o.onclick = L.bind(function(ev, btn) {
+                       return this.super('handleXHRTransfer', [{
+                               q_params: { query: { all: 'true' } },
+                               commandCPath: '/images/build/prune',
+                               commandDPath: '/build/prune',
+                               commandTitle: dm2.Types['builder'].sub['prune'].i18n,
+                               successMessage: _('Cleaned build cache'),
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['clean'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               noFileUpload: true,
+                       }]);
+               }, this);
+
+               // Import image
+
+               s = m.section(form.TableSection, 'import', dm2.Types['image'].sub['import'].i18n,
+                       _('Download a valid remote image tar.'));
+               s.addremove = false;
+               s.anonymous = true;
+
+               let imgsrc = s.option(form.Value, '_image_source');
+               imgsrc.placeholder = 'https://host/image.tar';
+
+               let tagimpOpt = s.option(form.Value, '_import_image_tag_name');
+               tagimpOpt.placeholder = 'repository:tag';
+
+               let importBtn = s.option(form.Button, '_import');
+               importBtn.inputtitle = `${dm2.Types['image'].sub['import'].i18n} ${dm2.Types['image'].sub['import'].e}` //_('Import') + ' ➡️';
+               importBtn.inputstyle = 'add';
+               importBtn.onclick = L.bind(function(ev, btn) {
+                       const rawtag = tagimpOpt.formvalue('import') || '';
+                       const input = String(rawtag).trim();
+                       if (!input) {
+                               ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image repo tag'), 4000, 'warning');
+                               return false;
+                       }
+                       const rawremote = imgsrc.formvalue('import') || '';
+                       let remote = String(rawremote).trim();
+                       if (!remote) {
+                               ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image source'), 4000, 'warning');
+                               return false;
+                       }
+
+                       const { name, tag: ver } = splitImageTag(input);
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: { query: { fromSrc: remote, repo: ver } },
+                               commandCPath: '/images/create',
+                               commandDPath: '/images/create',
+                               commandTitle: dm2.Types['image'].sub['create'].i18n,
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.Types['image'].sub['create'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               onSuccess: () => refresh(),
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.image_create,
+                       //      { query: { fromSrc: remote, repo: ver } },
+                       //      dm2.Types['image'].sub['import'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('Image create started/completed')
+                       //      }
+                       // );
+               }, this);
+
+
+               s = m.section(form.TableSection, 'prune', _('Images overview'), );
+               s.addremove = false;
+               s.anonymous = true;
+
+               const prune = s.option(form.Button, '_prune', null);
+               prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
+               prune.inputstyle = 'negative';
+               prune.onclick = L.bind(function(ev, btn) {
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: {  },
+                               commandCPath: '/images/prune',
+                               commandDPath: '/images/prune',
+                               commandTitle: dm2.ActionTypes['prune'].i18n,
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               onSuccess: () => refresh(),
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.image_prune,
+                       //      { query: { filters: '' } },
+                       //      dm2.ActionTypes['prune'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('started/completed'),
+                       //              onSuccess: () => refresh(),
+                       //      }
+                       // );
+               }, this);
+
+               o = s.option(form.Button, '_export', null);
+               o.inputtitle = `${dm2.ActionTypes['save'].i18n} ${dm2.ActionTypes['save'].e}`;
+               o.inputstyle = 'cbi-button-positive';
+               o.onclick = L.bind(function(ev, btn) {
+                       ev.preventDefault();
+
+                       const selected = Object.keys(view.selectedImages).filter(k => view.selectedImages[k]);
+                       if (!selected.length) {
+                               ui.addTimeLimitedNotification(_('Export'), _('No images selected'), 3000, 'warning');
+                               return;
+                       }
+
+                       // Get tags or IDs for selected images
+                       const names = selected.map(sid => {
+                               const image = s.map.data.data[sid];
+                               const tag = image?.RepoTags?.[0];
+                               return tag || image?.Id?.substr(12);
+                       });
+
+                       // http.uc does not yet handle parameter arrays, so /images/get needs access to the URL params
+                       window.location.href = `${view.dockerman_url}/images/get?${names.map(e => `names=${e}`).join('&')}`;
+
+               }, this);
+
+               const size_total = calculateSizeTotal();
+
+               imgSec = m.section(form.TableSection, 'image');
+               imgSec.anonymous = true;
+               imgSec.nodescriptions = true;
+               imgSec.addremove = true;
+               imgSec.sortable = true;
+               imgSec.filterrow = true;
+               imgSec.addbtntitle = `${dm2.ActionTypes['upload'].i18n} ${dm2.ActionTypes['upload'].e}`;
+               imgSec.footer = [
+                       '',
+                       `${_('Total')} ${image_list.length}`,
+                       '',
+                       `${'%1024mB'.format(size_total)}`,
+               ];
+
+               imgSec.handleAdd = function(sid, ev) {
+                       return view.handleFileUpload();
+               };
+
+               imgSec.handleGet = function(image, ev) {
+                       const tag = image.RepoTags?.[0];
+                       const name = tag || image.Id.substr(12);
+
+                       // Direct HTTP download - avoid RPC
+                       window.location.href = `${view.dockerman_url}/images/get/${name}`;
+                       return true;
+               };
+
+               imgSec.handleRemove = function(sid, image, force=false, ev) {
+                       return view.executeDockerAction(
+                               dm2.image_remove,
+                               { id: image.Id, query: { force: force } },
+                               dm2.ActionTypes['remove'].i18n,
+                               {
+                                       showOutput: true,
+                                       onSuccess: () => {
+                                               delete this.map.data.data[sid];
+                                               return this.super('handleRemove', [ev]);
+                                       }
+                               }
+                       );
+               };
+
+               imgSec.handleInspect = function(image, ev) {
+                       return view.executeDockerAction(
+                               dm2.image_inspect,
+                               { id: image.Id },
+                               dm2.ActionTypes['inspect'].i18n,
+                               { showOutput: true, showSuccess: false }
+                       );
+               };
+
+               imgSec.handleHistory = function(image, ev) {
+                       return view.executeDockerAction(
+                               dm2.image_history,
+                               { id: image.Id },
+                               dm2.ActionTypes['history'].i18n,
+                               { showOutput: true, showSuccess: false }
+                       );
+               };
+
+               imgSec.renderRowActions = function (sid) {
+                       const image = this.map.data.data[sid];
+                       const btns = [
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-neutral',
+                                       'title': dm2.ActionTypes['inspect'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleInspect, image),
+                               }, [dm2.ActionTypes['inspect'].e]),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-neutral',
+                                       'title': dm2.ActionTypes['history'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleHistory, image),
+                               }, [dm2.ActionTypes['history'].e]),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-positive save',
+                                       'title': dm2.ActionTypes['save'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleGet, image),
+                               }, [dm2.ActionTypes['save'].e]),
+                               E('div', {
+                                       'style': 'width: 20px',
+                                       // Some safety margin for mis-clicks
+                               }, [' ']),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative remove',
+                                       'title': dm2.ActionTypes['remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, sid, image, false),
+                                       'disabled': image?._disable_delete,
+                               }, [dm2.ActionTypes['remove'].e]),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative important remove',
+                                       'title': dm2.ActionTypes['force_remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, sid, image, true),
+                                       'disabled': image?._disable_delete,
+                               }, [dm2.ActionTypes['force_remove'].e]),
+                       ];
+                       return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
+               };
+
+               o = imgSec.option(form.Flag, '_selected');
+               o.onchange = function(ev, sid, value) {
+                       if (value == 1) {
+                               view.selectedImages[sid] = value;
+                       }
+                       else {
+                               delete view.selectedImages[sid];
+                       }
+                       return;
+               }
+
+               o = imgSec.option(form.DummyValue, 'RepoTags', dm2.Types['image'].sub['tag'].e);
+               o.cfgvalue = function(sid) {
+                       const image = this.map.data.data[sid];
+                       const tags = Array.isArray(image?.RepoTags) ? image.RepoTags : [];
+
+                       if (tags.length === 0 || (tags.length === 1 && tags[0] === '<none>:<none>'))
+                               return '<none>';
+
+                       const tagLinks = tags.map(tag => {
+                               if (tag === '<none>:<none>')
+                                       return E('span', {}, tag);
+
+                               /* last tag - don't link it - last tag removal == delete */
+                               if (tags.length === 1)
+                                       return tag;
+
+                               return E('a', {
+                                       'href': '#',
+                                       'title': _('Click to remove this tag'),
+                                       'click': ui.createHandlerFn(view, (tag, imageId, ev) => {
+
+                                               ev.preventDefault();
+                                               ui.showModal(_('Remove tag'), [
+                                                       E('p', {}, _('Do you want to remove the tag "%s"?').format(tag)),
+                                                       E('div', { 'class': 'right' }, [
+                                                               E('button', {
+                                                                       'class': 'cbi-button',
+                                                                       'click': ui.hideModal
+                                                               }, '↩'),
+                                                               ' ',
+                                                               E('button', {
+                                                                       'class': 'cbi-button cbi-button-negative',
+                                                                       'click': ui.createHandlerFn(view, () => {
+                                                                               ui.hideModal();
+
+                                                                               return view.executeDockerAction(
+                                                                                       dm2.image_remove,
+                                                                                       { id: tag, query: { noprune: 'true' } },
+                                                                                       dm2.Types['image'].sub['untag'].i18n,
+                                                                                       {
+                                                                                               showOutput: true,
+                                                                                               successMessage: _('Tag removed successfully'),
+                                                                                               successDuration: 4000,
+                                                                                               onSuccess: () => refresh(),
+                                                                                       }
+                                                                               );
+                                                                       })
+                                                               }, dm2.Types['image'].sub['untag'].e)
+                                                       ])
+                                               ]);
+                                       }, tag, image.Id)
+                               }, tag);
+                       });
+
+                       // Join with commas and spaces
+                       const content = [];
+                       for (const [i, tag] of tagLinks.entries()) {
+                               if (i > 0) content.push(', ');
+                               content.push(tag);
+                       }
+
+                       return E('span', {}, content);
+               };
+
+               o = imgSec.option(form.DummyValue, 'Containers', _('Containers'));
+               o.cfgvalue = function(sid) {
+                       const imageId = this.map.data.data[sid].Id;
+                       // Collect all matching container name links for this image
+                       const anchors = container_list.reduce((acc, container) => {
+                               if (container?.ImageID !== imageId) return acc;
+                               for (const name of container?.Names || [])
+                                       acc.push(E('a', { href: `container/${container.Id}` }, [ name.substring(1) ]));
+                               return acc;
+                       }, []);
+
+                       // Interleave separators
+                       if (!anchors.length) return E('div', {});
+                       const content = [];
+                       for (let i = 0; i < anchors.length; i++) {
+                               if (i) content.push(' | ');
+                               content.push(anchors[i]);
+                       }
+
+                       return E('div', {}, content);
+               };
+
+               o = imgSec.option(form.DummyValue, 'Size', _('Size'));
+               o.cfgvalue = function(sid) {
+                       const s = this.map.data.data[sid].Size;
+                       return '%1024mB'.format(s);
+               };
+               imgSec.option(form.DummyValue, 'Created', _('Created'));
+               o = imgSec.option(form.DummyValue, '_id', _('ID'));
+
+               /* Remember: we load a JSONMap - so uci config is non-existent for these
+               elements, so we must pull from this.map.data, otherwise o.load returns nothing */
+               o.cfgvalue = function(sid) {
+                       const image = this.map.data.data[sid];
+                       const shortId = image?._id || '';
+                       const fullId = image?.Id || '';
+
+                       return E('a', {
+                               'href': '#',
+                               'style': 'font-family: monospace',
+                               'title': _('Click to add a new tag to this image'),
+                               'click': ui.createHandlerFn(view, function(imageId, ev) {
+                                       ev.preventDefault();
+
+                                       let repoInput, tagInput;
+                                       ui.showModal(_('New tag'), [
+                                               E('p', {}, _('Enter a new tag for image %s:').format(imageId.slice(7, 19))),
+                                               E('div', { 'class': 'cbi-value' }, [
+                                                       E('label', { 'class': 'cbi-value-title' }, _('Repository')),
+                                                       E('div', { 'class': 'cbi-value-field' }, [
+                                                               repoInput = E('input', {
+                                                                       'type': 'text',
+                                                                       'class': 'cbi-input-text',
+                                                                       'placeholder': '[registry.io[:443]/]myrepo/myimage'
+                                                               })
+                                                       ])
+                                               ]),
+                                               E('div', { 'class': 'cbi-value' }, [
+                                                       E('label', { 'class': 'cbi-value-title' }, _('Tag')),
+                                                       E('div', { 'class': 'cbi-value-field' }, [
+                                                               tagInput = E('input', {
+                                                                       'type': 'text',
+                                                                       'class': 'cbi-input-text',
+                                                                       'placeholder': 'latest',
+                                                                       'value': 'latest'
+                                                               })
+                                                       ])
+                                               ]),
+                                               E('div', { 'class': 'right' }, [
+                                                       E('button', {
+                                                               'class': 'cbi-button',
+                                                               'click': ui.hideModal
+                                                       }, ['↩']),
+                                                       ' ',
+                                                       E('button', {
+                                                               'class': 'cbi-button cbi-button-positive',
+                                                               'click': ui.createHandlerFn(view, () => {
+                                                                       const repo = repoInput.value.trim();
+                                                                       const tag = tagInput.value.trim() || 'latest';
+
+                                                                       if (!repo) {
+                                                                               ui.addTimeLimitedNotification(null, [_('Repository cannot be empty')], 3000, 'warning');
+                                                                               return;
+                                                                       }
+
+                                                                       ui.hideModal();
+
+                                                                       return view.executeDockerAction(
+                                                                               dm2.image_tag,
+                                                                               { id: imageId, query: { repo: repo, tag: tag } },
+                                                                               dm2.Types['image'].sub['tag'].i18n,
+                                                                               {
+                                                                                       showOutput: true,
+                                                                                       successMessage: _('Tag added successfully'),
+                                                                                       successDuration: 4000,
+                                                                                       onSuccess: () => refresh(),
+                                                                               }
+                                                                       );
+                                                               })
+                                                       }, [dm2.Types['image'].sub['tag'].e])
+                                               ])
+                                       ]);
+                               }, fullId)
+                       }, shortId);
+               };
+
+               this.insertOutputFrame(s, m);
+
+               poll.add(L.bind(() => { refresh(); }, this), 10);
+
+               return m.render();
+       },
+
+       handleFileUpload() {
+               // const uploadUrl = `?quiet=${encodeURIComponent('false')}`;
+
+               return this.super('handleXHRTransfer', [{
+                       q_params: { query: { quiet: 'false' } },
+                       commandCPath: `/images/load`,
+                       commandDPath: `/images/load`,
+                       commandTitle: _('Uploading…'),
+                       commandMessage: _('Uploading image…'),
+                       successMessage: _('Image loaded successfully'),
+                       defaultPath: '/tmp'
+               }]);
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+       getImagesTable(images) {
+               const data = [];
+
+               for (const image of images) {
+                       // Just push plain data objects without UCI metadata
+                       data.push({
+                               ...image,
+                               _disable_delete: null,
+                               _id: (image.Id || '').substring(7, 20),
+                               Created: this.buildTimeString(image.Created) || '',
+                       });
+               }
+
+               return data;
+       },
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js
new file mode 100644 (file)
index 0000000..29d8321
--- /dev/null
@@ -0,0 +1,165 @@
+'use strict';
+'require form';
+'require fs';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return dm2.dv.extend({
+       load() {
+               const requestPath = L.env.requestpath;
+               const netId = requestPath[requestPath.length-1] || '';
+               this.networkId = netId;
+
+               return Promise.all([
+                       dm2.network_inspect({ id: netId }),
+                       dm2.container_list({query: {all: true}}),
+               ]);
+       },
+
+       render([network, containers]) {
+               if (network?.code !== 200) {
+                       window.location.href = `${this.dockerman_url}/networks`;
+                       return;
+               }
+
+               const view = this;
+               const this_network = network.body || {};
+               const container_list = Array.isArray(containers.body) ? containers.body : [];
+
+               const m = new form.JSONMap({
+                               network: this_network,
+                               Driver: this_network?.IPAM?.Driver,
+                               Config: this_network?.IPAM?.Config,
+                               Containers: Object.entries(this_network?.Containers || {}).map(([id, info]) => ({ id, ...info })),
+                               _inspect: {},
+                       },
+                       _('Docker - Networks'),
+                       _('This page displays all docker networks that have been created on the connected docker host.'));
+               m.submit = false;
+               m.reset = false;
+
+               let s = m.section(form.NamedSection, 'network', _('Networks overview'));
+               s.anonymous = true;
+               s.addremove = false;
+               s.nodescriptions = true;
+
+               let o, t, ss;
+
+               // INFO TAB
+               t = s.tab('info', _('Info'));
+
+               o = s.taboption('info', form.DummyValue, 'Name', _('Network Name'));
+               o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
+               o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
+               o = s.taboption('info', form.DummyValue, 'Scope', _('Scope'));
+               o = s.taboption('info', form.DummyValue, 'Driver', _('Driver'));
+               o = s.taboption('info', form.Flag, 'EnableIPv6', _('IPv6'));
+               o.readonly = true;
+
+               o = s.taboption('info', form.Flag, 'Internal', _('Internal'));
+               o.readonly = true;
+
+               o = s.taboption('info', form.Flag, 'Attachable', _('Attachable'));
+               o.readonly = true;
+
+               o = s.taboption('info', form.Flag, 'Ingress', _('Ingress'));
+               o.readonly = true;
+
+               o = s.taboption('info', form.DummyValue, 'ConfigFrom', _('ConfigFrom'));
+               o.cfgvalue = view.objectCfgValueTT;
+
+
+               o = s.taboption('info', form.Flag, 'ConfigOnly', _('Config Only'));
+               o.readonly = true;
+               o.cfgvalue = view.objectCfgValueTT;
+
+               o = s.taboption('info', form.DummyValue, 'Containers', _('Containers'));
+               o.load = function(sid) {
+                       return view.parseContainerLinksForNetwork(this_network, container_list);
+               };
+
+               o = s.taboption('info', form.DummyValue, 'Options', _('Options'));
+               o.cfgvalue = view.objectCfgValueTT;
+
+               o = s.taboption('info', form.DummyValue, 'Labels', _('Labels'));
+               o.cfgvalue = view.objectCfgValueTT;
+
+               // CONFIGS TAB
+               t = s.tab('detail', _('Detail'));
+
+               o = s.taboption('detail', form.DummyValue, 'Driver', _('IPAM Driver'));
+
+               o = s.taboption('detail', form.SectionValue, '_conf_', form.TableSection, 'Config', _('Network Configurations'));
+               ss = o.subsection;
+               ss.anonymous = true;
+
+               ss.option(form.DummyValue, 'Subnet', _('Subnet'));
+               ss.option(form.DummyValue, 'Gateway', _('Gateway'));
+
+               o = s.taboption('detail', form.SectionValue, '_cont_', form.TableSection, 'Containers', _('Containers'));
+               ss = o.subsection;
+               ss.anonymous = true;
+
+               o = ss.option(form.DummyValue, 'Name', _('Name'));
+               o.cfgvalue = function(sid) {
+                       const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
+                       const containerId = container_list.find(c => c.Names.find(e => e.substring(1) === val)).Id;
+                       return E('a', {
+                               href: `${view.dockerman_url}/container/${containerId}`,
+                               title: containerId,
+                               style: 'white-space: nowrap;'
+                       }, [val]);
+               };
+
+               ss.option(form.DummyValue, 'MacAddress', _('Mac Address'));
+               ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
+
+               // Show IPv6 column when at least one entry contains a non-empty IPv6Address
+               const _networkContainers = Object.values(this_network?.Containers || {});
+               const _hasIPv6 = _networkContainers.some(c => c?.IPv6Address && String(c.IPv6Address).trim() !== '');
+               if (_hasIPv6) {
+                       ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
+               }
+
+               // INSPECT TAB
+
+               t = s.tab('inspect', _('Inspect'));
+
+               o = s.taboption('inspect', form.SectionValue, '__ins__', form.NamedSection, '_inspect', null);
+               ss = o.subsection;
+               ss.anonymous = true;
+               ss.nodescriptions = true;
+
+               o = ss.option(form.Button, '_inspect_button', null);
+               o.inputtitle = `${dm2.ActionTypes['inspect'].i18n} ${dm2.ActionTypes['inspect'].e}`;
+               o.inputstyle = 'neutral';
+               o.onclick = L.bind(function(section_id, ev) {
+                       return dm2.network_inspect({ id: this_network.Id }).then((response) => {
+                               const inspectField = document.getElementById('inspect-output-text');
+                               if (inspectField && response?.body) {
+                                       inspectField.textContent = JSON.stringify(response.body, null, 2);
+                               }
+                       });
+               }, this);
+
+               o = s.taboption('inspect', form.SectionValue, '__insoutput__', form.NamedSection, null, null);
+               o.render = L.bind(() => {
+                       return this.insertOutputFrame(null, null);
+               }, this);
+
+               return m.render();
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js
new file mode 100644 (file)
index 0000000..a008f5e
--- /dev/null
@@ -0,0 +1,257 @@
+'use strict';
+'require form';
+'require fs';
+'require ui';
+'require tools.widgets as widgets';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return dm2.dv.extend({
+       load() {
+               return Promise.all([
+
+               ]);
+       },
+
+       render([]) {
+
+               // stuff JSONMap with {network: {}} to prime it with a new empty entry
+               const m = new form.JSONMap({network: {}}, _('Docker - New Network'));
+               m.submit = true;
+               m.reset = true;
+
+               let s = m.section(form.NamedSection, 'network', _('Create new docker network'));
+               s.anonymous = true;
+               s.nodescriptions = true;
+               s.addremove = false;
+
+               let o;
+
+               o = s.option(form.Value, 'name', _('Network Name'),
+                       _('Name of the network that can be selected during container creation'));
+               o.rmempty = true;
+
+               o = s.option(form.ListValue, 'driver', _('Driver'));
+               o.rmempty = true;
+               o.value('bridge', _('Bridge device'));
+               o.value('macvlan', _('MAC VLAN'));
+               o.value('ipvlan',  _('IP VLAN'));
+               o.value('overlay', _('Overlay network'));
+
+               o = s.option(widgets.DeviceSelect, 'parent', _('Base device'));
+               o.rmempty = true;
+               o.create = false
+               o.noaliases = true;
+               o.nocreate = true;
+               o.depends('driver', 'macvlan');
+
+               o = s.option(form.ListValue, 'macvlan_mode', _('Mode'));
+               o.rmempty = true;
+               o.depends('driver', 'macvlan');
+               o.default = 'bridge';
+               o.value('bridge', _('Bridge (Support direct communication between MAC VLANs)'));
+               o.value('private', _('Private (Prevent communication between MAC VLANs)'));
+               o.value('vepa', _('VEPA (Virtual Ethernet Port Aggregator)'));
+               o.value('passthru', _('Pass-through (Mirror physical device to single MAC VLAN)'));
+
+               o = s.option(form.ListValue, 'ipvlan_mode', _('Ipvlan Mode'));
+               o.rmempty = true;
+               o.depends('driver', 'ipvlan');
+               o.default='l3';
+               o.value('l2', _('L2 bridge'));
+               o.value('l3', _('L3 bridge'));
+
+               o = s.option(form.Flag, 'ingress',
+                       _('Ingress'),
+                       _('Ingress network is the network which provides the routing-mesh in swarm mode'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = 0;
+               o.depends('driver', 'overlay');
+
+               o = s.option(form.DynamicList, 'options', _('Options'));
+               o.rmempty = true;
+               o.placeholder='com.docker.network.driver.mtu=1500';
+
+               o = s.option(form.DynamicList, 'labels', _('Labels'));
+               o.rmempty = true;
+               o.placeholder='foo=bar';
+
+               o = s.option(form.Flag, 'internal', _('Internal'), _('Restrict external access to the network'));
+               o.rmempty = true;
+               o.depends('driver', 'overlay');
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = o.disabled;
+
+               // if nixio.fs.access('/etc/config/network') and nixio.fs.access('/etc/config/firewall')then
+               //      o = s.option(form.Flag, 'op_macvlan', _('Create macvlan interface'), _('Auto create macvlan interface in Openwrt'))
+               //      o.depends('driver', 'macvlan')
+               //      o.disabled = 0
+               //      o.enabled = 1
+               //      o.default = 1
+               // end
+
+               o = s.option(form.Value, 'subnet', _('Subnet'));
+               o.rmempty = true;
+               o.placeholder = '10.1.0.0/16';
+               o.datatype = 'ip4addr';
+
+               o = s.option(form.Value, 'gateway', _('Gateway'));
+               o.rmempty = true;
+               o.placeholder = '10.1.1.1';
+               o.datatype = 'ip4addr';
+
+               o = s.option(form.Value, 'ip_range', _('IP range'));
+               o.rmempty = true;
+               o.placeholder='10.1.1.0/24';
+               o.datatype = 'ip4addr';
+
+               o = s.option(form.DynamicList, 'aux_address', _('Exclude IPs'));
+               o.rmempty = true;
+               o.placeholder = 'my-route=10.1.1.1';
+
+               o = s.option(form.Flag, 'ipv6', _('Enable IPv6'));
+               o.rmempty = true;
+               o.disabled = 0;
+               o.enabled = 1;
+               o.default = o.disabled;
+
+               o = s.option(form.Value, 'subnet6', _('IPv6 Subnet'));
+               o.rmempty = true;
+               o.placeholder='fe80::/10'
+               o.datatype = 'ip6addr';
+               o.depends('ipv6', 1);
+
+               o = s.option(form.Value, 'gateway6', _('IPv6 Gateway'));
+               o.rmempty = true;
+               o.placeholder='fe80::1';
+               o.datatype = 'ip6addr';
+               o.depends('ipv6', 1);
+
+               this.map = m;
+
+               return m.render();
+
+       },
+
+       handleSave(ev) {
+               ev?.preventDefault();
+
+               const view = this;
+
+               const map = this.map;
+               if (!map)
+                       return Promise.reject(new Error(_('Form is not ready yet.')));
+
+               const listToKv = view.listToKv;
+
+               const toBool = (val) => (val === 1 || val === '1' || val === true);
+
+               return map.parse()
+                       .then(() => {
+                               const get = (opt) => map.data.get('json', 'network', opt);
+                               const name = get('name');
+                               const driver = get('driver');
+                               const internal = toBool(get('internal'));
+                               const ingress = toBool(get('ingress'));
+                               const ipv6 = toBool(get('ipv6'));
+                               const subnet = get('subnet');
+                               const gateway = get('gateway');
+                               const ipRange = get('ip_range');
+                               const auxAddress = listToKv(get('aux_address'));
+                               const optionsList = listToKv(get('options'));
+                               const labelsList = listToKv(get('labels'));
+                               const subnet6 = get('subnet6');
+                               const gateway6 = get('gateway6');
+
+                               const createBody = {
+                                       Name: name,
+                                       Driver: driver,
+                                       EnableIPv6: ipv6,
+                                       IPAM: {
+                                               Driver: 'default'
+                                       },
+                                       Internal: internal,
+                                       Labels: labelsList,
+                               };
+
+                               if (subnet || gateway || ipRange
+                                       || (auxAddress && typeof auxAddress === 'object' && Object.keys(auxAddress).length)) {
+                                       createBody.IPAM.Config = [{
+                                               Subnet: subnet,
+                                               Gateway: gateway,
+                                               IPRange: ipRange,
+                                               AuxAddress: auxAddress,
+                                               AuxiliaryAddresses: auxAddress,
+                                       }];
+                               }
+
+                               if (driver === 'macvlan') {
+                                       createBody.Options = {
+                                               macvlan_mode: get('macvlan_mode'),
+                                               parent: get('parent'),
+                                       };
+                               }
+                               else if (driver === 'ipvlan') {
+                                       createBody.Options = {
+                                               ipvlan_mode: get('ipvlan_mode'),
+                                       };
+                               }
+                               else if (driver === 'overlay') {
+                                       createBody.Ingress = ingress;
+                               }
+
+                               if (ipv6 && (subnet6 || gateway6)) {
+                                       createBody.IPAM.Config = createBody.IPAM.Config || [];
+                                       createBody.IPAM.Config.push({
+                                               Subnet: subnet6,
+                                               Gateway: gateway6,
+                                       });
+                               }
+
+                               if (optionsList && typeof optionsList === 'object' && Object.keys(optionsList).length) {
+                                       createBody.Options = Object.assign(createBody.Options || {}, optionsList);
+                               }
+
+                               if (labelsList && typeof labelsList === 'object' && Object.keys(labelsList).length) {
+                                       createBody.Labels = Object.assign(createBody.Labels || {}, labelsList);
+                               }
+
+                               return createBody;
+                       })
+                       .then((createBody) => view.executeDockerAction(
+                               dm2.network_create,
+                               { body: createBody },
+                               _('Create network'),
+                               {
+                                       showOutput: false,
+                                       showSuccess: false,
+                                       onSuccess: (response) => {
+                                               if (response?.body?.Warning) {
+                                                       view.showNotification(_('Network created with warning'), response.body.Warning, 5000, 'warning');
+                                               } else {
+                                                       view.showNotification(_('Network created'), _('OK'), 4000, 'success');
+                                               }
+                                               window.location.href = `${this.dockerman_url}/networks`;
+                                       }
+                               }
+                       ))
+                       .catch((err) => {
+                               view.showNotification(_('Create network failed'), err?.message || String(err), 7000, 'error');
+                               return false;
+                       });
+       },
+
+       handleSaveApply: null,
+       handleReset: null,
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js
new file mode 100644 (file)
index 0000000..2bff066
--- /dev/null
@@ -0,0 +1,236 @@
+'use strict';
+'require form';
+'require fs';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return dm2.dv.extend({
+       load() {
+               return Promise.all([
+                       dm2.network_list(),
+                       dm2.container_list({query: {all: true}}),
+               ]);
+       },
+
+       render([networks, containers]) {
+               if (networks?.code !== 200) {
+                       return E('div', {}, [ networks?.body?.message ]);
+               }
+
+               let network_list = this.getNetworksTable(networks.body, containers.body);
+               // let container_list = containers.body;
+               const view = this; // Capture the view context
+
+
+               let pollPending = null;
+               let netSec = null;
+
+               const refresh = () => {
+                       if (pollPending) return pollPending;
+                       pollPending = view.load().then(([networks2, containers2]) => {
+                               network_list = view.getNetworksTable(networks2.body, containers2.body);
+                               // container_list = containers2.body;
+                               m.data = new m.data.constructor({network: network_list, prune: {}});
+
+                               if (netSec) {
+                                       netSec.footer = [
+                                               `${_('Total')} ${network_list.length}`,
+                                       ];
+                               }
+
+                               return m.render();
+                       }).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
+                       return pollPending;
+               };
+
+
+               let s, o;
+               const m = new form.JSONMap({network: network_list, prune: {}},
+                       _('Docker - Networks'),
+                       _('This page displays all docker networks that have been created on the connected docker host.'));
+               m.submit = false;
+               m.reset = false;
+
+               s = m.section(form.TableSection, 'prune', _('Networks overview'), null);
+               s.addremove = false;
+               s.anonymous = true;
+
+               const prune = s.option(form.Button, '_prune', null);
+               prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
+               prune.inputstyle = 'negative';
+               prune.onclick = L.bind(function(section_id, ev) {
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: {  },
+                               commandCPath: '/networks/prune',
+                               commandDPath: '/networks/prune',
+                               commandTitle: dm2.ActionTypes['prune'].i18n,
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.network_prune,
+                       //      { query: { filters: '' } },
+                       //      dm2.ActionTypes['prune'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('started/completed'),
+                       //              onSuccess: () => {
+                       //                      setTimeout(() => window.location.href = `${this.dockerman_url}/networks`, 1000);
+                       //              }
+                       //      }
+                       // );
+               }, this);
+
+               netSec = m.section(form.TableSection, 'network');
+               netSec.anonymous = true;
+               netSec.nodescriptions = true;
+               netSec.addremove = true;
+               netSec.sortable = true;
+               netSec.filterrow = true;
+               netSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
+               netSec.footer = [
+                       `${_('Total')} ${network_list.length}`,
+               ];
+
+               netSec.handleAdd = function(section_id, ev) {
+                       window.location.href = `${view.dockerman_url}/network_new`;
+               };
+
+               netSec.handleRemove = function(section_id, force, ev) {
+                       const network = network_list.find(net => net['.name'] === section_id);
+                       if (!network?.Id) return false;
+
+                       return view.executeDockerAction(
+                               dm2.network_remove,
+                               { id: network.Id },
+                               dm2.ActionTypes['remove'].i18n,
+                               {
+                                       showOutput: true,
+                                       onSuccess: () => {
+                                               return refresh();
+                                       }
+                               }
+                       );
+               };
+
+               netSec.handleInspect = function(section_id, ev) {
+                       const network = network_list.find(net => net['.name'] === section_id);
+                       if (!network?.Id) return false;
+
+                       return view.executeDockerAction(
+                               dm2.network_inspect,
+                               { id: network.Id },
+                               dm2.ActionTypes['inspect'].i18n,
+                               { showOutput: true, showSuccess: false }
+                       );
+               };
+
+               netSec.renderRowActions = function (section_id) {
+                       const network = network_list.find(net => net['.name'] === section_id);
+                       const btns = [
+                               E('button', {
+                                       'class': 'cbi-button view',
+                                       'title': dm2.ActionTypes['inspect'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleInspect, section_id),
+                               }, [dm2.ActionTypes['inspect'].e]),
+
+                               E('div', {
+                                       'style': 'width: 20px',
+                                       // Some safety margin for mis-clicks
+                               }, [' ']),
+
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative remove',
+                                       'title': dm2.ActionTypes['remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, section_id, false),
+                                       'disabled': network?._disable_delete,
+                               }, dm2.ActionTypes['remove'].e),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative important remove',
+                                       'title': dm2.ActionTypes['force_remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, section_id, true),
+                                       'disabled': network?._disable_delete,
+                               }, dm2.ActionTypes['force_remove'].e),
+                       ];
+                       return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
+               };
+
+               o = netSec.option(form.DummyValue, '_shortId', _('ID'));
+
+               o = netSec.option(form.DummyValue, 'Name', _('Name'));
+
+               o = netSec.option(form.DummyValue, 'Labels', _('Labels') + '  🏷️');
+               o.cfgvalue = view.objectCfgValueTT;
+
+               o = netSec.option(form.DummyValue, '_container', _('Containers'));
+
+               o = netSec.option(form.DummyValue, 'Driver', _('Driver'));
+
+               o = netSec.option(form.DummyValue, '_interface', _('Parent Interface'));
+
+               o = netSec.option(form.DummyValue, '_subnet', _('Subnet'));
+
+               o = netSec.option(form.DummyValue, '_gateway', _('Gateway'));
+
+               this.insertOutputFrame(s, m);
+
+               return m.render();
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+       getNetworksTable(networks, containers) {
+               const data = [];
+
+               for (const [i, net] of (networks || []).entries()) {
+                       const n = net.Name;
+                       const _shortId = (net.Id || '').substring(0, 12);
+                       const shortLink = E('a', {
+                               'href': `${view.dockerman_url}/network/${net.Id}`,
+                               'style': 'font-family: monospace;',
+                               'title': _('Click to view this network'),
+                       }, [_shortId]);
+
+                       // Just push plain data objects without UCI metadata
+                       const configs = Array.isArray(net?.IPAM?.Config) ? net.IPAM.Config : [];
+                       data.push({
+                               ...net,
+                               _gateway: configs.map(o => o.Gateway).filter(o => o).join(', ') || '',
+                               _subnet: configs.map(o => o.Subnet).filter(o => o).join(', ') || '',
+                               _disable_delete: ( n === 'bridge' || n === 'none' || n === 'host' ) ? true : null,
+                               _shortId: shortLink,
+                               _container: this.parseContainerLinksForNetwork(net, containers),
+                               _interface: (net.Driver === 'bridge')
+                                       ? net.Options?.['com.docker.network.bridge.name'] || ''
+                                       : (net.Driver === 'macvlan')
+                                               ? net?.Options?.parent
+                                               : '',
+                       });
+               }
+
+               return data;
+       },
+
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js
new file mode 100644 (file)
index 0000000..66ec57b
--- /dev/null
@@ -0,0 +1,280 @@
+'use strict';
+'require form';
+'require fs';
+'require uci';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+/**
+ * Returns a Set of image IDs in use by containers
+ * @param {Array} containers - Array of container objects
+ * @returns {Set<string>} Set of image IDs
+ */
+function getImagesInUseByContainers(containers) {
+       const inUse = new Set();
+       for (const c of containers || []) {
+               if (c.ImageID) inUse.add(c.ImageID);
+               else if (c.Image) inUse.add(c.Image);
+       }
+       return inUse;
+}
+
+/**
+ * Returns a Set of network IDs in use by containers
+ * @param {Array} containers - Array of container objects
+ * @returns {Set<string>} Set of network IDs
+ */
+function getNetworksInUseByContainers(containers) {
+       const inUse = new Set();
+       for (const c of containers || []) {
+               const networks = c.NetworkSettings?.Networks;
+               if (networks && typeof networks === 'object') {
+                       for (const netName in networks) {
+                               const net = networks[netName];
+                               if (net.NetworkID) inUse.add(net.NetworkID);
+                               else if (netName) inUse.add(netName);
+                       }
+               }
+       }
+       return inUse;
+}
+
+/**
+ * Returns a Set of volume mountpoints in use by containers
+ * @param {Array} containers - Array of container objects
+ * @returns {Set<string>} Set of volume names or mountpoints
+ */
+function getVolumesInUseByContainers(containers) {
+       const inUse = new Set();
+       for (const c of containers || []) {
+               const mounts = c.Mounts;
+               if (Array.isArray(mounts)) {
+                       for (const m of mounts) {
+                               if (m.Type === 'volume' && m.Name) inUse.add(m.Name);
+                       }
+               }
+       }
+       return inUse;
+}
+
+
+return dm2.dv.extend({
+       load() {
+               const now = Math.floor(Date.now() / 1000);
+
+               return Promise.all([
+                       dm2.docker_version(),
+                       dm2.docker_info(),
+                       // dm2.docker_df(), // takes > 20 seconds on large docker environments
+                       dm2.container_list().then(r => r.body || []),
+                       dm2.image_list().then(r => r.body || []),
+                       dm2.network_list().then(r => r.body || []),
+                       dm2.volume_list().then(r => r.body || []),
+                       dm2.callMountPoints(),
+               ]);
+       },
+
+       handleAction(name, action, ev) {
+               return dm2.callRcInit(name, action).then(function(ret) {
+                       if (ret)
+                               throw _('Command failed');
+
+                       return true;
+               }).catch(function(e) {
+                       L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning');
+               });
+       },
+
+       render([version_response,
+                       info_response,
+                       // df_response,
+                       container_list,
+                       image_list,
+                       network_list,
+                       volume_list,
+                       mounts,
+               ]) {
+               const version_headers = [];
+               const version_body = [];
+               const info_body = [];
+               // const df_body = [];
+               const docker_ep = uci.get('dockerd', 'globals', 'hosts');
+               let isLocal = false;
+               if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1)
+                       isLocal = true;
+
+               if (info_response?.code !== 200) {
+                       return E('div', {}, [ info_response?.body?.message ]);
+               }
+
+               this.parseHeaders(version_response.headers, version_headers);
+               this.parseBody(version_response.body, version_body);
+               this.parseBody(info_response.body, info_body);
+               // this.parseBody(df_response.body, df_body);
+               const view = this;
+               const info = info_response.body;
+
+               this.concount = info?.Containers || 0;
+               this.conactivecount = info?.ContainersRunning || 0;
+
+               /* Because the df function that reconciles Volumes, Networks and Containers
+               is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */
+               this.imgcount = image_list.length;
+               this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0;
+
+               this.netcount = network_list.length;
+               this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0;
+
+               this.volcount = volume_list?.Volumes?.length;
+               this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0;
+
+               this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0;
+               if (isLocal && this.freespace !== 0)
+                       this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')';
+
+               const mainContainer = E('div', { 'class': 'cbi-map' });
+
+               // Add heading and description first
+               mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
+               mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
+                       _('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
+                       E('br'),
+                       E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README'])
+               ]));
+
+               if (isLocal)
+                       mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
+                               E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
+                                       E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')),
+                                       E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')),
+                               ])
+                       ]));
+
+               // Create the info table
+               const summaryTable = new L.ui.Table(
+                       [_('Info'), ''],
+                       { id: 'containers-table', style: 'width: 100%; table-layout: auto;' },
+                       []
+               );
+
+               summaryTable.update([
+                       [ _('Docker Version'), version_response.body.Version ],
+                       [ _('Api Version'), version_response.body.ApiVersion ],
+                       [ _('CPUs'), info_response.body.NCPU ],
+                       [ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ],
+                       [ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ],
+                       [ _('Index Server Address'), info_response.body.IndexServerAddress ],
+                       [ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ],
+               ]);
+
+               // Wrap the table in a cbi-section
+               mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
+                       summaryTable.render()
+               ]));
+
+
+               // Create a container div with grid layout for the status badges
+               let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [
+                       this.overviewBadge(`${this.dockerman_url}/containers`,
+                               E('img', {
+                                       src: L.resource('dockerman/containers.svg'),
+                                       style: 'width: 80px; height: 80px;'
+                               }, []),
+                               _('Containers'),
+                               _('Total: '),
+                               view.concount,
+                               _('Running: '),
+                               view.conactivecount),
+                       this.overviewBadge(`${this.dockerman_url}/images`,
+                               E('img', {
+                                       src: L.resource('dockerman/images.svg'),
+                                       style: 'width: 80px; height: 80px;'
+                               }, []),
+                               _('Images'),
+                               _('Total: '),
+                               view.imgcount,
+                               view.imgactivecount ? _('In Use: ') : '',
+                               view.imgactivecount ? view.imgactivecount : ''),
+                       this.overviewBadge(`${this.dockerman_url}/networks`,
+                               E('img', {
+                                       src: L.resource('dockerman/networks.svg'),
+                                       style: 'width: 80px; height: 80px;'
+                               }, []),
+                               _('Networks'),
+                               _('Total: '),
+                               view.netcount,
+                               view.netactivecount ? _('In Use: ') : '',
+                               view.netactivecount ? view.netactivecount : ''),
+                       this.overviewBadge(`${this.dockerman_url}/volumes`,
+                               E('img', {
+                                       src: L.resource('dockerman/volumes.svg'),
+                                       style: 'width: 80px; height: 80px;'
+                               }, []),
+                               _('Volumes'),
+                               _('Total: '),
+                               view.volcount,
+                               view.volactivecount ? _('In Use: ') : '',
+                               view.volactivecount ? view.volactivecount : ''),
+               ]);
+
+               // Add badges section
+               mainContainer.appendChild(statusContainer);
+
+               const m = new form.JSONMap({
+                       // df: df_body,
+                       vb: version_body,
+                       ib: info_body
+               });
+               m.readonly = true;
+               m.tabbed = false;
+
+               let s, o, v;
+
+               // Add Version and Environment tables
+               s = m.section(form.TableSection, 'vb', _('Version'));
+               s.anonymous = true;
+
+               o = s.option(form.DummyValue, 'entry', _('Name'));
+               o = s.option(form.DummyValue, 'value', _('Value'));
+
+               s = m.section(form.TableSection, 'ib', _('Environment'));
+               s.anonymous = true;
+               s.filterrow = true;
+
+               o = s.option(form.DummyValue, 'entry', _('Entry'));
+               o = s.option(form.DummyValue, 'value', _('Value'));
+
+               // Render the form sections and append them
+               return m.render()
+                       .then(fe => {
+                               mainContainer.appendChild(fe);
+                               return mainContainer;
+                       });
+       },
+
+       overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) {
+               return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [
+                               E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [
+                                       E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [
+                                               resource_div,
+                                       ]),
+                                       E('div', { style: 'flex: 1;' }, [
+                                               E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption),
+                                               E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
+                                                       E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)])
+                                               ]),
+                                               E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
+                                                       E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)])
+                                               ])
+                                       ])
+                               ])
+                       ])
+
+       }
+});
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js
new file mode 100644 (file)
index 0000000..6f31abc
--- /dev/null
@@ -0,0 +1,332 @@
+'use strict';
+'require form';
+'require fs';
+'require ui';
+'require dockerman.common as dm2';
+
+/*
+Copyright 2026
+Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com> 
+Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
+LICENSE: GPLv2.0
+*/
+
+
+return dm2.dv.extend({
+       load() {
+               return Promise.all([
+                       dm2.volume_list(),
+                       dm2.container_list({query: {all: true}}),
+               ]);
+       },
+
+       render([volumes, containers]) {
+               if (volumes?.code !== 200) {
+                       return E('div', {}, [ volumes.body.message ]);
+               }
+
+               // this.volumes = volumes || {};
+               let container_list = containers.body || [];
+               let volume_list = this.getVolumesTable(volumes.body);
+               const view = this; // Capture the view context
+
+               let pollPending = null;
+               let volSec = null;
+
+               const refresh = () => {
+                       if (pollPending) return pollPending;
+                       pollPending = view.load().then(([volumes2, containers2]) => {
+                               volume_list = view.getVolumesTable(volumes2.body);
+                               container_list = containers2.body;
+                               m.data = new m.data.constructor({volume: volume_list, prune: {}});
+
+                               if (volSec) {
+                                       volSec.footer = [
+                                               `${_('Total')} ${volume_list.length}`,
+                                       ];
+                               }
+
+                               return m.render();
+                       }).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
+                       return pollPending;
+               };
+
+               let s, o;
+               const m = new form.JSONMap({volume: volume_list, prune: {}},
+                       _('Docker - Volumes'),
+                       _('This page displays all docker volumes that have been created on the connected docker host.'));
+               m.submit = false;
+               m.reset = false;
+
+               s = m.section(form.TableSection, 'prune', null, _('Volumes overview'));
+               s.addremove = false;
+               s.anonymous = true;
+               const prune = s.option(form.Button, '_prune', null);
+               prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
+               prune.inputstyle = 'negative';
+               prune.onclick = L.bind(function(sid, ev) {
+
+                       return this.super('handleXHRTransfer', [{
+                               q_params: {  },
+                               commandCPath: '/volumes/prune',
+                               commandDPath: '/volumes/prune',
+                               commandTitle: dm2.ActionTypes['prune'].i18n,
+                               onUpdate: (msg) => {
+                                       try {
+                                               if(msg.error)
+                                                       ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
+
+                                               const output = JSON.stringify(msg, null, 2) + '\n';
+                                               view.insertOutput(output);
+                                       } catch {
+
+                                       }
+                               },
+                               noFileUpload: true,
+                       }]);
+
+                       // return view.executeDockerAction(
+                       //      dm2.volume_prune,
+                       //      { query: { filters: '' } },
+                       //      dm2.ActionTypes['prune'].i18n,
+                       //      {
+                       //              showOutput: true,
+                       //              successMessage: _('started/completed'),
+                       //              onSuccess: () => {
+                       //                      setTimeout(() => window.location.href = `${this.dockerman_url}/volumes`, 1000);
+                       //              }
+                       //      }
+                       // );
+               }, this);
+
+
+               volSec = m.section(form.TableSection, 'volume');
+               volSec.anonymous = true;
+               volSec.nodescriptions = true;
+               volSec.addremove = true;
+               volSec.sortable = true;
+               volSec.filterrow = true;
+               volSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
+               volSec.footer = [
+                       `${_('Total')} ${volume_list.length}`,
+               ];
+
+               volSec.handleAdd = function(ev) {
+
+                       ev.preventDefault();
+                       let nameInput, labelsInput;
+                       return ui.showModal(_('New volume'), [
+                               E('p', {}, _('Enter an optional name and labels for the new volume')),
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Name')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               nameInput = E('input', {
+                                                       'type': 'text',
+                                                       'class': 'cbi-input-text',
+                                                       'placeholder': _('volume name'),
+                                               })
+                                       ])
+                               ]),
+
+                               E('div', { 'class': 'cbi-value' }, [
+                                       E('label', { 'class': 'cbi-value-title' }, _('Labels')),
+                                       E('div', { 'class': 'cbi-value-field' }, [
+                                               labelsInput = E('input', {
+                                                       'type': 'text',
+                                                       'class': 'cbi-input-text',
+                                                       'placeholder': 'key=value, key2=value2, ...',
+                                               })
+                                               // labelsInput = new ui.DynamicList([], [], {}).render(),
+                                       ])
+                               ]),
+
+
+                               E('div', { 'class': 'right' }, [
+                                       E('button', {
+                                               'class': 'cbi-button',
+                                               'click': ui.hideModal
+                                       }, ['↩']),
+                                       ' ',
+                                       E('button', {
+                                               'class': 'cbi-button cbi-button-positive',
+                                               'click': ui.createHandlerFn(view, () => {
+                                                       const name = nameInput.value.trim();
+                                                       const labels = Object.fromEntries(
+                                                               (labelsInput.value.trim()?.split(',') || [])
+                                                                       .map(e => e.trim())
+                                                                       .filter(Boolean)
+                                                                       .map(e => e.split('='))
+                                                                       .filter(pair => pair.length === 2)
+                                                       );
+
+                                                       ui.hideModal();
+
+                                                       return view.executeDockerAction(
+                                                               dm2.volume_create,
+                                                               { opts: { Name: name, Labels: labels } },
+                                                               dm2.Types['volume'].sub['create'].i18n,
+                                                               {
+                                                                       showOutput: true,
+                                                                       onSuccess: () => {
+                                                                               return refresh();
+                                                                       }
+                                                               }
+                                                       );
+                                               })
+                                       }, [dm2.Types['volume'].sub['create'].e])
+                               ])
+                       ]);
+               };
+
+               volSec.handleRemove = function(sid, force, ev) {
+                       const volume = volume_list.find(net => net['.name'] === sid);
+
+                       if (!volume?.Name) return false;
+
+                       return view.executeDockerAction(
+                               dm2.volume_remove,
+                               { id: volume.Name, query: { force: force } },
+                               dm2.ActionTypes['remove'].i18n,
+                               {
+                                       showOutput: true,
+                                       onSuccess: () => {
+                                               return refresh();
+                                       }
+                               }
+                       );
+               };
+
+               volSec.handleInspect = function(sid, ev) {
+                       const volume = volume_list.find(net => net['.name'] === sid);
+
+                       if (!volume?.Name) return false;
+
+                       return view.executeDockerAction(
+                               dm2.volume_inspect,
+                               { id: volume.Name },
+                               dm2.ActionTypes['inspect'].i18n,
+                               { showOutput: true, showSuccess: false }
+                       );
+               };
+
+               volSec.renderRowActions = function (sid) {
+                       const volume = volume_list.find(net => net['.name'] === sid);
+                       const btns = [
+                               E('button', {
+                                       'class': 'cbi-button view',
+                                       'title': dm2.ActionTypes['inspect'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleInspect, sid),
+                               }, [dm2.ActionTypes['inspect'].e]),
+
+                               E('div', {
+                                       'style': 'width: 20px',
+                                       // Some safety margin for mis-clicks
+                               }, [' ']),
+
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative remove',
+                                       'title': dm2.ActionTypes['remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, sid, false),
+                                       'disabled': volume?._disable_delete,
+                               }, [dm2.ActionTypes['remove'].e]),
+                               E('button', {
+                                       'class': 'cbi-button cbi-button-negative important remove',
+                                       'title': dm2.ActionTypes['force_remove'].i18n,
+                                       'click': ui.createHandlerFn(this, this.handleRemove, sid, true),
+                               }, [dm2.ActionTypes['force_remove'].e]),
+                       ];
+                       return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
+               };
+
+               volSec.option(form.DummyValue, '_name', _('Name'));
+
+               o = volSec.option(form.DummyValue, 'Labels', _('Labels') + '  🏷️');
+               o.cfgvalue = view.objectCfgValueTT;
+
+               volSec.option(form.DummyValue, 'Driver', _('Driver'));
+
+               o = volSec.option(form.DummyValue, 'Containers', _('Containers'));
+               o.cfgvalue = function(sid) {
+                       const vol = this.map.data.data[sid] || {};
+                       return view.parseContainerLinksForVolume(vol, container_list);
+               };
+
+               o = volSec.option(form.DummyValue, 'Mountpoint', _('Mount Point'));
+               o.cfgvalue = function(sid) {
+                       const mp = this.map.data.get(this.map.config, sid, this.option);
+                       if (!mp) return;
+                       // Try to match Docker volume mountpoint pattern: /var/lib/docker/volumes/<id>/_data
+                       const match = mp.match(/^(.*\/volumes\/)([^/]+)(\/.*)?$/);
+                       if (match && match[2].length > 36) {
+                               // Show the first 12 characters of the ID portion
+                               return match[1] + match[2].substring(0, 12) + '...' + (match[3] || '');
+                       }
+                       return mp;
+               };
+
+               o = volSec.option(form.DummyValue, 'CreatedAt', _('Created'));
+
+               this.insertOutputFrame(s, m);
+
+               return m.render();
+       },
+
+       handleSave: null,
+       handleSaveApply: null,
+       handleReset: null,
+
+       getVolumesTable(volumes) {
+               const data = [];
+
+               for (const [i, vol] of (volumes?.Volumes || []).entries()) {
+                       const n = vol.Name;
+                       const labels = vol?.Labels || {};
+
+                       // Just push plain data objects without UCI metadata
+                       data.push({
+                               ...vol,
+                               Labels: labels,
+                               _name: (vol.Name || '').substring(0, 12),
+                               Containers: vol.Containers || '',
+                       });
+               }
+
+               return data;
+       },
+
+       parseContainerLinksForVolume(volume, containers) {
+               const links = [];
+               for (const cont of containers || []) {
+                       const mounts = cont?.Mounts || [];
+                       const usesVolume = mounts.some(m => {
+                               if (m?.Type !== 'volume' && m?.Type !== 'bind') return false;
+                               const byName = !!volume?.Name && m?.Name === volume.Name;
+                               const bySource = !!volume?.Mountpoint && (m?.Source === volume.Mountpoint || (m?.Source || '').startsWith(volume.Mountpoint));
+                               return byName || bySource;
+                       });
+
+                       if (usesVolume) {
+                               const containerName = cont?.Names?.[0]?.replace(/^\//, '') || (cont?.Id || '').substring(0, 12);
+                               const containerId = cont?.Id;
+                               links.push(E('a', {
+                                       href: `${this.dockerman_url}/container/${containerId}`,
+                                       title: containerId,
+                                       style: 'white-space: nowrap;'
+                               }, [containerName]));
+                       }
+               }
+
+               if (!links.length)
+                       return '-';
+
+               const out = [];
+               for (let i = 0; i < links.length; i++) {
+                       out.push(links[i]);
+                       if (i < links.length - 1)
+                               out.push(' | ');
+               }
+
+               return E('div', {}, out);
+       },
+
+});
diff --git a/applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json b/applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json
new file mode 100644 (file)
index 0000000..0cc3995
--- /dev/null
@@ -0,0 +1,318 @@
+{
+       "admin/services/dockerman": {
+               "title": "Dockerman JS",
+               "order": "60",
+               "action": {
+                       "type": "firstchild"
+               },
+               "depends": {
+                       "acl": [ "luci-app-dockerman" ],
+                       "fs": {
+                               "/etc/init.d/dockerd": "executable",
+                               "/usr/bin/dockerd": "executable"
+                       },
+                       "uci": { "dockerd": true }
+               }
+       },
+
+       "admin/services/dockerman/overview": {
+               "title": "Overview",
+               "order": 1,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/overview"
+               }
+       },
+
+       "admin/services/dockerman/configuration": {
+               "title": "Configuration",
+               "order": 2,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/configuration"
+               }
+       },
+
+       "admin/services/dockerman/container/archive/*": {
+               "action": {
+                       "type": "alias",
+                       "path": "admin/services/dockerman/containers"
+               }
+       },
+
+       "admin/services/dockerman/container/archive/put/*": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "container_put_archive"
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/container/archive/get/*": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "container_get_archive"
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/container/export/*": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "container_export"
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/container/*": {
+               "title_hide": "Container",
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/container"
+               }
+       },
+
+       "admin/services/dockerman/containers": {
+               "title": "Containers",
+               "order": 3,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/containers"
+               }
+       },
+
+       "admin/services/dockerman/containers/prune": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "containers_prune",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/container": {
+               "action": {
+                       "type": "alias",
+                       "path": "admin/services/dockerman/containers"
+               }
+       },
+
+       "admin/services/dockerman/container_new": {
+               "title_hide": "Container",
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/container_new"
+               }
+       },
+
+       "admin/services/dockerman/images/build": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_build",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images/build/prune": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_build_prune",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images/get/*": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_get"
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images/load": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_load",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images/prune": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "images_prune",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images": {
+               "title": "Images",
+               "order": 4,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/images"
+               }
+       },
+
+       "admin/services/dockerman/images/create": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_create",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/images/push/*": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "image_push",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/image": {
+               "action": {
+                       "type": "alias",
+                       "path": "admin/services/dockerman/images"
+               }
+       },
+
+       "admin/services/dockerman/network/*": {
+               "title_hide": "Network",
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/network"
+               }
+       },
+
+       "admin/services/dockerman/networks": {
+               "title": "Networks",
+               "order": 5,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/networks"
+               }
+       },
+
+       "admin/services/dockerman/networks/prune": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "networks_prune",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/network_new": {
+               "title_hide": "Network",
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/network_new"
+               }
+       },
+
+       "admin/services/dockerman/network": {
+               "action": {
+                       "type": "alias",
+                       "path": "admin/services/dockerman/networks"
+               }
+       },
+
+       "admin/services/dockerman/volumes": {
+               "title": "Volumes",
+               "order": 6,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/volumes"
+               }
+       },
+
+       "admin/services/dockerman/volumes/prune": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "volumes_prune",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/docker/events": {
+               "action": {
+                       "type": "function",
+                       "module": "luci.controller.docker",
+                       "function": "docker_events",
+                       "post": true
+               },
+               "auth": {
+                       "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
+                       "login": true
+               }
+       },
+
+       "admin/services/dockerman/events": {
+               "title": "Events",
+               "order": 7,
+               "action": {
+                       "type": "view",
+                       "path": "dockerman/events"
+               }
+       }
+}
\ No newline at end of file
index 353ccaa16c17f8c4f3f11b7af7472e42a2044ce8..3e277d3f8235b9ebbed62aa14697649ee2977322 100644 (file)
@@ -2,9 +2,25 @@
        "luci-app-dockerman": {
                "description": "Grant UCI access for luci-app-dockerman",
                "read": {
+                       "file_comment": "so directory picker can browse the FS",
+                       "file": {
+                               "/*": ["list", "read"]
+                       },
+                       "ubus": {
+                               "docker": [ "*" ],
+                               "docker.*": [ "*" ],
+                               "file": [ "*" ],
+                               "luci": [ "getMountPoints" ],
+                               "rc": [ "init" ]
+                       },
                        "uci": [ "dockerd" ]
                        },
                "write": {
+                       "ubus": {
+                               "docker": [ "*" ],
+                               "docker.*": [ "*" ],
+                               "rc": [ "init" ]
+                       },
                        "uci": [ "dockerd" ]
                }
        }
diff --git a/applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc b/applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc
new file mode 100755 (executable)
index 0000000..5fb8539
--- /dev/null
@@ -0,0 +1,507 @@
+#!/usr/bin/env ucode
+
+// Copyright 2025 Paul Donald / luci-lib-docker-js
+// Licensed to the public under the Apache License 2.0.
+// Built against the docker v1.47 API
+
+
+'use strict';
+
+import * as http from 'luci.http';
+import * as fs from 'fs';
+import * as socket from 'socket';
+import { cursor } from 'uci';
+import * as ds from 'luci.docker_socket';
+
+// const cmdline = fs.readfile('/proc/self/cmdline');
+// const args = split(cmdline, '\0');
+const caller = trim(fs.readfile('/proc/self/comm'));
+
+const BLOCKSIZE = 8192;
+const POLL_TIMEOUT = 8000; // default; can be overridden per request
+// const API_VER = '/v1.47';
+const PROTOCOL = 'HTTP/1.1';
+const CLIENT_VER = '1';
+
+function merge(a, b) {
+       let c = {};
+       for (let k, v in a)
+               c[k] = v;
+       for (let k, v in b)
+               c[k] = v;
+       return c;
+};
+
+function chunked_body_reader(sock, initial_buffer) {
+       let state = 0, chunklen = 0, buffer = initial_buffer || '';
+
+       function poll_and_recv() {
+               let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
+               if (!ready || !length(ready)) return null;
+               let data = sock.recv(BLOCKSIZE);
+               if (!data) return null;
+               buffer += data;
+               return true;
+       }
+
+       return () => {
+               while (true) {
+                       if (state === 0) {
+                               let m = match(buffer, /^([0-9a-fA-F]+)\r\n/);
+                               if (!m || length(m) < 2) {
+                                       if (!poll_and_recv()) return null;
+                                       continue;
+                               }
+                               chunklen = int(m[1], 16);
+                               buffer = substr(buffer, length(m[0]));
+                               if (chunklen === 0) return null;
+                               state = 1;
+                       }
+                       if (state === 1 && length(buffer) >= chunklen + 2) {
+                               let chunk = substr(buffer, 0, chunklen);
+                               buffer = substr(buffer, chunklen + 2);
+                               state = 0;
+                               return chunk;
+                       } else {
+                               if (!poll_and_recv()) return null;
+                               continue;
+                       }
+               }
+       };
+};
+
+function read_http_headers(response_headers, response) {
+       const lines = split(response, /\r?\n/);
+
+       for (let l in lines) {
+               let kv = match(l, /([^:]+):\s*(.*)/);
+               if (kv && length(kv) === 3)
+                       response_headers[lc(kv[1])] = kv[2];
+       }
+
+       return response_headers;
+};
+
+function get_api_ver() {
+
+       const ctx = cursor();
+       const version = ctx.get('dockerd', 'globals', 'api_version') || '';
+       const version_str = version ? `/${version}` : '';
+       ctx.unload();
+
+       return version_str;
+};
+
+function coerce_values_to_string(obj) {
+       for (let k, v in obj) {
+               v = `${v}`;
+               obj[k]=v;
+       }
+       return obj;
+};
+
+function call_docker(method, path, options) {
+       options = options || {};
+       const headers = options.headers || {};
+       let payload = options.payload || null;
+
+       /* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g.
+       '127.0.0.1:2375'.
+       We use get_socket_dest_compat() which builds the SockAddress manually to
+       avoid this.
+
+       Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without
+       --tls* options.
+
+       A solution is a reverse proxy or ssh port forwarding to a remote host that
+       uses the unix socket, and you still connect to a 'local port', or socket:
+       ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client)
+       or (dropbear)
+       socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock
+       ssh -L 2375:localhost:12375 user@remote-host
+       */
+
+
+       /* works on ucode 2025-12-01 */
+       const sock_dest = ds.get_socket_dest_compat();
+       const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
+
+       /* works on ucode 2026-01-16 */
+       // const sock_dest = ds.get_socket_dest();
+       // const sock_addr = socket.sockaddr(sock_dest);
+       // const sock = socket.create(sock_addr.family, socket.SOCK_STREAM);
+
+       if (caller != 'rpcd') {
+               print('sock_dest:', sock_dest, '\n');
+               // print('sock_addr:', sock_addr, '\n');
+       }
+       if (!sock) {
+               return {
+                       code: 500,
+                       headers: {},
+                       body: { message: "Failed to create socket" }
+               };
+       }
+
+       let conn_result = sock.connect(sock_dest);
+       let err_msg = `Failed to connect to docker host at ${sock_dest}`;
+       if (!conn_result) {
+               sock.close();
+               return {
+                       code: 500,
+                       headers: {},
+                       body: { message: err_msg}
+               };
+       }
+
+       if (caller != 'rpcd')
+               print("query: ", options.query, '\n');
+
+       const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : '';
+       const url = path + query;
+
+       const req_headers = [
+               `${method} ${get_api_ver()}${url} ${PROTOCOL}`,
+               `Host: luci-host`,
+               `User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
+               `Connection: close`
+       ];
+
+       if (payload) {
+               if (type(payload) === 'object') {
+                       payload = sprintf('%J', payload);
+                       headers['Content-Type'] = 'application/json';
+               }
+               headers['Content-Length'] = '' + length(payload);
+       }
+
+       for (let k, v in headers)
+               push(req_headers, `${k}: ${v}`);
+
+       push(req_headers, '', '');
+
+       if (caller != 'rpcd')
+               print(join('\r\n', req_headers), "\n");
+
+       sock.send(join('\r\n', req_headers));
+       if (payload) sock.send(payload);
+
+       const response_buff = sock.recv(BLOCKSIZE);
+       if (!response_buff || response_buff === '') {
+               sock.close();
+               return {
+                       code: 500,
+                       headers: {},
+                       body: { message: "No response from Docker socket" }
+               };
+       }
+       
+       const response_parts = split(response_buff, /\r?\n\r?\n/, 2);
+       const response_headers = read_http_headers({}, response_parts[0]);
+       let response_body;
+
+       let is_chunked = (response_headers['transfer-encoding'] === 'chunked');
+
+       let reader;
+       if (is_chunked) {
+               reader = chunked_body_reader(sock, response_parts[1]);
+       }
+       else if (response_headers['content-length']) {
+               let content_length = int(response_headers['content-length']);
+               let buf = response_parts[1];
+
+               reader = () => {
+                       if (content_length <= 0) return null;
+
+                       if (buf && length(buf)) {
+                               let chunk = substr(buf, 0, content_length);
+                               buf = substr(buf, length(chunk));
+                               content_length -= length(chunk);
+                               return chunk;
+                       }
+
+                       let data = sock.recv(min(BLOCKSIZE, content_length));
+                       if (!data || data === '') return null;
+
+                       content_length -= length(data);
+                       return data;
+               };
+       }
+       else {
+               // Fallback for HTTP/1.0 or no content-length: read until close or timeout
+               reader = () => {
+                       // Poll with 2 second timeout
+                       let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
+                       if (!ready || !length(ready)) return null; // Timeout or error
+
+                       let data = sock.recv(BLOCKSIZE);
+                       if (!data || data === '') return null;
+                       return data;
+               };
+       }
+
+       let chunks = [], chunk;
+       while ((chunk = reader())) {
+               push(chunks, chunk);
+       }
+
+       sock.close();
+
+       response_body = join('', chunks);
+
+       // Parse HTTP status code
+       let status_line = split(response_parts[0], /\r?\n/)[0];
+       let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
+       let code = status_match ? int(status_match[1]) : 0;
+
+       // Docker events endpoint returns newline-delimited JSON, not a single JSON object
+       if (response_headers['content-type'] === 'application/json' && response_body) {
+               // Single JSON object
+               let data;
+               try { data = json(rtrim(response_body)); }
+               catch { data = null; }
+
+               // Check if this is newline-delimited JSON (multiple lines with JSON objects)
+               if (!data) {
+                       // Parse each line as a separate JSON object
+                       let lines = split(trim(response_body), /\n/);
+                       let events = [];
+                       for (let line in lines) {
+                               line = trim(line);
+                               if (line) {
+                                       try { push(events, json(line)); }
+                                       catch { /* skip invalid lines */ }
+                               }
+                       }
+                       response_body = events;
+               } else {
+                       response_body = data;
+               }
+       }
+
+       return {
+               code: code,
+               headers: response_headers,
+               body: response_body
+       };
+};
+
+function run_ttyd(request) {
+
+       const id = request.args.id || '';
+       const cmd = request.args.cmd || '/bin/sh';
+       const port = request.args.port || 7682;
+       const uid = request.args.uid || '';
+
+       if (!id) {
+               return { error: 'Container ID is required' };
+       }
+
+       let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`;
+       const sock_addr = ds.get_socket_dest();
+
+       /* Build the full command:
+       ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD
+
+       if the socket is /var/run/docker.sock, prefix unix://
+
+       Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls*
+       */
+       const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr;
+       ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`;
+       if (uid && uid !== '') {
+               ttyd_cmd = `${ttyd_cmd} -u ${uid}`;
+       }
+
+       ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`;
+
+       // Try to kill any existing ttyd processes on this port
+       system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true');
+
+       // Start ttyd
+       system(ttyd_cmd);
+
+       return { status: 'ttyd started', command: ttyd_cmd };
+}
+
+// https://docs.docker.com/reference/api/engine/version/v1.47/
+
+/* Note: methods here are included for structural reference. Some rpcd methods
+are not suitable to be called from the GUI because they are streaming endpoints
+or the operations in a busy dockerd cluster take a *long* time which causes
+timeouts at the front end. Good examples of this are:
+- /system/df 
+- push
+- pull
+- all /prune
+
+We include them here because they can be useful from the command line.
+*/
+
+const core_methods = {
+       version: { call: () => call_docker('GET', '/version') },
+       info:    { call: () => call_docker('GET', '/info') },
+       ping:    { call: () => call_docker('GET', '/_ping') },
+       df:      { call: () => call_docker('GET', '/system/df') },
+       events:  { args: { query: { 'since': '', 'until': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) },
+};
+
+const exec_methods = {
+       start:   { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) },
+       resize:  { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) },
+       inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) },
+};
+
+const container_methods = {
+       list:    { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) },
+       create:  { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) },
+       inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) },
+       top:     { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) },
+       logs:    { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) },
+       changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) },
+       export:  { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) },
+       stats:   { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) },
+       resize:  { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) },
+       start:   { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) },
+       stop:    { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) },
+       restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) },
+       kill:    { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) },
+       update:  { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) },
+       rename:  { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) },
+       pause:   { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) },
+       unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) },
+       // attach
+       // attach websocket
+       // wait
+       remove:  { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) },
+       // archive info
+       info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
+       // archive get
+       get_archive:  { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
+       // archive extract
+       put_archive:  { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) },
+       exec:    { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) },
+       prune:   { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.query }) },
+
+       // Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker
+       ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) },
+};
+
+const image_methods = {
+       list:    { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request.args.query }) },
+       // build is long-running, and will likely cause time-out on the call. Function only here for reference.
+       build:   { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) },
+       build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) },
+       create:  { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) },
+       inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) },
+       history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) },
+       push:    { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) },
+       tag:     { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) },
+       remove:  { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) },
+       search:  { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) },
+       prune:   { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) },
+       // create/commit
+       get:     { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) },
+       // get == export several
+       load:    { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) },
+};
+
+const network_methods = {
+       list:    { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) },
+       inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) },
+       remove:  { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) },
+       create:  { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) },
+       connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) },
+       disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) },
+       prune:   { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) },
+};
+
+const volume_methods = {
+       list:    { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) },
+       create:  { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) },
+       inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) },
+       update:  { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) },
+       remove:  { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) },
+       prune:   { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.query }) },
+};
+
+const methods = {
+       'docker': core_methods, 
+       'docker.container': container_methods,
+       'docker.exec': exec_methods,
+       'docker.image': image_methods,
+       'docker.network': network_methods,
+       'docker.volume': volume_methods,
+};
+
+// CLI test mode - check if script is run directly (not loaded by rpcd)
+if (caller != 'rpcd') {
+       // Usage: ./docker_rpc.uc <object.method> <json-args>
+       // Example: ./docker_rpc.uc docker.network.list '{"query":{"filters":""}}'
+       // Example: ./docker_rpc.uc docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'
+       const scr_name = split(SCRIPT_NAME, '/')[-1] || 'docker_rpc.uc';
+
+       if (length(ARGV) < 1) {
+               print(`Usage: ${scr_name} <object.method> [json-args]\n`);
+
+               print("Available methods:\n");
+               for (let obj in methods) {
+                       for (let name, info in methods[obj]) {
+                               let sig = name;
+                               if (info && info.args) {
+                                       try {
+                                               sig = `${sig} ${sprintf('\'%J\'', info.args)}`;
+                                       } catch {
+                                               sig = `${sig} <args>`;
+                                       }
+                               }
+                               print(`  ${obj}.${sig}\n`);
+                       }
+               }
+
+               print("\nExamples:\n");
+               print(`  ${scr_name} docker.version\n`);
+               print(`  ${scr_name} docker.network.list '{"query":{}}'\n`);
+               print(`  ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`);
+               print(`  ${scr_name} docker.container.list '{"query":{"all":true}}'\n`);
+               exit(1);
+       }
+
+       const method_path = split(ARGV[0], '.');
+       if (length(method_path) < 1) {
+               die(`Invalid method path: ${ARGV[0]}\n`);
+       }
+
+       // Build object path (e.g., "docker.network")
+       const obj_parts = slice(method_path, 0, -1);
+       const obj_name = join('.', obj_parts);
+       const method_name = method_path[length(method_path) - 1];
+
+       if (!methods[obj_name]) {
+               die(`Unknown object: ${obj_name}\n`);
+       }
+
+       if (!methods[obj_name][method_name]) {
+               die(`Unknown method: ${obj_name}.${method_name}\n`);
+       }
+
+       // Parse args if provided
+       let args = {};
+       if (length(ARGV) > 1) {
+               try {
+                       args = json(ARGV[1]);
+               } catch (e) {
+                       die(`Invalid JSON args: ${e}\n`);
+               }
+       }
+
+       // Call the method
+       const request = { args: args };
+       const result = methods[obj_name][method_name].call(request);
+
+       // Pretty print result
+       print(result, "\n");
+       exit(0);
+};
+
+return methods;
diff --git a/applications/luci-app-dockerman/ucode/controller/docker.uc b/applications/luci-app-dockerman/ucode/controller/docker.uc
new file mode 100644 (file)
index 0000000..b2ca046
--- /dev/null
@@ -0,0 +1,393 @@
+// Docker HTTP streaming endpoint
+// Copyright 2025 Paul Donald <newtwen+github@gmail.com>
+// Licensed to the public under the Apache License 2.0.
+// Built against the docker v1.47 API
+
+'use strict';
+
+import { stdout } from 'fs';
+import * as ds from 'luci.docker_socket';
+import * as socket from 'socket';
+import { cursor } from 'uci';
+
+const BUFF_HEAD = 6; // 8000\r\n
+const BUFF_TAIL = 2; // \r\n
+const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776
+// const API_VER = 'v1.47';
+const PROTOCOL = 'HTTP/1.1';
+const CLIENT_VER = '1';
+
+let DockerController = {
+
+       // Handle file upload for chunked transfer
+       handle_file_upload: function(sock) {
+               let total_bytes = 0;
+               http.setfilehandler(function(meta, chunk, eof) {
+                       if (meta.file && meta.name === 'upload-archive') {
+                               if (chunk && length(chunk) > 0) {
+                                       let hex_size = sprintf('%x', length(chunk));
+                                       sock.send(hex_size + '\r\n');
+                                       sock.send(chunk);
+                                       sock.send('\r\n');
+                                       total_bytes += length(chunk);
+                               }
+                               if (eof) {
+                                       sock.send('0\r\n\r\n');
+                               }
+                       }
+               });
+               return total_bytes;
+       },
+
+       // Reusable header builder
+       build_headers: function(headers) {
+               let hdrs = [];
+               if (headers) {
+                       for (let key in headers) {
+                               if (headers[key] != null && headers[key] != '') {
+                                       push(hdrs, `${key}: ${headers[key]}`);
+                               }
+                       }
+               }
+               return length(hdrs) ? join('\r\n', hdrs) : '';
+       },
+
+       // Parse the initial HTTP response, split into parts and header lines, and store as properties
+       initial_response_parser: function(response_buff) {
+               let parts = split(response_buff, /\r?\n\r?\n/, 2);
+               let header_lines = split(parts[0], /\r?\n/);
+               let status_line = header_lines[0];
+               let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
+               let code = status_match ? int(status_match[1]) : 500;
+               this.response_parts = parts;
+               this.header_lines = header_lines;
+               this.status_line = status_line;
+               this.status_match = status_match;
+               this.code = code;
+       },
+
+       // Stream the rest of the response in chunks from the socket
+       stream_response_chunks: function(sock, blocksize) {
+               let chunk;
+               while ((chunk = sock.recv(blocksize))) {
+                       if (chunk && length(chunk)) {
+                               this.debug('Streaming chunk:', substr(chunk, 0, 10));
+                               stdout.write(chunk);
+                       }
+               }
+       },
+
+       // Send a 200 OK response with headers and body
+       /* Write CGI response directly to stdout bypassing http.uc
+       The minimum to trigger a valid response via CGI is typically
+       Status: \r\n
+
+       The Docker response contains the \r\n after its headers, and the browser can
+       handle the chunked encoding fine, so we just forward its output verbatim.
+
+       Docker emits a x-docker-container-path-stat header with some meta-data for
+       the path, which we forward. uhttpd seems to coalesce headers, and inject its
+       own, so we occasionally have two Connection: headers.
+       */
+       send_initial_200_response: function(headers, body) {
+               stdout.write('Status: 200 OK\r\n');
+               if (headers && type(headers) == 'array') {
+                       stdout.write(join('', headers));
+               }
+
+               if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) {
+                       stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n')));
+               }
+       },
+
+       // Debug output if &debug=... is present
+       debug: function(...args) {
+               let dbg = http.formvalue('debug');
+               let tostr = function(x) { return `${x}`; };
+               if (dbg != null && dbg != '') {
+                       http.prepare_content('application/json');
+                       http.write_json({msg: join(' ', map(args, tostr)) + '\n' });
+               }
+       },
+
+       // Generic error response helper
+       error_response: function(code, msg, detail) {
+               http.status(code ?? 500, msg ?? 'Internal Error');
+               http.prepare_content('application/json');
+               let out = { error: msg ?? 'Internal Error' };
+               if (detail)
+                       out.detail = detail;
+               http.write_json(out);
+       },
+
+       get_api_ver: function() {
+               let ctx = cursor();
+               let version = ctx.get('dockerd', 'globals', 'api_version') || '';
+               ctx.unload();
+               return version ? `/${version}` : '';
+       },
+
+       join_args_array: function(args) {
+               return (type(args) == "array") ? join('/', args) : args;
+       },
+
+       require_param: function(name) {
+               let val = http.formvalue(name);
+               if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` });
+               return val;
+       },
+
+       // Reusable query string builder
+       build_query_str: function(query_params, skip_keys) {
+               let query_str = '';
+               if (query_params) {
+                       let parts = [];
+                       for (let key in query_params) {
+                               if (skip_keys && (key in skip_keys))
+                                       continue;
+                               let val = query_params[key];
+                               if (val == null || val == '') continue;
+                               if (type(val) === 'array') {
+                                       for (let v in val) {
+                                               push(parts, `${key}=${v}`);
+                                       }
+                               } else {
+                                       push(parts, `${key}=${val}`);
+                               }
+                       }
+                       if (length(parts))
+                               query_str = '?' + join('&', parts);
+               }
+               return query_str;
+       },
+
+       get_archive: function(docker_path, id, docker_function, query_params, archive_name) {
+               this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name);
+               id = this.join_args_array(id);
+               let id_param = '';
+               if (id) id_param = `/${id}`;
+
+               const sock_dest = ds.get_socket_dest_compat();
+               const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
+
+               this.debug('Socket created:', !!sock);
+
+               if (!sock) {
+                       this.debug('Socket creation failed');
+                       this.error_response(500, 'Failed to create socket');
+                       return;
+               }
+
+               if (!sock.connect(sock_dest)) {
+                       this.debug('Socket connect failed');
+                       sock.close();
+                       this.error_response(503, 'Failed to connect to Docker daemon');
+                       return;
+               }
+
+               let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`;
+               let url = `${docker_path}${id_param}${docker_function}${query_str}`;
+               let req = [
+                       `GET ${this.get_api_ver()}${url} ${PROTOCOL}`,
+                       `Host: openwrt-docker-ui`,
+                       `User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
+                       `Connection: close`,
+                       ``,
+                       ``
+               ];
+
+               this.debug('Sending request:', req);
+               sock.send(join('\r\n', req));
+
+               let response_buff = sock.recv(BLOCKSIZE);
+               this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
+               if (!response_buff || response_buff == '') {
+                       this.debug('No response from Docker daemon');
+                       sock.close();
+                       this.error_response(500, 'No response from Docker daemon');
+                       return;
+               }
+
+               this.initial_response_parser(response_buff);
+
+               if (this.code != 200) {
+                       this.debug('Docker error status:', this.code, this.status_line);
+                       sock.close();
+                       this.error_response(this.code, 'Docker Error', this.status_line);
+                       return;
+               }
+
+               let filename = length(id) >= 64 ? substr(id, 0, 12) : id;
+               if (!filename) filename = 'multi';
+               let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`];
+
+               this.send_initial_200_response(include_headers, response_buff);
+               this.stream_response_chunks(sock, BLOCKSIZE);
+
+               sock.close();
+               return;
+       },
+
+       docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) {
+               this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile);
+               const sock_dest = ds.get_socket_dest_compat();
+               const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
+
+               this.debug('Socket created:', !!sock);
+
+               if (!sock) {
+                       this.debug('Socket creation failed');
+                       this.error_response(500, 'Failed to create socket');
+                       return;
+               }
+
+               if (!sock.connect(sock_dest)) {
+                       this.debug('Socket connect failed');
+                       sock.close();
+                       this.error_response(503, 'Failed to connect to Docker daemon');
+                       return;
+               }
+
+               let skip_keys = {
+                       token: true,
+                       'X-Registry-Auth': true,
+                       'upload-name': true,
+                       'upload-archive': true,
+                       'upload-path': true,
+               };
+
+               let remote = false;
+
+               if (query_params && type(query_params) === 'object' &&
+                       query_params['remote'] != null && query_params['remote'] != '')
+                       remote = true;
+
+               let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`;
+
+               let hdr_str = this.build_headers(headers);
+
+               let url = `${docker_path}${docker_function}${query_str}`;
+
+               let req = [
+                       `${method} ${this.get_api_ver()}${url} ${PROTOCOL}`,
+                       `Host: openwrt-docker-ui`,
+                       `User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`,
+                       `Connection: close`,
+               ];
+
+               if (hdr_str)
+                       push(req, hdr_str);
+               if (haveFile) {
+                       push(req, 'Content-Type: application/x-tar');
+                       push(req, 'Transfer-Encoding: chunked');
+               }
+               push(req, '');
+               push(req, '');
+
+               this.debug('Sending request:', req);
+               sock.send(join('\r\n', req));
+
+               if (haveFile)
+                       this.handle_file_upload(sock);
+               else
+                       sock.send('\r\n\r\n');
+
+               let response_buff = sock.recv(BLOCKSIZE);
+               this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
+               if (!response_buff || response_buff == '') {
+                       this.debug('No response from Docker daemon');
+                       sock.close();
+                       this.error_response(500, 'No response from Docker daemon');
+                       return;
+               }
+
+               this.initial_response_parser(response_buff);
+               if (this.code != 200) {
+                       this.debug('Docker error status:', this.code, this.status_line);
+                       sock.close();
+                       this.error_response(this.code, 'Docker Error', this.status_line);
+                       return;
+               }
+
+               this.send_initial_200_response('', response_buff);
+               this.stream_response_chunks(sock, BLOCKSIZE);
+               sock.close();
+               return;
+       },
+
+       // Handler methods
+       container_get_archive: function(id) {
+               this.require_param('path');
+               this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar');
+       },
+
+       container_export: function(id) {
+               this.get_archive('/containers', id, '/export', null, 'container_export.tar');
+       },
+
+       containers_prune: function() {
+               this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false);
+       },
+
+       container_put_archive: function(id) {
+               this.require_param('path');
+               this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true);
+       },
+
+       docker_events: function() {
+               this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false);
+       },
+
+       image_build: function(...args) {
+               let remote = http.formvalue('remote');
+               this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote);
+       },
+
+       image_build_prune: function() {
+               this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false);
+       },
+
+       image_create: function() {
+               let headers = {
+                       'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
+               };
+               this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false);
+       },
+
+       image_get: function(...args) {
+               this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar');
+       },
+
+       image_load: function() {
+               this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true);
+       },
+
+       images_prune: function() {
+               this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false);
+       },
+
+       image_push: function(...args) {
+               let headers = {
+                       'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
+               };
+               this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false);
+       },
+
+       networks_prune: function() {
+               this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false);
+       },
+
+       volumes_prune: function() {
+               this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false);
+       },
+};
+
+// Export all handlers with automatic error wrapping
+let controller = DockerController;
+let exports = {};
+for (let k, v in controller) {
+       if (type(v) == 'function')
+               exports[k] = v;
+}
+
+return exports;
diff --git a/applications/luci-app-dockerman/ucode/docker_socket.uc b/applications/luci-app-dockerman/ucode/docker_socket.uc
new file mode 100644 (file)
index 0000000..ab4d149
--- /dev/null
@@ -0,0 +1,87 @@
+
+// Copyright 2025 Paul Donald / luci-lib-docker-js
+// Licensed to the public under the Apache License 2.0.
+// Built against the docker v1.47 API
+
+import { cursor } from 'uci';
+import * as socket from 'socket';
+
+/**
+ * Get the Docker socket path from uci config, more backwards compatible.
+ */
+export function get_socket_dest_compat() {
+       const ctx = cursor();
+       let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
+       ctx.unload();
+
+       sock_entry = lc(sock_entry);
+       /* start ucode 2025-12-01 compatibility */
+       let sock_split, addr = sock_entry, proto, proto_num, port = 0;
+       let family;
+
+       if (index(sock_entry, '://') != -1) {
+               let sock_split = split(lc(sock_entry), '://', 2);
+               addr = sock_split?.[1];
+               proto = sock_split?.[0];
+       } 
+       if (index(addr, '/') != -1) {
+               // we got '/var/run/docker.sock' format
+               return socket.sockaddr(addr);
+       }
+
+       if (proto === 'tcp' || proto === 'udp' || proto === 'inet') {
+               family = socket.AF_INET;
+               if (proto === 'tcp')
+                       proto_num = socket.IPPROTO_TCP;
+               else if (proto === 'udp')
+                       proto_num =  socket.IPPROTO_UDP;
+       }
+       else if (proto === 'tcp6' || proto === 'udp6' || proto === 'inet6') {
+               family = socket.AF_INET6;
+               if (proto === 'tcp6')
+                       proto_num = socket.IPPROTO_TCP;
+               else if (proto === 'udp6')
+                       proto_num =  socket.IPPROTO_UDP;
+       }
+       else if (proto === 'unix')
+               family = socket.AF_UNIX;
+       else {
+               family = socket.AF_INET; // ipv4
+               proto_num = socket.IPPROTO_TCP; // tcp
+       }
+
+       let host = addr;
+       const l_bracket = index(host, '[');
+       const r_bracket = rindex(host, ']');
+       if (l_bracket != -1 && r_bracket != -1) {
+               host = substr(host, l_bracket + 1, r_bracket - 1);
+               family = socket.AF_INET6;
+       }
+
+       // find port based on addr, otherwise we find ':' in IPv6
+       const port_index = rindex(addr, ':');
+       if (port_index != -1) {
+               port = int(substr(addr, port_index + 1)) || 0;
+               host = substr(host, 0, port_index);
+       }
+
+       const sock = socket.addrinfo(host, port, {protocol: proto_num});
+
+       return socket.sockaddr(sock[0].addr);
+       // return {family: family, address: host, port: port};
+};
+
+
+/**
+ * Get the Docker socket path from uci config
+ */
+export function get_socket_dest() {
+
+       const ctx = cursor();
+       let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
+       sock_entry = lc(sock_entry);
+       let sock_addr = split(sock_entry, '://', 2)?.[1] ?? sock_entry;
+       ctx.unload();
+
+       return sock_addr;
+};