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
--- /dev/null
+# 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
+```
--- /dev/null
+'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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ 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,
+});
-<?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
--- /dev/null
+'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();
+ }
+});
--- /dev/null
+'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,
+
+});
--- /dev/null
+'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,
+
+});
--- /dev/null
+'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;
+ },
+
+});
--- /dev/null
+'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,
+
+});
--- /dev/null
+'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;
+ },
+
+});
--- /dev/null
+'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,
+
+});
--- /dev/null
+'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,
+
+});
--- /dev/null
+'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;
+ },
+
+});
--- /dev/null
+'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)])
+ ])
+ ])
+ ])
+ ])
+
+ }
+});
--- /dev/null
+'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);
+ },
+
+});
--- /dev/null
+{
+ "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
"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" ]
}
}
--- /dev/null
+#!/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;
--- /dev/null
+// 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;
--- /dev/null
+
+// 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;
+};