diff --git a/applications/luci-app-dockerman/Makefile b/applications/luci-app-dockerman/Makefile index 69b62c162c..920fd1bb1d 100644 --- a/applications/luci-app-dockerman/Makefile +++ b/applications/luci-app-dockerman/Makefile @@ -3,18 +3,16 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI Support for docker LUCI_DEPENDS:=@(aarch64||arm||x86_64) \ +luci-base \ - +luci-compat \ - +luci-lib-docker \ +docker \ +ttyd \ +dockerd \ - +docker-compose + +docker-compose \ + +ucode-mod-socket PKG_LICENSE:=AGPL-3.0 -PKG_MAINTAINER:=lisaac \ +PKG_MAINTAINER:=Paul Donald \ Florian Eckert -PKG_VERSION:=0.5.13.20241008 include ../../luci.mk diff --git a/applications/luci-app-dockerman/README.md b/applications/luci-app-dockerman/README.md new file mode 100644 index 0000000000..2b7d59ab79 --- /dev/null +++ b/applications/luci-app-dockerman/README.md @@ -0,0 +1,206 @@ +# Dockerman JS + +## Notice + +After dockerd _v27_, docker will **remove** the ability to listen on sockets of the form + +`xxx://x.x.x.x:2375` or `xxx://x.x.x.x:2376` (or `xxx://[2001:db8::1]:2375`) + +unless you run the daemon with various `--tls*` flags. That is, dockerd will *refuse* +to start unless it is configured to use TLS. See +[here](https://docs.docker.com/engine/security/#docker-daemon-attack-surface) +[here](https://docs.docker.com/engine/deprecated/#unauthenticated-tcp-connections) +and [here](https://docs.docker.com/engine/security/protect-access/). + +ucode is not yet capable of TLS, so if you want dockerd to listen on a port, +you have a few options. + +Issues opened in the luci repo regarding connection setup will go unanswered. +DIY. + +This implementation includes three methods to connect to the API. + + +# API Availability + + +| | rpcd/CGI | Reverse Proxy | Controller | +|------------------|----------|----------------|------------| +| API | ✅ | ✅ | ✅ | +| File Stream | ❌ | ✅ | ✅ | +| Console Start | ✅ | ❌ | ❌ | +| Stream endpoints | ❌ | ✅ | ✅ | + +* Stream endpoints are docker API paths that continue to stream data, like logs + +Dockerman uses a combination of rpcd and ucode Controller so API, Console via +ttyd and File Streaming operations are available. dockerd is configured by +default to use `unix:///var/run/docker.sock`, and is secure this way. + + +It is possible to configure dockerd to listen on e.g.: + +`['unix:///var/run/docker.sock', 'tcp://0.0.0.0:2375']` + +when you have a Reverse Proxy configured. + +## Reverse Proxy + +Use nginx or Caddy to proxy connections to dockerd which is configured with +`--tls*` flags, or communicates directly with `unix:///var/run/docker.sock`, +which adds the necessary `Access-Control-Allow-Origin: ...` +headers for browser clients. You might even be able to run a +docker container that does this. If you don't want to set a proxy up, use a +[browser plugin](#browser-plug-in). + +https://github.com/lucaslorentz/caddy-docker-proxy +https://github.com/Tecnativa/docker-socket-proxy + +## LuCI + +Included is a ucode rpc API interface to talk with the docker socket, so all +API calls are sent via rpcd, and appear as POST calls in your front end at e.g. + +http://192.168.1.1/cgi-bin/luci + + +All calls to the docker API are authenticated with your session login. + +### Controller + +Included also is a ucode based controller to forward requests more directly to +the docker API socket to avoid the rpc penalty, and stream file uploads and +downloads. These are still authenticated with your session login. The methods +to reach the controller API are defined in the menu JSON file. The controller +API interface only exposes a limited subset of API methods. + + +# Architecture + +## High-Level Architecture + +### rpcd and controller +``` +┌──────────────────────────────────────────────────────────────────┐ +│ OpenWrt/LuCI │ +│ │ +│ ┌─────────────────────┐ │ +│ │ Browser / UI │ │ +│ │ containers.js │ │ +│ │ images.js │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ │ 1. GET /admin/docker/container/inspect/id?x=y │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ LuCI Dispatcher │ │ +│ │ (dispatcher.uc) │ │ +│ │ - Parses URL path │ │ +│ │ - Looks up action │ │ +│ │ - Extracts query params │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ 2. Call controller function(env) │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ HTTP Controller │ │ +│ │ (docker.uc) │ │ +│ │ - container_inspect(env)│ │ +│ │ - Gets params from env │ │ +│ │ - Creates socket │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ 3. Connect to Docker socket │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ Docker Socket │ │ +│ │ /var/run/docker.sock │ │ +│ │ (AF_UNIX socket) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ 4. HTTP GET /v1.47/containers/{id}/json │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ Docker Daemon 200 OK │ │ +│ │ - Creates JSON blob │ │ +│ │ - Streams binary data │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ 5. data chunks (32KB blocks) │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ UHTTPd Web Server │ │ +│ │ - Receives chunks │ │ +│ │ - Writes to HTTP socket │ │ +│ │ (no buffering) │ │ +│ └──────────┬───────────────┘ │ +│ │ │ +│ │ 6. HTTP 200 + data stream │ +│ V │ +│ ┌──────────────────────────┐ │ +│ │ Browser │ │ +│ │ - Receives data stream │ │ +│ │ - Processes response │ │ +│ │ - Displays result │ │ +│ └──────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Request/Response Flow + +### Container Export Flow + +``` +Browser Ucode Controller Docker + │ │ │ + ├─ GET /admin/docker │ │ + │ /container/export │ │ + │ /{id}?abc123 ─────>│ │ + │ ├─ Get param 'id' │ + │ │ from env.http │ + │ │ │ + │ ├─ Create socket │ + │ │ │ + │ ├─ Connect to │ + │ │ /var/run/ │ + │ │ docker.sock ────> + │ │ │ + │ │ <─ HTTP 200 OK │ + │ │ │ + │ │ <─ tar chunk 1 │ + │ │ <─ tar chunk 2 │ + │ <─ HTTP 200 OK ──────│ <─ tar chunk 3 │ + │ <─ tar chunk 1 ──────│ <─ ... │ + │ <─ tar chunk 2 ──────│ <─ EOF │ + │ <─ ... │ │ + │ │ │ + ├─ Done │ │ + │ ├─ Close socket │ + │ │ │ +``` + + +## Socket Connection Details + +``` +┌──────────────────────────────────────┐ +│ UHTTPd (Web Server) │ +│ [Controller Process] │ +└─────────────┬────────────────────────┘ + │ + │ AF_UNIX socket + │ (named pipe) + V +┌──────────────────────────────────────┐ +│ Docker Daemon │ +│ /var/run/docker.sock │ +└─────────────┬────────────────────────┘ + │ + │ HTTP Protocol + │ (over socket) + V + Docker API Engine + - Creates export tar + - Sends as chunked stream +``` diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js new file mode 100644 index 0000000000..b5bee3c449 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js @@ -0,0 +1,1409 @@ +'use strict'; +'require form'; +'require fs'; +'require uci'; +'require ui'; +'require rpc'; +'require view'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +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} 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} - 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} - 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 += `${escaped}`; + } 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
+ html = html.replace(/\r\n/g, '
').replace(/\r/g, '
').replace(/\n/g, '
'); + + 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, +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/containers.svg b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/containers.svg index 4165f90bdc..eba6cc41e6 100644 --- a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/containers.svg +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/containers.svg @@ -1,7 +1,12 @@ - - - - - Docker icon - - + + + + + + + \ No newline at end of file diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js new file mode 100644 index 0000000000..d131aa48ef --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js @@ -0,0 +1,137 @@ +'use strict'; +'require form'; +'require fs'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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).') + '
' + + _('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' + '
' + + _('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.') + '
' + + _('Destinations prefixed with a Registry host matching an entry in this table invoke its corresponding credentials.') + '
' + + _('The first match is used.') + '
' + + _('A Token is preferred over a Password.') + '
' + + _('Tokes and Passwords are not encrypted in the uci configuration.')); + ss = o.subsection; + ss.anonymous = true; + ss.nodescriptions = true; + ss.addremove = true; + ss.sortable = true; + + o = ss.option(form.Value, 'username', + _('User')); + o.placeholder = 'jbloggs'; + o.rmempty = false; + + o = ss.option(form.Value, 'password', + _('Password')); + o.placeholder = 'foobar'; + o.password = true; + + o = ss.option(form.Value, 'serveraddress', + _('Registry')); + o.datatype = 'or(hostname,hostport,ipaddr,ipaddrport)'; + o.placeholder = 'registry.foo.io[:443] | 192.0.2.1[:443]'; + o.rmempty = false; + o.value('container-registry.oracle.com'); + o.value('registry.docker.io'); + o.value('gcr.io'); + o.value('ghcr.io'); + o.value('quay.io'); + o.value('registry.gitlab.com'); + o.value('registry.redhat.io'); + + o = ss.option(form.Value, 'token', + _('Token')); + o.password = true; + + + return m.render(); + } +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js new file mode 100644 index 0000000000..879a0fb9ad --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js @@ -0,0 +1,1678 @@ +'use strict'; +'require form'; +'require fs'; +'require poll'; +'require uci'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + 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('
') : '-'; + }; + + // 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('
') : '-'; + }; + + // 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 = '' + _('Loading logs…') + ''; + + return dm2.container_logs({ id: container_id, query: { tail: lines, stdout: true, stderr: true } }) + .then((response) => { + if (response?.code >= 300) { + logsDiv.innerHTML = '' + _('Error loading logs:') + '
' + + (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 = '' + _('Error loading logs:') + '
' + errorMsg; + this.showNotification(_('Error'), errorMsg, 7000, 'error'); + return false; + }); + }, + + clearLogs() { + const logsDiv = document.getElementById('container-logs-text'); + if (logsDiv) { + logsDiv.innerHTML = ''; + } + }, + + connectConsole(container_id) { + const commandWrapper = document.getElementById('console-command'); + const selectedItem = commandWrapper?.querySelector('li[selected]'); + const command = selectedItem?.textContent?.trim() || '/bin/sh'; + const uid = document.getElementById('console-uid')?.value || ''; + const port = parseInt(document.getElementById('console-port')?.value || '7682'); + const view = this; + + const connectBtn = document.getElementById('console-connect-btn'); + if (connectBtn) connectBtn.disabled = true; + + // Call RPC to start ttyd + return dm2.container_ttyd_start({ + id: container_id, + cmd: command, + port: port, + uid: uid + }) + .then((response) => { + if (connectBtn) connectBtn.disabled = false; + + if (response?.code >= 300) { + const errorMsg = response?.body?.error || response?.body?.message || _('Failed to start console'); + view.showNotification(_('Error'), errorMsg, 7000, 'error'); + return false; + } + + // Show iframe and set source + const frameContainer = document.getElementById('console-frame-container'); + if (frameContainer) { + frameContainer.style.display = 'block'; + const ttydFrame = document.getElementById('ttyd-frame'); + if (ttydFrame) { + // Wait for ttyd to fully start and be ready for connections + // Use a retry pattern to handle timing variations + const waitForTtydReady = (attempt = 0, maxAttempts = 5, initialDelay = 500) => { + const delay = initialDelay + (attempt * 200); // Increase delay on retries + + setTimeout(() => { + const protocol = window.location.protocol === 'https:' ? 'https' : 'http'; + const ttydUrl = `${protocol}://${window.location.hostname}:${port}`; + + // Test connection with a simple HEAD request + fetch(ttydUrl, { method: 'HEAD', mode: 'no-cors' }) + .then(() => { + // Connection successful, load the iframe + ttydFrame.src = ttydUrl; + }) + .catch(() => { + // Connection failed, retry if we haven't exceeded max attempts + if (attempt < maxAttempts - 1) { + waitForTtydReady(attempt + 1, maxAttempts, initialDelay); + } else { + // Max retries exceeded, load iframe anyway + ttydFrame.src = ttydUrl; + view.showNotification(_('Warning'), _('TTYd may still be starting up'), 5000, 'warning'); + } + }); + }, delay); + }; + + waitForTtydReady(); + } + } + + view.showNotification(_('Success'), _('Console connected'), 3000, 'info'); + return true; + }) + .catch((err) => { + if (connectBtn) connectBtn.disabled = false; + const errorMsg = err?.message || String(err) || _('Failed to connect to console'); + view.showNotification(_('Error'), errorMsg, 7000, 'error'); + return false; + }); + }, + + disconnectConsole() { + const frameContainer = document.getElementById('console-frame-container'); + if (frameContainer) { + frameContainer.style.display = 'none'; + const ttydFrame = document.getElementById('ttyd-frame'); + if (ttydFrame) { + ttydFrame.src = ''; + } + } + + this.showNotification(_('Info'), _('Console disconnected'), 3000, 'info'); + }, + + executeAction(ev, action, container_id) { + ev?.preventDefault(); + + const actionMap = Object.freeze({ + 'start': _('Start'), + 'restart': _('Restart'), + 'stop': _('Stop'), + 'kill': _('Kill'), + 'pause': _('Pause'), + 'unpause': _('Unpause'), + 'remove': _('Remove'), + }); + + const actionLabel = actionMap[action] || action; + + // Confirm removal + if (action === 'remove') { + if (!confirm(_('Remove container?'))) { + return; + } + } + + const view = this; + const methodName = 'container_' + action; + const method = dm2[methodName]; + + if (!method) { + view.showNotification(_('Error'), _('Action unavailable: ') + action, 7000, 'error'); + return; + } + + view.executeDockerAction( + method, + { id: container_id, query: {} }, + actionLabel, + { + showOutput: false, + showSuccess: true, + successMessage: actionLabel + _(' completed'), + successDuration: 5000, + onSuccess: () => { + if (action === 'remove') { + setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000); + } else { + setTimeout(() => location.reload(), 1000); + } + } + } + ); + }, + + executeNetworkAction(action, networkID, networkName, this_container) { + const view = this; + + if (action === 'disconnect') { + if (!confirm(_('Disconnect network "%s" from container?').format(networkName))) { + return; + } + + view.executeDockerAction( + dm2.network_disconnect, + { + id: networkID, + body: { Container: view.containerId, Force: false } + }, + _('Disconnect network'), + { + showOutput: false, + showSuccess: true, + successMessage: _('Network disconnected'), + successDuration: 5000, + onSuccess: () => { + setTimeout(() => location.reload(), 1000); + } + } + ); + } else if (action === 'connect') { + const newNetworks = this.networks.filter(n => !Object.keys(this_container.NetworkSettings?.Networks || {}).includes(n.Name)); + + if (newNetworks.length === 0) { + view.showNotification(_('Info'), _('No additional networks available to connect'), 5000, 'info'); + return; + } + + // Create modal dialog for selecting network + const networkSelect = E('select', { + 'id': 'network-select', + 'class': 'cbi-input-select', + 'style': 'width:100%; margin-top:10px;' + }, newNetworks.map(n => { + const subnet0 = n?.IPAM?.Config?.[0]?.Subnet; + const subnet1 = n?.IPAM?.Config?.[1]?.Subnet; + return E('option', { 'value': n.Id }, [`${n.Name}${n?.Driver ? ' | ' + n.Driver : ''}${subnet0 ? ' | ' + subnet0 : ''}${subnet1 ? ' | ' + subnet1 : ''}`]); + })); + + const ip4Input = E('input', { + 'type': 'text', + 'id': 'network-ip', + 'class': 'cbi-input-text', + 'placeholder': 'e.g., 172.18.0.5', + 'style': 'width:100%; margin-top:5px;' + }); + + const ip6Input = E('input', { + 'type': 'text', + 'id': 'network-ip', + 'class': 'cbi-input-text', + 'placeholder': 'e.g., 2001:db8:1::1', + 'style': 'width:100%; margin-top:5px;' + }); + + const modalBody = E('div', { 'class': 'cbi-section' }, [ + E('p', {}, _('Select network to connect:')), + networkSelect, + E('label', { 'style': 'display:block; margin-top:10px;' }, _('IP Address (optional):')), + ip4Input, + ip6Input, + ]); + + ui.showModal(_('Connect Network'), [ + modalBody, + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': () => { + const selectedNetwork = networkSelect.value; + const ip4Address = ip4Input.value || ''; + const ip6Address = ip6Input.value || ''; + + if (!selectedNetwork) { + view.showNotification(_('Error'), [_('No network selected')], 5000, 'error'); + return; + } + + ui.hideModal(); + + const body = { Container: view.containerId }; + body.EndpointConfig = { IPAMConfig: { IPv4Address: ip4Address } }; //, IPv6Address: ip6Address || null + + view.executeDockerAction( + dm2.network_connect, + { id: selectedNetwork, body: body }, + _('Connect network'), + { + showOutput: false, + showSuccess: true, + successMessage: _('Network connected'), + successDuration: 5000, + onSuccess: () => { + setTimeout(() => location.reload(), 1000); + } + } + ); + } + }, _('Connect')) + ]) + ]); + } + }, + + // handleSave: null, + handleSaveApply: null, + handleReset: null, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js new file mode 100644 index 0000000000..11b7da2af1 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container_new.js @@ -0,0 +1,945 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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 is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M')); + o.rmempty = true; + o.placeholder = '128m'; + o.depends('advanced', 1); + o.write = function(section_id, value) { + if (!value || value == 0) return 0; + this.map.data.data[section_id].memory = view.parseMemory(value);; + return view.parseMemory(value); + }; + o.validate = function(section_id, value) { + if (!value) return true; + if (value > view.memory) return _(`Only ${view.memory} bytes available`); + return true; + }; + + o = s.option(form.Value, 'memory_reservation', _('Memory Reservation')); + o.depends('advanced', 1); + o.placeholder = '128m'; + o.cfgvalue = (sid, val) => { + const res = view.map.data.data[sid].memory_reservation; + return res ? '%1024.2m'.format(res) : 0; + }; + o.write = function(section_id, value) { + if (!value || value == 0) return 0; + this.map.data.data[section_id].memory_reservation = view.parseMemory(value);; + return view.parseMemory(value); + }; + + o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'), + _('Block IO weight (relative weight) accepts a weight value between 10 and 1000.')); + o.rmempty = true; + o.placeholder='500'; + o.datatype = 'and(uinteger,min(10),max(1000))'; + o.depends('advanced', 1); + + o = s.option(form.DynamicList, 'log_opt', _('Log driver options'), + _('The logging configuration for this container')); + o.rmempty = true; + o.placeholder='max-size=1m'; + o.depends('advanced', 1); + + + this.map = m; + + return m.render(); + + }, + + handleSave(ev) { + ev?.preventDefault(); + const view = this; // Capture the view context + const map = this.map; + if (!map) + return Promise.reject(new Error(_('Form is not ready yet.'))); + + const listToKv = view.listToKv; + + const toBool = (val) => (val === 1 || val === '1' || val === true); + const toInt = (val) => val ? Number.parseInt(val) : undefined; + const toFloat = (val) => val ? Number.parseFloat(val) : undefined; + + return map.parse() + .then(() => { + const get = (opt) => map.data.get('json', 'container', opt); + const name = get('name'); + const pull = toBool(get('pull')); + const network = get('network'); + const publish = get('publish'); + const command = get('command'); + const publish_all = toBool(get('publish_all')); + const device = get('device'); + const tmpfs = get('tmpfs'); + const sysctl = get('sysctl'); + const log_opt = get('log_opt'); + + const createBody = { + Hostname: get('hostname'), + User: get('user'), + AttachStdin: toBool(get('interactive')), + Tty: toBool(get('tty')), + OpenStdin: toBool(get('interactive')), + Env: get('env'), + Cmd: command ? command.split(' ') : null, + Image: get('image'), + HostConfig: { + CpuShares: toInt(get('cpu_shares')), + Memory: toInt(get('memory')), + MemoryReservation: toInt(get('memory_reservation')), + BlkioWeight: toInt(get('blkio_weight')), + CapAdd: get('cap_add'), + CpuPeriod: toInt(get('cpu_period')), + CpuQuota: toInt(get('cpu_quota')), + NanoCPUs: toFloat(get('cpus')) * (10 ** 9), + Devices: device ? device + .filter(d => d && typeof d === 'string' && d.trim().length > 0) + .map(d => { + const parts = d.split(':'); + return { + PathOnHost: parts[0], + PathInContainer: parts[1] || parts[0], + CgroupPermissions: parts[2] || 'rwm' + }; + }) : undefined, + LogConfig: log_opt ? { + Type: 'json-file', + Config: listToKv(log_opt) + } : undefined, + NetworkMode: network, + PortBindings: publish ? Object.fromEntries( + (Array.isArray(publish) ? publish : [publish]) + .filter(p => p && typeof p === 'string' && p.trim().length > 0) + .map(p => { + const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/); + if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]]; + return null; + }).filter(Boolean) + ) : undefined, + Mounts: undefined, + Links: get('link'), + Privileged: toBool(get('privileged')), + PublishAllPorts: toBool(get('publish_all')), + RestartPolicy: { Name: get('restart_policy') }, + Dns: get('dns'), + Tmpfs: tmpfs ? Object.fromEntries( + (Array.isArray(tmpfs) ? tmpfs : [tmpfs]) + .filter(t => t && typeof t === 'string' && t.trim().length > 0) + .map(t => { + const parts = t.split(':'); + return [parts[0], parts[1] || '']; + }) + ) : undefined, + Sysctls: sysctl ? listToKv(sysctl) : undefined, + }, + NetworkingConfig: { + EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } }, + } + }; + + // Parse volume entries and populate Mounts + const volumeEntries = get('volume') || []; + const volumeNames = new Set((view.volumes || []).map(v => v.Name)); + const volumeIds = new Set((view.volumes || []).map(v => v.Id)); + const mounts = []; + for (const entry of volumeEntries) { + let [source, target, options] = (typeof entry === 'string' ? entry : '')?.split(':')?.map(e => e && e.trim() || ''); + if (!options) options = ''; + + // Validate source and target are not empty + if (!source || !target) { + console.warn('Invalid volume entry (empty source or target):', entry); + continue; + } + + // Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind + let type = 'bind'; + if (source === '@image') { + type = 'image'; + } else if (source === '@tmpfs') { + type = 'tmpfs'; + } else if (volumeNames.has(source) || volumeIds.has(source)) { + type = 'volume'; + } + + const mount = { + Type: type, + Source: source, + Target: target, + ReadOnly: options.split(',').includes('ro') + }; + + // Add type-specific options + if (type === 'bind') { + const bindOptions = {}; + const propagation = options.split(',').find(opt => + ['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt) + ); + if (propagation) bindOptions.Propagation = propagation; + if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions; + } else if (type === 'volume') { + const volumeOptions = {}; + if (options.includes('nocopy')) volumeOptions.NoCopy = true; + if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions; + } else if (type === 'image') { + const imageOptions = {}; + const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath=')); + if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length); + if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions; + // Image source is implied by selected container image + mount.Source = createBody.Image; + } else if (type === 'tmpfs') { + const tmpfsOptions = {}; + const optsList = options.split(',').map(o => o.trim()).filter(Boolean); + for (const opt of optsList) { + if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length)); + else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length)); + else { + if (!tmpfsOptions.Options) tmpfsOptions.Options = []; + const kv = opt.split('='); + if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]); + else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]); + } + } + mount.Source = ''; + if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions; + } + + mounts.push(mount); + } + createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined; + + // Clean up undefined values + Object.keys(createBody.HostConfig).forEach(key => { + if (createBody.HostConfig[key] === undefined) + delete createBody.HostConfig[key]; + }); + + if (!name) + return Promise.reject(new Error(_('No name specified.'))); + + return { name, createBody }; + }) + .then(({ name, createBody }) => view.executeDockerAction( + dm2.container_create, + { query: { name: name }, body: createBody }, + _('Create container'), + { + showOutput: false, + showSuccess: false, + onSuccess: (response) => { + const isDuplicate = view.isDuplicate && view.duplicateContainer; + const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created'); + const msgText = isDuplicate ? + _('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) : + _('New container has been created.'); + + if (response?.body?.Warnings) { + view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning'); + } else { + view.showNotification(msgTitle, msgText, 4000, 'success'); + } + window.location.href = `${this.dockerman_url}/containers`; + } + } + )) + .catch((err) => { + view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error'); + return false; + }); + }, + + handleSaveApply: null, + handleReset: null, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js new file mode 100644 index 0000000000..301888b1a5 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js @@ -0,0 +1,360 @@ +'use strict'; +'require form'; +'require fs'; +'require poll'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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.') + '
' + + _('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('
') + : '', + }); + } + + return data; + }, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js new file mode 100644 index 0000000000..198cb8a29a --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js @@ -0,0 +1,327 @@ +'use strict'; +'require form'; +'require fs'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +LICENSE: GPLv2.0 +*/ + + +/* API v1.52 + +GET /events supports content-type negotiation and can produce either + application/x-ndjson (Newline delimited JSON object stream) or + application/json-seq (RFC7464). + +application/x-ndjson: + +{"some":"thing\n"} +{"some2":"thing2\n"} +... + +application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e + +␞{"some":"thing\n"}␊ +␞{"some2":"thing2\n"}␊ +... + +*/ + +return dm2.dv.extend({ + load() { + const now = Math.floor(Date.now() / 1000); + + return Promise.all([ + dm2.docker_events({ query: { since: `0`, until: `${now}` } }), + ]); + }, + + render([events]) { + if (events?.code !== 200) { + return E('div', {}, [ events?.body?.message ]); + } + + this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : ''; + const event_list = events?.body || []; + const view = this; + + const mainContainer = E('div', { 'class': 'cbi-map' }, [ + E('h2', {}, [_('Docker - Events')]) + ]); + + // Filters + const now = new Date(); + const nowIso = now.toISOString().slice(0, 16); + const filtersSection = E('div', { 'class': 'cbi-section' }, [ + E('div', { 'class': 'cbi-section-node' }, [ + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Type')), + E('div', { 'class': 'cbi-value-field' }, [ + E('select', { + 'id': 'event-type-filter', + 'class': 'cbi-input-select', + 'change': () => { + view.updateSubtypeFilter(this.value); + view.renderEventsTable(event_list); + } + }, [ + E('option', { 'value': '' }, _('All Types')), + ...Object.keys(dm2.Types).map(type => + E('option', { 'value': type }, `${dm2.Types[type].e} ${dm2.Types[type].i18n}`) + ) + ]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Subtype')), + E('div', { 'class': 'cbi-value-field' }, [ + E('select', { + 'id': 'event-subtype-filter', + 'class': 'cbi-input-select', + 'disabled': true, + 'change': () => { + view.renderEventsTable(event_list); + } + }, [ + E('option', { 'value': '' }, _('Select Type First')) + ]) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('From')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'id': 'event-from-date', + 'type': 'datetime-local', + 'value': '1970-01-01T00:00', + 'step': 60, + 'style': 'width: 180px;', + 'change': () => { view.renderEventsTable(event_list); } + }), + E('button', { + 'type': 'button', + 'class': 'cbi-button', + 'style': 'margin-left: 8px;', + 'click': () => { + const now = new Date(); + const iso = now.toISOString().slice(0,16); + document.getElementById('event-from-date').value = iso; + view.renderEventsTable(event_list); + } + }, _('Now')), + E('button', { + 'type': 'button', + 'class': 'cbi-button', + 'style': 'margin-left: 8px;', + 'click': () => { + const unixzero = new Date(0); + const iso = unixzero.toISOString().slice(0,16); + document.getElementById('event-from-date').value = iso; + view.renderEventsTable(event_list); + } + }, _('0')) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('To')), + E('div', { 'class': 'cbi-value-field' }, [ + E('input', { + 'id': 'event-to-date', + 'type': 'datetime-local', + 'value': nowIso, + 'step': 60, + 'style': 'width: 180px;', + 'change': () => { view.renderEventsTable(event_list); } + }), + E('button', { + 'type': 'button', + 'class': 'cbi-button', + 'style': 'margin-left: 8px;', + 'click': () => { + const now = new Date(); + const iso = now.toISOString().slice(0,16); + document.getElementById('event-to-date').value = iso; + view.renderEventsTable(event_list); + } + }, _('Now')) + ]) + ]) + ]) + ]); + mainContainer.appendChild(filtersSection); + + this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' }); + mainContainer.appendChild(this.tableSection); + + this.renderEventsTable(event_list); + + mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null)); + + return mainContainer; + }, + + renderEventsTable(event_list) { + const view = this; + + // Get filter values + const typeFilter = document.getElementById('event-type-filter')?.value || ''; + const subtypeFilter = document.getElementById('event-subtype-filter')?.value || ''; + + // Build filters object for docker_events API + const filters = {}; + if (typeFilter) { + filters.type = [typeFilter]; + } + if (subtypeFilter) { + filters.event = [subtypeFilter]; + } + + // Show loading indicator + this.tableSection.innerHTML = ''; + + // Query docker events with filters and date range + const fromInput = document.getElementById('event-from-date'); + const toInput = document.getElementById('event-to-date'); + let since = '0'; + let until = Math.floor(Date.now() / 1000).toString(); + if (fromInput && fromInput.value) { + const fromDate = new Date(fromInput.value); + if (!isNaN(fromDate.getTime())) { + since = Math.floor(fromDate.getTime() / 1000).toString(); + since = since < 0 ? 0 : since; + } + } + if (toInput && toInput.value) { + const toDate = new Date(toInput.value); + if (!isNaN(toDate.getTime())) { + const now = Date.now() / 1000; + until = Math.floor(toDate.getTime() / 1000).toString(); + until = until > now ? now : until; + } + } + const queryParams = { since, until }; + if (Object.keys(filters).length > 0) { + // docker pre v27: filters => docker *streams* events. v27, send events in body. + // Some older dockerd endpoints don't like encoded filter params, even if we can't stream. + queryParams.filters = JSON.stringify(filters); + } + + event_list = new Set(); + view.outputText = ''; + let eventsTable = null; + + function updateTable() { + const ev_array = Array.from(event_list.keys()); + const rows = ev_array.map(event => { + const type = event.Type; + const typeInfo = dm2.Types[type]; + const typeDisplay = typeInfo ? `${typeInfo.e} ${typeInfo.i18n}` : type; + const actionParts = event.Action?.split(':') || []; + const action = actionParts.length > 0 ? actionParts[0] : ''; + const action_sub = actionParts.length > 1 ? actionParts[1] : null; + const actionInfo = typeInfo?.sub?.[action]; + const actionDisplay = actionInfo ? `${actionInfo.e} ${actionInfo.i18n}${action_sub ? ':'+action_sub : ''}` : action; + return [ + view.buildTimeString(event.time), + typeDisplay, + actionDisplay, + view.objectToText(event.Actor), + event.scope || '' + ]; + }); + + const output = JSON.stringify(ev_array, null, 2); + view.outputText = output + '\n'; + view.insertOutput(view.outputText); + + if (!eventsTable) { + eventsTable = new L.ui.Table( + [_('Time'), _('Type'), _('Action'), _('Actor'), _('Scope')], + { id: 'events-table', style: 'width: 100%; table-layout: auto;' }, + E('em', [_('No events found')]) + ); + view.tableSection.innerHTML = ''; + view.tableSection.appendChild(eventsTable.render()); + } + eventsTable.update(rows); + } + + view.tableSection.innerHTML = ''; + + /* Partial transfers work but XHR times out waiting, even with xhr.timeout = 0 */ + // view.handleXHRTransfer({ + // q_params:{ query: queryParams }, + // commandCPath: '/docker/events', + // commandDPath: '/events', + // commandTitle: dm2.ActionTypes['prune'].i18n, + // showProgress: false, + // onUpdate: (msg) => { + // try { + // if(msg.error) + // ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error'); + + // event_list.add(msg); + // updateTable(); + + // const output = JSON.stringify(msg, null, 2) + '\n'; + // view.insertOutput(output); + // } catch { + + // } + // }, + // noFileUpload: true, + // }); + + view.executeDockerAction( + dm2.docker_events, + { query: queryParams }, + _('Load Events'), + { + showOutput: false, + showSuccess: false, + onSuccess: (response) => { + if (response.body) + event_list = Array.isArray(response.body) ? new Set(response.body) : new Set([response.body]); + updateTable(); + }, + onError: (err) => { + view.tableSection.innerHTML = ''; + view.tableSection.appendChild(E('em', { 'style': 'color: red;' }, _('Failed to load events: %s').format(err?.message || err))); + } + } + ); + }, + + updateSubtypeFilter(selectedType) { + const subtypeSelect = document.getElementById('event-subtype-filter'); + if (!subtypeSelect) return; + + // Clear existing options + subtypeSelect.innerHTML = ''; + + if (!selectedType || !dm2.Types[selectedType] || !dm2.Types[selectedType].sub) { + subtypeSelect.disabled = true; + subtypeSelect.appendChild(E('option', { 'value': '' }, _('Select Type First'))); + return; + } + + // Enable and populate with subtypes + subtypeSelect.disabled = false; + subtypeSelect.appendChild(E('option', { 'value': '' }, _('All Subtypes'))); + + const subtypes = dm2.Types[selectedType].sub; + for (const action in subtypes) { + subtypeSelect.appendChild( + E('option', { 'value': action }, `${subtypes[action].e} ${subtypes[action].i18n}`) + ); + } + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js new file mode 100644 index 0000000000..62704e999f --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/images.js @@ -0,0 +1,724 @@ +'use strict'; +'require form'; +'require fs'; +'require poll'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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 !== ':') { + 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] === ':')) + return ''; + + const tagLinks = tags.map(tag => { + if (tag === ':') + return E('span', {}, tag); + + /* last tag - don't link it - last tag removal == delete */ + if (tags.length === 1) + return tag; + + return E('a', { + 'href': '#', + 'title': _('Click to remove this tag'), + 'click': ui.createHandlerFn(view, (tag, imageId, ev) => { + + ev.preventDefault(); + ui.showModal(_('Remove tag'), [ + E('p', {}, _('Do you want to remove the tag "%s"?').format(tag)), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.hideModal + }, '↩'), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': ui.createHandlerFn(view, () => { + ui.hideModal(); + + return view.executeDockerAction( + dm2.image_remove, + { id: tag, query: { noprune: 'true' } }, + dm2.Types['image'].sub['untag'].i18n, + { + showOutput: true, + successMessage: _('Tag removed successfully'), + successDuration: 4000, + onSuccess: () => refresh(), + } + ); + }) + }, dm2.Types['image'].sub['untag'].e) + ]) + ]); + }, tag, image.Id) + }, tag); + }); + + // Join with commas and spaces + const content = []; + for (const [i, tag] of tagLinks.entries()) { + if (i > 0) content.push(', '); + content.push(tag); + } + + return E('span', {}, content); + }; + + o = imgSec.option(form.DummyValue, 'Containers', _('Containers')); + o.cfgvalue = function(sid) { + const imageId = this.map.data.data[sid].Id; + // Collect all matching container name links for this image + const anchors = container_list.reduce((acc, container) => { + if (container?.ImageID !== imageId) return acc; + for (const name of container?.Names || []) + acc.push(E('a', { href: `container/${container.Id}` }, [ name.substring(1) ])); + return acc; + }, []); + + // Interleave separators + if (!anchors.length) return E('div', {}); + const content = []; + for (let i = 0; i < anchors.length; i++) { + if (i) content.push(' | '); + content.push(anchors[i]); + } + + return E('div', {}, content); + }; + + o = imgSec.option(form.DummyValue, 'Size', _('Size')); + o.cfgvalue = function(sid) { + const s = this.map.data.data[sid].Size; + return '%1024mB'.format(s); + }; + imgSec.option(form.DummyValue, 'Created', _('Created')); + o = imgSec.option(form.DummyValue, '_id', _('ID')); + + /* Remember: we load a JSONMap - so uci config is non-existent for these + elements, so we must pull from this.map.data, otherwise o.load returns nothing */ + o.cfgvalue = function(sid) { + const image = this.map.data.data[sid]; + const shortId = image?._id || ''; + const fullId = image?.Id || ''; + + return E('a', { + 'href': '#', + 'style': 'font-family: monospace', + 'title': _('Click to add a new tag to this image'), + 'click': ui.createHandlerFn(view, function(imageId, ev) { + ev.preventDefault(); + + let repoInput, tagInput; + ui.showModal(_('New tag'), [ + E('p', {}, _('Enter a new tag for image %s:').format(imageId.slice(7, 19))), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Repository')), + E('div', { 'class': 'cbi-value-field' }, [ + repoInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': '[registry.io[:443]/]myrepo/myimage' + }) + ]) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Tag')), + E('div', { 'class': 'cbi-value-field' }, [ + tagInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'placeholder': 'latest', + 'value': 'latest' + }) + ]) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'cbi-button', + 'click': ui.hideModal + }, ['↩']), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(view, () => { + const repo = repoInput.value.trim(); + const tag = tagInput.value.trim() || 'latest'; + + if (!repo) { + ui.addTimeLimitedNotification(null, [_('Repository cannot be empty')], 3000, 'warning'); + return; + } + + ui.hideModal(); + + return view.executeDockerAction( + dm2.image_tag, + { id: imageId, query: { repo: repo, tag: tag } }, + dm2.Types['image'].sub['tag'].i18n, + { + showOutput: true, + successMessage: _('Tag added successfully'), + successDuration: 4000, + onSuccess: () => refresh(), + } + ); + }) + }, [dm2.Types['image'].sub['tag'].e]) + ]) + ]); + }, fullId) + }, shortId); + }; + + this.insertOutputFrame(s, m); + + poll.add(L.bind(() => { refresh(); }, this), 10); + + return m.render(); + }, + + handleFileUpload() { + // const uploadUrl = `?quiet=${encodeURIComponent('false')}`; + + return this.super('handleXHRTransfer', [{ + q_params: { query: { quiet: 'false' } }, + commandCPath: `/images/load`, + commandDPath: `/images/load`, + commandTitle: _('Uploading…'), + commandMessage: _('Uploading image…'), + successMessage: _('Image loaded successfully'), + defaultPath: '/tmp' + }]); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null, + + getImagesTable(images) { + const data = []; + + for (const image of images) { + // Just push plain data objects without UCI metadata + data.push({ + ...image, + _disable_delete: null, + _id: (image.Id || '').substring(7, 20), + Created: this.buildTimeString(image.Created) || '', + }); + } + + return data; + }, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js new file mode 100644 index 0000000000..29d832198e --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network.js @@ -0,0 +1,165 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +LICENSE: GPLv2.0 +*/ + + +return dm2.dv.extend({ + load() { + const requestPath = L.env.requestpath; + const netId = requestPath[requestPath.length-1] || ''; + this.networkId = netId; + + return Promise.all([ + dm2.network_inspect({ id: netId }), + dm2.container_list({query: {all: true}}), + ]); + }, + + render([network, containers]) { + if (network?.code !== 200) { + window.location.href = `${this.dockerman_url}/networks`; + return; + } + + const view = this; + const this_network = network.body || {}; + const container_list = Array.isArray(containers.body) ? containers.body : []; + + const m = new form.JSONMap({ + network: this_network, + Driver: this_network?.IPAM?.Driver, + Config: this_network?.IPAM?.Config, + Containers: Object.entries(this_network?.Containers || {}).map(([id, info]) => ({ id, ...info })), + _inspect: {}, + }, + _('Docker - Networks'), + _('This page displays all docker networks that have been created on the connected docker host.')); + m.submit = false; + m.reset = false; + + let s = m.section(form.NamedSection, 'network', _('Networks overview')); + s.anonymous = true; + s.addremove = false; + s.nodescriptions = true; + + let o, t, ss; + + // INFO TAB + t = s.tab('info', _('Info')); + + o = s.taboption('info', form.DummyValue, 'Name', _('Network Name')); + o = s.taboption('info', form.DummyValue, 'Id', _('ID')); + o = s.taboption('info', form.DummyValue, 'Created', _('Created')); + o = s.taboption('info', form.DummyValue, 'Scope', _('Scope')); + o = s.taboption('info', form.DummyValue, 'Driver', _('Driver')); + o = s.taboption('info', form.Flag, 'EnableIPv6', _('IPv6')); + o.readonly = true; + + o = s.taboption('info', form.Flag, 'Internal', _('Internal')); + o.readonly = true; + + o = s.taboption('info', form.Flag, 'Attachable', _('Attachable')); + o.readonly = true; + + o = s.taboption('info', form.Flag, 'Ingress', _('Ingress')); + o.readonly = true; + + o = s.taboption('info', form.DummyValue, 'ConfigFrom', _('ConfigFrom')); + o.cfgvalue = view.objectCfgValueTT; + + + o = s.taboption('info', form.Flag, 'ConfigOnly', _('Config Only')); + o.readonly = true; + o.cfgvalue = view.objectCfgValueTT; + + o = s.taboption('info', form.DummyValue, 'Containers', _('Containers')); + o.load = function(sid) { + return view.parseContainerLinksForNetwork(this_network, container_list); + }; + + o = s.taboption('info', form.DummyValue, 'Options', _('Options')); + o.cfgvalue = view.objectCfgValueTT; + + o = s.taboption('info', form.DummyValue, 'Labels', _('Labels')); + o.cfgvalue = view.objectCfgValueTT; + + // CONFIGS TAB + t = s.tab('detail', _('Detail')); + + o = s.taboption('detail', form.DummyValue, 'Driver', _('IPAM Driver')); + + o = s.taboption('detail', form.SectionValue, '_conf_', form.TableSection, 'Config', _('Network Configurations')); + ss = o.subsection; + ss.anonymous = true; + + ss.option(form.DummyValue, 'Subnet', _('Subnet')); + ss.option(form.DummyValue, 'Gateway', _('Gateway')); + + o = s.taboption('detail', form.SectionValue, '_cont_', form.TableSection, 'Containers', _('Containers')); + ss = o.subsection; + ss.anonymous = true; + + o = ss.option(form.DummyValue, 'Name', _('Name')); + o.cfgvalue = function(sid) { + const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option); + const containerId = container_list.find(c => c.Names.find(e => e.substring(1) === val)).Id; + return E('a', { + href: `${view.dockerman_url}/container/${containerId}`, + title: containerId, + style: 'white-space: nowrap;' + }, [val]); + }; + + ss.option(form.DummyValue, 'MacAddress', _('Mac Address')); + ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address')); + + // Show IPv6 column when at least one entry contains a non-empty IPv6Address + const _networkContainers = Object.values(this_network?.Containers || {}); + const _hasIPv6 = _networkContainers.some(c => c?.IPv6Address && String(c.IPv6Address).trim() !== ''); + if (_hasIPv6) { + ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address')); + } + + // INSPECT TAB + + t = s.tab('inspect', _('Inspect')); + + o = s.taboption('inspect', form.SectionValue, '__ins__', form.NamedSection, '_inspect', null); + ss = o.subsection; + ss.anonymous = true; + ss.nodescriptions = true; + + o = ss.option(form.Button, '_inspect_button', null); + o.inputtitle = `${dm2.ActionTypes['inspect'].i18n} ${dm2.ActionTypes['inspect'].e}`; + o.inputstyle = 'neutral'; + o.onclick = L.bind(function(section_id, ev) { + return dm2.network_inspect({ id: this_network.Id }).then((response) => { + const inspectField = document.getElementById('inspect-output-text'); + if (inspectField && response?.body) { + inspectField.textContent = JSON.stringify(response.body, null, 2); + } + }); + }, this); + + o = s.taboption('inspect', form.SectionValue, '__insoutput__', form.NamedSection, null, null); + o.render = L.bind(() => { + return this.insertOutputFrame(null, null); + }, this); + + return m.render(); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js new file mode 100644 index 0000000000..a008f5e64d --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/network_new.js @@ -0,0 +1,257 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require tools.widgets as widgets'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +LICENSE: GPLv2.0 +*/ + + +return dm2.dv.extend({ + load() { + return Promise.all([ + + ]); + }, + + render([]) { + + // stuff JSONMap with {network: {}} to prime it with a new empty entry + const m = new form.JSONMap({network: {}}, _('Docker - New Network')); + m.submit = true; + m.reset = true; + + let s = m.section(form.NamedSection, 'network', _('Create new docker network')); + s.anonymous = true; + s.nodescriptions = true; + s.addremove = false; + + let o; + + o = s.option(form.Value, 'name', _('Network Name'), + _('Name of the network that can be selected during container creation')); + o.rmempty = true; + + o = s.option(form.ListValue, 'driver', _('Driver')); + o.rmempty = true; + o.value('bridge', _('Bridge device')); + o.value('macvlan', _('MAC VLAN')); + o.value('ipvlan', _('IP VLAN')); + o.value('overlay', _('Overlay network')); + + o = s.option(widgets.DeviceSelect, 'parent', _('Base device')); + o.rmempty = true; + o.create = false + o.noaliases = true; + o.nocreate = true; + o.depends('driver', 'macvlan'); + + o = s.option(form.ListValue, 'macvlan_mode', _('Mode')); + o.rmempty = true; + o.depends('driver', 'macvlan'); + o.default = 'bridge'; + o.value('bridge', _('Bridge (Support direct communication between MAC VLANs)')); + o.value('private', _('Private (Prevent communication between MAC VLANs)')); + o.value('vepa', _('VEPA (Virtual Ethernet Port Aggregator)')); + o.value('passthru', _('Pass-through (Mirror physical device to single MAC VLAN)')); + + o = s.option(form.ListValue, 'ipvlan_mode', _('Ipvlan Mode')); + o.rmempty = true; + o.depends('driver', 'ipvlan'); + o.default='l3'; + o.value('l2', _('L2 bridge')); + o.value('l3', _('L3 bridge')); + + o = s.option(form.Flag, 'ingress', + _('Ingress'), + _('Ingress network is the network which provides the routing-mesh in swarm mode')); + o.rmempty = true; + o.disabled = 0; + o.enabled = 1; + o.default = 0; + o.depends('driver', 'overlay'); + + o = s.option(form.DynamicList, 'options', _('Options')); + o.rmempty = true; + o.placeholder='com.docker.network.driver.mtu=1500'; + + o = s.option(form.DynamicList, 'labels', _('Labels')); + o.rmempty = true; + o.placeholder='foo=bar'; + + o = s.option(form.Flag, 'internal', _('Internal'), _('Restrict external access to the network')); + o.rmempty = true; + o.depends('driver', 'overlay'); + o.disabled = 0; + o.enabled = 1; + o.default = o.disabled; + + // if nixio.fs.access('/etc/config/network') and nixio.fs.access('/etc/config/firewall')then + // o = s.option(form.Flag, 'op_macvlan', _('Create macvlan interface'), _('Auto create macvlan interface in Openwrt')) + // o.depends('driver', 'macvlan') + // o.disabled = 0 + // o.enabled = 1 + // o.default = 1 + // end + + o = s.option(form.Value, 'subnet', _('Subnet')); + o.rmempty = true; + o.placeholder = '10.1.0.0/16'; + o.datatype = 'ip4addr'; + + o = s.option(form.Value, 'gateway', _('Gateway')); + o.rmempty = true; + o.placeholder = '10.1.1.1'; + o.datatype = 'ip4addr'; + + o = s.option(form.Value, 'ip_range', _('IP range')); + o.rmempty = true; + o.placeholder='10.1.1.0/24'; + o.datatype = 'ip4addr'; + + o = s.option(form.DynamicList, 'aux_address', _('Exclude IPs')); + o.rmempty = true; + o.placeholder = 'my-route=10.1.1.1'; + + o = s.option(form.Flag, 'ipv6', _('Enable IPv6')); + o.rmempty = true; + o.disabled = 0; + o.enabled = 1; + o.default = o.disabled; + + o = s.option(form.Value, 'subnet6', _('IPv6 Subnet')); + o.rmempty = true; + o.placeholder='fe80::/10' + o.datatype = 'ip6addr'; + o.depends('ipv6', 1); + + o = s.option(form.Value, 'gateway6', _('IPv6 Gateway')); + o.rmempty = true; + o.placeholder='fe80::1'; + o.datatype = 'ip6addr'; + o.depends('ipv6', 1); + + this.map = m; + + return m.render(); + + }, + + handleSave(ev) { + ev?.preventDefault(); + + const view = this; + + const map = this.map; + if (!map) + return Promise.reject(new Error(_('Form is not ready yet.'))); + + const listToKv = view.listToKv; + + const toBool = (val) => (val === 1 || val === '1' || val === true); + + return map.parse() + .then(() => { + const get = (opt) => map.data.get('json', 'network', opt); + const name = get('name'); + const driver = get('driver'); + const internal = toBool(get('internal')); + const ingress = toBool(get('ingress')); + const ipv6 = toBool(get('ipv6')); + const subnet = get('subnet'); + const gateway = get('gateway'); + const ipRange = get('ip_range'); + const auxAddress = listToKv(get('aux_address')); + const optionsList = listToKv(get('options')); + const labelsList = listToKv(get('labels')); + const subnet6 = get('subnet6'); + const gateway6 = get('gateway6'); + + const createBody = { + Name: name, + Driver: driver, + EnableIPv6: ipv6, + IPAM: { + Driver: 'default' + }, + Internal: internal, + Labels: labelsList, + }; + + if (subnet || gateway || ipRange + || (auxAddress && typeof auxAddress === 'object' && Object.keys(auxAddress).length)) { + createBody.IPAM.Config = [{ + Subnet: subnet, + Gateway: gateway, + IPRange: ipRange, + AuxAddress: auxAddress, + AuxiliaryAddresses: auxAddress, + }]; + } + + if (driver === 'macvlan') { + createBody.Options = { + macvlan_mode: get('macvlan_mode'), + parent: get('parent'), + }; + } + else if (driver === 'ipvlan') { + createBody.Options = { + ipvlan_mode: get('ipvlan_mode'), + }; + } + else if (driver === 'overlay') { + createBody.Ingress = ingress; + } + + if (ipv6 && (subnet6 || gateway6)) { + createBody.IPAM.Config = createBody.IPAM.Config || []; + createBody.IPAM.Config.push({ + Subnet: subnet6, + Gateway: gateway6, + }); + } + + if (optionsList && typeof optionsList === 'object' && Object.keys(optionsList).length) { + createBody.Options = Object.assign(createBody.Options || {}, optionsList); + } + + if (labelsList && typeof labelsList === 'object' && Object.keys(labelsList).length) { + createBody.Labels = Object.assign(createBody.Labels || {}, labelsList); + } + + return createBody; + }) + .then((createBody) => view.executeDockerAction( + dm2.network_create, + { body: createBody }, + _('Create network'), + { + showOutput: false, + showSuccess: false, + onSuccess: (response) => { + if (response?.body?.Warning) { + view.showNotification(_('Network created with warning'), response.body.Warning, 5000, 'warning'); + } else { + view.showNotification(_('Network created'), _('OK'), 4000, 'success'); + } + window.location.href = `${this.dockerman_url}/networks`; + } + } + )) + .catch((err) => { + view.showNotification(_('Create network failed'), err?.message || String(err), 7000, 'error'); + return false; + }); + }, + + handleSaveApply: null, + handleReset: null, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js new file mode 100644 index 0000000000..2bff066693 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/networks.js @@ -0,0 +1,236 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +LICENSE: GPLv2.0 +*/ + + +return dm2.dv.extend({ + load() { + return Promise.all([ + dm2.network_list(), + dm2.container_list({query: {all: true}}), + ]); + }, + + render([networks, containers]) { + if (networks?.code !== 200) { + return E('div', {}, [ networks?.body?.message ]); + } + + let network_list = this.getNetworksTable(networks.body, containers.body); + // let container_list = containers.body; + const view = this; // Capture the view context + + + let pollPending = null; + let netSec = null; + + const refresh = () => { + if (pollPending) return pollPending; + pollPending = view.load().then(([networks2, containers2]) => { + network_list = view.getNetworksTable(networks2.body, containers2.body); + // container_list = containers2.body; + m.data = new m.data.constructor({network: network_list, prune: {}}); + + if (netSec) { + netSec.footer = [ + `${_('Total')} ${network_list.length}`, + ]; + } + + return m.render(); + }).catch((err) => { console.warn(err) }).finally(() => { pollPending = null }); + return pollPending; + }; + + + let s, o; + const m = new form.JSONMap({network: network_list, prune: {}}, + _('Docker - Networks'), + _('This page displays all docker networks that have been created on the connected docker host.')); + m.submit = false; + m.reset = false; + + s = m.section(form.TableSection, 'prune', _('Networks overview'), null); + s.addremove = false; + s.anonymous = true; + + const prune = s.option(form.Button, '_prune', null); + prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`; + prune.inputstyle = 'negative'; + prune.onclick = L.bind(function(section_id, ev) { + + return this.super('handleXHRTransfer', [{ + q_params: { }, + commandCPath: '/networks/prune', + commandDPath: '/networks/prune', + commandTitle: dm2.ActionTypes['prune'].i18n, + onUpdate: (msg) => { + try { + if(msg.error) + ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error'); + + const output = JSON.stringify(msg, null, 2) + '\n'; + view.insertOutput(output); + } catch { + + } + }, + noFileUpload: true, + }]); + + // return view.executeDockerAction( + // dm2.network_prune, + // { query: { filters: '' } }, + // dm2.ActionTypes['prune'].i18n, + // { + // showOutput: true, + // successMessage: _('started/completed'), + // onSuccess: () => { + // setTimeout(() => window.location.href = `${this.dockerman_url}/networks`, 1000); + // } + // } + // ); + }, this); + + netSec = m.section(form.TableSection, 'network'); + netSec.anonymous = true; + netSec.nodescriptions = true; + netSec.addremove = true; + netSec.sortable = true; + netSec.filterrow = true; + netSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`; + netSec.footer = [ + `${_('Total')} ${network_list.length}`, + ]; + + netSec.handleAdd = function(section_id, ev) { + window.location.href = `${view.dockerman_url}/network_new`; + }; + + netSec.handleRemove = function(section_id, force, ev) { + const network = network_list.find(net => net['.name'] === section_id); + if (!network?.Id) return false; + + return view.executeDockerAction( + dm2.network_remove, + { id: network.Id }, + dm2.ActionTypes['remove'].i18n, + { + showOutput: true, + onSuccess: () => { + return refresh(); + } + } + ); + }; + + netSec.handleInspect = function(section_id, ev) { + const network = network_list.find(net => net['.name'] === section_id); + if (!network?.Id) return false; + + return view.executeDockerAction( + dm2.network_inspect, + { id: network.Id }, + dm2.ActionTypes['inspect'].i18n, + { showOutput: true, showSuccess: false } + ); + }; + + netSec.renderRowActions = function (section_id) { + const network = network_list.find(net => net['.name'] === section_id); + const btns = [ + E('button', { + 'class': 'cbi-button view', + 'title': dm2.ActionTypes['inspect'].i18n, + 'click': ui.createHandlerFn(this, this.handleInspect, section_id), + }, [dm2.ActionTypes['inspect'].e]), + + E('div', { + 'style': 'width: 20px', + // Some safety margin for mis-clicks + }, [' ']), + + E('button', { + 'class': 'cbi-button cbi-button-negative remove', + 'title': dm2.ActionTypes['remove'].i18n, + 'click': ui.createHandlerFn(this, this.handleRemove, section_id, false), + 'disabled': network?._disable_delete, + }, dm2.ActionTypes['remove'].e), + E('button', { + 'class': 'cbi-button cbi-button-negative important remove', + 'title': dm2.ActionTypes['force_remove'].i18n, + 'click': ui.createHandlerFn(this, this.handleRemove, section_id, true), + 'disabled': network?._disable_delete, + }, dm2.ActionTypes['force_remove'].e), + ]; + return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns)); + }; + + o = netSec.option(form.DummyValue, '_shortId', _('ID')); + + o = netSec.option(form.DummyValue, 'Name', _('Name')); + + o = netSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️'); + o.cfgvalue = view.objectCfgValueTT; + + o = netSec.option(form.DummyValue, '_container', _('Containers')); + + o = netSec.option(form.DummyValue, 'Driver', _('Driver')); + + o = netSec.option(form.DummyValue, '_interface', _('Parent Interface')); + + o = netSec.option(form.DummyValue, '_subnet', _('Subnet')); + + o = netSec.option(form.DummyValue, '_gateway', _('Gateway')); + + this.insertOutputFrame(s, m); + + return m.render(); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null, + + getNetworksTable(networks, containers) { + const data = []; + + for (const [i, net] of (networks || []).entries()) { + const n = net.Name; + const _shortId = (net.Id || '').substring(0, 12); + const shortLink = E('a', { + 'href': `${view.dockerman_url}/network/${net.Id}`, + 'style': 'font-family: monospace;', + 'title': _('Click to view this network'), + }, [_shortId]); + + // Just push plain data objects without UCI metadata + const configs = Array.isArray(net?.IPAM?.Config) ? net.IPAM.Config : []; + data.push({ + ...net, + _gateway: configs.map(o => o.Gateway).filter(o => o).join(', ') || '', + _subnet: configs.map(o => o.Subnet).filter(o => o).join(', ') || '', + _disable_delete: ( n === 'bridge' || n === 'none' || n === 'host' ) ? true : null, + _shortId: shortLink, + _container: this.parseContainerLinksForNetwork(net, containers), + _interface: (net.Driver === 'bridge') + ? net.Options?.['com.docker.network.bridge.name'] || '' + : (net.Driver === 'macvlan') + ? net?.Options?.parent + : '', + }); + } + + return data; + }, + +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js new file mode 100644 index 0000000000..66ec57b333 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/overview.js @@ -0,0 +1,280 @@ +'use strict'; +'require form'; +'require fs'; +'require uci'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +LICENSE: GPLv2.0 +*/ + +/** + * Returns a Set of image IDs in use by containers + * @param {Array} containers - Array of container objects + * @returns {Set} 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} 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} Set of volume names or mountpoints + */ +function getVolumesInUseByContainers(containers) { + const inUse = new Set(); + for (const c of containers || []) { + const mounts = c.Mounts; + if (Array.isArray(mounts)) { + for (const m of mounts) { + if (m.Type === 'volume' && m.Name) inUse.add(m.Name); + } + } + } + return inUse; +} + + +return dm2.dv.extend({ + load() { + const now = Math.floor(Date.now() / 1000); + + return Promise.all([ + dm2.docker_version(), + dm2.docker_info(), + // dm2.docker_df(), // takes > 20 seconds on large docker environments + dm2.container_list().then(r => r.body || []), + dm2.image_list().then(r => r.body || []), + dm2.network_list().then(r => r.body || []), + dm2.volume_list().then(r => r.body || []), + dm2.callMountPoints(), + ]); + }, + + handleAction(name, action, ev) { + return dm2.callRcInit(name, action).then(function(ret) { + if (ret) + throw _('Command failed'); + + return true; + }).catch(function(e) { + L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning'); + }); + }, + + render([version_response, + info_response, + // df_response, + container_list, + image_list, + network_list, + volume_list, + mounts, + ]) { + const version_headers = []; + const version_body = []; + const info_body = []; + // const df_body = []; + const docker_ep = uci.get('dockerd', 'globals', 'hosts'); + let isLocal = false; + if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1) + isLocal = true; + + if (info_response?.code !== 200) { + return E('div', {}, [ info_response?.body?.message ]); + } + + this.parseHeaders(version_response.headers, version_headers); + this.parseBody(version_response.body, version_body); + this.parseBody(info_response.body, info_body); + // this.parseBody(df_response.body, df_body); + const view = this; + const info = info_response.body; + + this.concount = info?.Containers || 0; + this.conactivecount = info?.ContainersRunning || 0; + + /* Because the df function that reconciles Volumes, Networks and Containers + is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */ + this.imgcount = image_list.length; + this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0; + + this.netcount = network_list.length; + this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0; + + this.volcount = volume_list?.Volumes?.length; + this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0; + + this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0; + if (isLocal && this.freespace !== 0) + this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')'; + + const mainContainer = E('div', { 'class': 'cbi-map' }); + + // Add heading and description first + mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')])); + mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [ + _('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'), + E('br'), + E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README']) + ])); + + if (isLocal) + mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [ + E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [ + E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')), + E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')), + ]) + ])); + + // Create the info table + const summaryTable = new L.ui.Table( + [_('Info'), ''], + { id: 'containers-table', style: 'width: 100%; table-layout: auto;' }, + [] + ); + + summaryTable.update([ + [ _('Docker Version'), version_response.body.Version ], + [ _('Api Version'), version_response.body.ApiVersion ], + [ _('CPUs'), info_response.body.NCPU ], + [ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ], + [ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ], + [ _('Index Server Address'), info_response.body.IndexServerAddress ], + [ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ], + ]); + + // Wrap the table in a cbi-section + mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [ + summaryTable.render() + ])); + + + // Create a container div with grid layout for the status badges + let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [ + this.overviewBadge(`${this.dockerman_url}/containers`, + E('img', { + src: L.resource('dockerman/containers.svg'), + style: 'width: 80px; height: 80px;' + }, []), + _('Containers'), + _('Total: '), + view.concount, + _('Running: '), + view.conactivecount), + this.overviewBadge(`${this.dockerman_url}/images`, + E('img', { + src: L.resource('dockerman/images.svg'), + style: 'width: 80px; height: 80px;' + }, []), + _('Images'), + _('Total: '), + view.imgcount, + view.imgactivecount ? _('In Use: ') : '', + view.imgactivecount ? view.imgactivecount : ''), + this.overviewBadge(`${this.dockerman_url}/networks`, + E('img', { + src: L.resource('dockerman/networks.svg'), + style: 'width: 80px; height: 80px;' + }, []), + _('Networks'), + _('Total: '), + view.netcount, + view.netactivecount ? _('In Use: ') : '', + view.netactivecount ? view.netactivecount : ''), + this.overviewBadge(`${this.dockerman_url}/volumes`, + E('img', { + src: L.resource('dockerman/volumes.svg'), + style: 'width: 80px; height: 80px;' + }, []), + _('Volumes'), + _('Total: '), + view.volcount, + view.volactivecount ? _('In Use: ') : '', + view.volactivecount ? view.volactivecount : ''), + ]); + + // Add badges section + mainContainer.appendChild(statusContainer); + + const m = new form.JSONMap({ + // df: df_body, + vb: version_body, + ib: info_body + }); + m.readonly = true; + m.tabbed = false; + + let s, o, v; + + // Add Version and Environment tables + s = m.section(form.TableSection, 'vb', _('Version')); + s.anonymous = true; + + o = s.option(form.DummyValue, 'entry', _('Name')); + o = s.option(form.DummyValue, 'value', _('Value')); + + s = m.section(form.TableSection, 'ib', _('Environment')); + s.anonymous = true; + s.filterrow = true; + + o = s.option(form.DummyValue, 'entry', _('Entry')); + o = s.option(form.DummyValue, 'value', _('Value')); + + // Render the form sections and append them + return m.render() + .then(fe => { + mainContainer.appendChild(fe); + return mainContainer; + }); + }, + + overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) { + return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [ + E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [ + E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [ + resource_div, + ]), + E('div', { style: 'flex: 1;' }, [ + E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption), + E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [ + E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)]) + ]), + E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [ + E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)]) + ]) + ]) + ]) + ]) + + } +}); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js new file mode 100644 index 0000000000..6f31abc510 --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/volumes.js @@ -0,0 +1,332 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require dockerman.common as dm2'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +Based on Docker Lua by lisaac +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//_data + const match = mp.match(/^(.*\/volumes\/)([^/]+)(\/.*)?$/); + if (match && match[2].length > 36) { + // Show the first 12 characters of the ID portion + return match[1] + match[2].substring(0, 12) + '...' + (match[3] || ''); + } + return mp; + }; + + o = volSec.option(form.DummyValue, 'CreatedAt', _('Created')); + + this.insertOutputFrame(s, m); + + return m.render(); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null, + + getVolumesTable(volumes) { + const data = []; + + for (const [i, vol] of (volumes?.Volumes || []).entries()) { + const n = vol.Name; + const labels = vol?.Labels || {}; + + // Just push plain data objects without UCI metadata + data.push({ + ...vol, + Labels: labels, + _name: (vol.Name || '').substring(0, 12), + Containers: vol.Containers || '', + }); + } + + return data; + }, + + parseContainerLinksForVolume(volume, containers) { + const links = []; + for (const cont of containers || []) { + const mounts = cont?.Mounts || []; + const usesVolume = mounts.some(m => { + if (m?.Type !== 'volume' && m?.Type !== 'bind') return false; + const byName = !!volume?.Name && m?.Name === volume.Name; + const bySource = !!volume?.Mountpoint && (m?.Source === volume.Mountpoint || (m?.Source || '').startsWith(volume.Mountpoint)); + return byName || bySource; + }); + + if (usesVolume) { + const containerName = cont?.Names?.[0]?.replace(/^\//, '') || (cont?.Id || '').substring(0, 12); + const containerId = cont?.Id; + links.push(E('a', { + href: `${this.dockerman_url}/container/${containerId}`, + title: containerId, + style: 'white-space: nowrap;' + }, [containerName])); + } + } + + if (!links.length) + return '-'; + + const out = []; + for (let i = 0; i < links.length; i++) { + out.push(links[i]); + if (i < links.length - 1) + out.push(' | '); + } + + return E('div', {}, out); + }, + +}); diff --git a/applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json b/applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json new file mode 100644 index 0000000000..0cc3995d15 --- /dev/null +++ b/applications/luci-app-dockerman/root/usr/share/luci/menu.d/luci-app-dockerman.json @@ -0,0 +1,318 @@ +{ + "admin/services/dockerman": { + "title": "Dockerman JS", + "order": "60", + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-dockerman" ], + "fs": { + "/etc/init.d/dockerd": "executable", + "/usr/bin/dockerd": "executable" + }, + "uci": { "dockerd": true } + } + }, + + "admin/services/dockerman/overview": { + "title": "Overview", + "order": 1, + "action": { + "type": "view", + "path": "dockerman/overview" + } + }, + + "admin/services/dockerman/configuration": { + "title": "Configuration", + "order": 2, + "action": { + "type": "view", + "path": "dockerman/configuration" + } + }, + + "admin/services/dockerman/container/archive/*": { + "action": { + "type": "alias", + "path": "admin/services/dockerman/containers" + } + }, + + "admin/services/dockerman/container/archive/put/*": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "container_put_archive" + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/container/archive/get/*": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "container_get_archive" + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/container/export/*": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "container_export" + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/container/*": { + "title_hide": "Container", + "action": { + "type": "view", + "path": "dockerman/container" + } + }, + + "admin/services/dockerman/containers": { + "title": "Containers", + "order": 3, + "action": { + "type": "view", + "path": "dockerman/containers" + } + }, + + "admin/services/dockerman/containers/prune": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "containers_prune", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/container": { + "action": { + "type": "alias", + "path": "admin/services/dockerman/containers" + } + }, + + "admin/services/dockerman/container_new": { + "title_hide": "Container", + "action": { + "type": "view", + "path": "dockerman/container_new" + } + }, + + "admin/services/dockerman/images/build": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_build", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images/build/prune": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_build_prune", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images/get/*": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_get" + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images/load": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_load", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images/prune": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "images_prune", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images": { + "title": "Images", + "order": 4, + "action": { + "type": "view", + "path": "dockerman/images" + } + }, + + "admin/services/dockerman/images/create": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_create", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/images/push/*": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "image_push", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/image": { + "action": { + "type": "alias", + "path": "admin/services/dockerman/images" + } + }, + + "admin/services/dockerman/network/*": { + "title_hide": "Network", + "action": { + "type": "view", + "path": "dockerman/network" + } + }, + + "admin/services/dockerman/networks": { + "title": "Networks", + "order": 5, + "action": { + "type": "view", + "path": "dockerman/networks" + } + }, + + "admin/services/dockerman/networks/prune": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "networks_prune", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/network_new": { + "title_hide": "Network", + "action": { + "type": "view", + "path": "dockerman/network_new" + } + }, + + "admin/services/dockerman/network": { + "action": { + "type": "alias", + "path": "admin/services/dockerman/networks" + } + }, + + "admin/services/dockerman/volumes": { + "title": "Volumes", + "order": 6, + "action": { + "type": "view", + "path": "dockerman/volumes" + } + }, + + "admin/services/dockerman/volumes/prune": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "volumes_prune", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/docker/events": { + "action": { + "type": "function", + "module": "luci.controller.docker", + "function": "docker_events", + "post": true + }, + "auth": { + "methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ], + "login": true + } + }, + + "admin/services/dockerman/events": { + "title": "Events", + "order": 7, + "action": { + "type": "view", + "path": "dockerman/events" + } + } +} \ No newline at end of file diff --git a/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json b/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json index 353ccaa16c..3e277d3f82 100644 --- a/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json +++ b/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json @@ -2,9 +2,25 @@ "luci-app-dockerman": { "description": "Grant UCI access for luci-app-dockerman", "read": { + "file_comment": "so directory picker can browse the FS", + "file": { + "/*": ["list", "read"] + }, + "ubus": { + "docker": [ "*" ], + "docker.*": [ "*" ], + "file": [ "*" ], + "luci": [ "getMountPoints" ], + "rc": [ "init" ] + }, "uci": [ "dockerd" ] }, "write": { + "ubus": { + "docker": [ "*" ], + "docker.*": [ "*" ], + "rc": [ "init" ] + }, "uci": [ "dockerd" ] } } diff --git a/applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc b/applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc new file mode 100755 index 0000000000..5fb85394d7 --- /dev/null +++ b/applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc @@ -0,0 +1,507 @@ +#!/usr/bin/env ucode + +// Copyright 2025 Paul Donald / luci-lib-docker-js +// Licensed to the public under the Apache License 2.0. +// Built against the docker v1.47 API + + +'use strict'; + +import * as http from 'luci.http'; +import * as fs from 'fs'; +import * as socket from 'socket'; +import { cursor } from 'uci'; +import * as ds from 'luci.docker_socket'; + +// const cmdline = fs.readfile('/proc/self/cmdline'); +// const args = split(cmdline, '\0'); +const caller = trim(fs.readfile('/proc/self/comm')); + +const BLOCKSIZE = 8192; +const POLL_TIMEOUT = 8000; // default; can be overridden per request +// const API_VER = '/v1.47'; +const PROTOCOL = 'HTTP/1.1'; +const CLIENT_VER = '1'; + +function merge(a, b) { + let c = {}; + for (let k, v in a) + c[k] = v; + for (let k, v in b) + c[k] = v; + return c; +}; + +function chunked_body_reader(sock, initial_buffer) { + let state = 0, chunklen = 0, buffer = initial_buffer || ''; + + function poll_and_recv() { + let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]); + if (!ready || !length(ready)) return null; + let data = sock.recv(BLOCKSIZE); + if (!data) return null; + buffer += data; + return true; + } + + return () => { + while (true) { + if (state === 0) { + let m = match(buffer, /^([0-9a-fA-F]+)\r\n/); + if (!m || length(m) < 2) { + if (!poll_and_recv()) return null; + continue; + } + chunklen = int(m[1], 16); + buffer = substr(buffer, length(m[0])); + if (chunklen === 0) return null; + state = 1; + } + if (state === 1 && length(buffer) >= chunklen + 2) { + let chunk = substr(buffer, 0, chunklen); + buffer = substr(buffer, chunklen + 2); + state = 0; + return chunk; + } else { + if (!poll_and_recv()) return null; + continue; + } + } + }; +}; + +function read_http_headers(response_headers, response) { + const lines = split(response, /\r?\n/); + + for (let l in lines) { + let kv = match(l, /([^:]+):\s*(.*)/); + if (kv && length(kv) === 3) + response_headers[lc(kv[1])] = kv[2]; + } + + return response_headers; +}; + +function get_api_ver() { + + const ctx = cursor(); + const version = ctx.get('dockerd', 'globals', 'api_version') || ''; + const version_str = version ? `/${version}` : ''; + ctx.unload(); + + return version_str; +}; + +function coerce_values_to_string(obj) { + for (let k, v in obj) { + v = `${v}`; + obj[k]=v; + } + return obj; +}; + +function call_docker(method, path, options) { + options = options || {}; + const headers = options.headers || {}; + let payload = options.payload || null; + + /* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g. + '127.0.0.1:2375'. + We use get_socket_dest_compat() which builds the SockAddress manually to + avoid this. + + Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without + --tls* options. + + A solution is a reverse proxy or ssh port forwarding to a remote host that + uses the unix socket, and you still connect to a 'local port', or socket: + ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client) + or (dropbear) + socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock + ssh -L 2375:localhost:12375 user@remote-host + */ + + + /* works on ucode 2025-12-01 */ + const sock_dest = ds.get_socket_dest_compat(); + const sock = socket.create(sock_dest.family, socket.SOCK_STREAM); + + /* works on ucode 2026-01-16 */ + // const sock_dest = ds.get_socket_dest(); + // const sock_addr = socket.sockaddr(sock_dest); + // const sock = socket.create(sock_addr.family, socket.SOCK_STREAM); + + if (caller != 'rpcd') { + print('sock_dest:', sock_dest, '\n'); + // print('sock_addr:', sock_addr, '\n'); + } + if (!sock) { + return { + code: 500, + headers: {}, + body: { message: "Failed to create socket" } + }; + } + + let conn_result = sock.connect(sock_dest); + let err_msg = `Failed to connect to docker host at ${sock_dest}`; + if (!conn_result) { + sock.close(); + return { + code: 500, + headers: {}, + body: { message: err_msg} + }; + } + + if (caller != 'rpcd') + print("query: ", options.query, '\n'); + + const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : ''; + const url = path + query; + + const req_headers = [ + `${method} ${get_api_ver()}${url} ${PROTOCOL}`, + `Host: luci-host`, + `User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`, + `Connection: close` + ]; + + if (payload) { + if (type(payload) === 'object') { + payload = sprintf('%J', payload); + headers['Content-Type'] = 'application/json'; + } + headers['Content-Length'] = '' + length(payload); + } + + for (let k, v in headers) + push(req_headers, `${k}: ${v}`); + + push(req_headers, '', ''); + + if (caller != 'rpcd') + print(join('\r\n', req_headers), "\n"); + + sock.send(join('\r\n', req_headers)); + if (payload) sock.send(payload); + + const response_buff = sock.recv(BLOCKSIZE); + if (!response_buff || response_buff === '') { + sock.close(); + return { + code: 500, + headers: {}, + body: { message: "No response from Docker socket" } + }; + } + + const response_parts = split(response_buff, /\r?\n\r?\n/, 2); + const response_headers = read_http_headers({}, response_parts[0]); + let response_body; + + let is_chunked = (response_headers['transfer-encoding'] === 'chunked'); + + let reader; + if (is_chunked) { + reader = chunked_body_reader(sock, response_parts[1]); + } + else if (response_headers['content-length']) { + let content_length = int(response_headers['content-length']); + let buf = response_parts[1]; + + reader = () => { + if (content_length <= 0) return null; + + if (buf && length(buf)) { + let chunk = substr(buf, 0, content_length); + buf = substr(buf, length(chunk)); + content_length -= length(chunk); + return chunk; + } + + let data = sock.recv(min(BLOCKSIZE, content_length)); + if (!data || data === '') return null; + + content_length -= length(data); + return data; + }; + } + else { + // Fallback for HTTP/1.0 or no content-length: read until close or timeout + reader = () => { + // Poll with 2 second timeout + let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]); + if (!ready || !length(ready)) return null; // Timeout or error + + let data = sock.recv(BLOCKSIZE); + if (!data || data === '') return null; + return data; + }; + } + + let chunks = [], chunk; + while ((chunk = reader())) { + push(chunks, chunk); + } + + sock.close(); + + response_body = join('', chunks); + + // Parse HTTP status code + let status_line = split(response_parts[0], /\r?\n/)[0]; + let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/); + let code = status_match ? int(status_match[1]) : 0; + + // Docker events endpoint returns newline-delimited JSON, not a single JSON object + if (response_headers['content-type'] === 'application/json' && response_body) { + // Single JSON object + let data; + try { data = json(rtrim(response_body)); } + catch { data = null; } + + // Check if this is newline-delimited JSON (multiple lines with JSON objects) + if (!data) { + // Parse each line as a separate JSON object + let lines = split(trim(response_body), /\n/); + let events = []; + for (let line in lines) { + line = trim(line); + if (line) { + try { push(events, json(line)); } + catch { /* skip invalid lines */ } + } + } + response_body = events; + } else { + response_body = data; + } + } + + return { + code: code, + headers: response_headers, + body: response_body + }; +}; + +function run_ttyd(request) { + + const id = request.args.id || ''; + const cmd = request.args.cmd || '/bin/sh'; + const port = request.args.port || 7682; + const uid = request.args.uid || ''; + + if (!id) { + return { error: 'Container ID is required' }; + } + + let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`; + const sock_addr = ds.get_socket_dest(); + + /* Build the full command: + ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD + + if the socket is /var/run/docker.sock, prefix unix:// + + Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls* + */ + const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr; + ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`; + if (uid && uid !== '') { + ttyd_cmd = `${ttyd_cmd} -u ${uid}`; + } + + ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`; + + // Try to kill any existing ttyd processes on this port + system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true'); + + // Start ttyd + system(ttyd_cmd); + + return { status: 'ttyd started', command: ttyd_cmd }; +} + +// https://docs.docker.com/reference/api/engine/version/v1.47/ + +/* Note: methods here are included for structural reference. Some rpcd methods +are not suitable to be called from the GUI because they are streaming endpoints +or the operations in a busy dockerd cluster take a *long* time which causes +timeouts at the front end. Good examples of this are: +- /system/df +- push +- pull +- all /prune + +We include them here because they can be useful from the command line. +*/ + +const core_methods = { + version: { call: () => call_docker('GET', '/version') }, + info: { call: () => call_docker('GET', '/info') }, + ping: { call: () => call_docker('GET', '/_ping') }, + df: { call: () => call_docker('GET', '/system/df') }, + events: { args: { query: { 'since': '', 'until': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) }, +}; + +const exec_methods = { + start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) }, + resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) }, + inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) }, +}; + +const container_methods = { + list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) }, + create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) }, + inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) }, + top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) }, + logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) }, + changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) }, + export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) }, + stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) }, + resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) }, + start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) }, + stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) }, + restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) }, + kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) }, + update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) }, + rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) }, + pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) }, + unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) }, + // attach + // attach websocket + // wait + remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) }, + // archive info + info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) }, + // archive get + get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) }, + // archive extract + put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) }, + exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) }, + prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.query }) }, + + // Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker + ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) }, +}; + +const image_methods = { + list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request.args.query }) }, + // build is long-running, and will likely cause time-out on the call. Function only here for reference. + build: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) }, + build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) }, + create: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) }, + inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) }, + history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) }, + push: { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) }, + tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) }, + remove: { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) }, + search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) }, + prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) }, + // create/commit + get: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) }, + // get == export several + load: { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) }, +}; + +const network_methods = { + list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) }, + inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) }, + remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) }, + create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) }, + connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) }, + disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) }, + prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) }, +}; + +const volume_methods = { + list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) }, + create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) }, + inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) }, + update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) }, + remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) }, + prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.query }) }, +}; + +const methods = { + 'docker': core_methods, + 'docker.container': container_methods, + 'docker.exec': exec_methods, + 'docker.image': image_methods, + 'docker.network': network_methods, + 'docker.volume': volume_methods, +}; + +// CLI test mode - check if script is run directly (not loaded by rpcd) +if (caller != 'rpcd') { + // Usage: ./docker_rpc.uc + // 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} [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} `; + } + } + print(` ${obj}.${sig}\n`); + } + } + + print("\nExamples:\n"); + print(` ${scr_name} docker.version\n`); + print(` ${scr_name} docker.network.list '{"query":{}}'\n`); + print(` ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`); + print(` ${scr_name} docker.container.list '{"query":{"all":true}}'\n`); + exit(1); + } + + const method_path = split(ARGV[0], '.'); + if (length(method_path) < 1) { + die(`Invalid method path: ${ARGV[0]}\n`); + } + + // Build object path (e.g., "docker.network") + const obj_parts = slice(method_path, 0, -1); + const obj_name = join('.', obj_parts); + const method_name = method_path[length(method_path) - 1]; + + if (!methods[obj_name]) { + die(`Unknown object: ${obj_name}\n`); + } + + if (!methods[obj_name][method_name]) { + die(`Unknown method: ${obj_name}.${method_name}\n`); + } + + // Parse args if provided + let args = {}; + if (length(ARGV) > 1) { + try { + args = json(ARGV[1]); + } catch (e) { + die(`Invalid JSON args: ${e}\n`); + } + } + + // Call the method + const request = { args: args }; + const result = methods[obj_name][method_name].call(request); + + // Pretty print result + print(result, "\n"); + exit(0); +}; + +return methods; diff --git a/applications/luci-app-dockerman/ucode/controller/docker.uc b/applications/luci-app-dockerman/ucode/controller/docker.uc new file mode 100644 index 0000000000..b2ca046145 --- /dev/null +++ b/applications/luci-app-dockerman/ucode/controller/docker.uc @@ -0,0 +1,393 @@ +// Docker HTTP streaming endpoint +// Copyright 2025 Paul Donald +// Licensed to the public under the Apache License 2.0. +// Built against the docker v1.47 API + +'use strict'; + +import { stdout } from 'fs'; +import * as ds from 'luci.docker_socket'; +import * as socket from 'socket'; +import { cursor } from 'uci'; + +const BUFF_HEAD = 6; // 8000\r\n +const BUFF_TAIL = 2; // \r\n +const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776 +// const API_VER = 'v1.47'; +const PROTOCOL = 'HTTP/1.1'; +const CLIENT_VER = '1'; + +let DockerController = { + + // Handle file upload for chunked transfer + handle_file_upload: function(sock) { + let total_bytes = 0; + http.setfilehandler(function(meta, chunk, eof) { + if (meta.file && meta.name === 'upload-archive') { + if (chunk && length(chunk) > 0) { + let hex_size = sprintf('%x', length(chunk)); + sock.send(hex_size + '\r\n'); + sock.send(chunk); + sock.send('\r\n'); + total_bytes += length(chunk); + } + if (eof) { + sock.send('0\r\n\r\n'); + } + } + }); + return total_bytes; + }, + + // Reusable header builder + build_headers: function(headers) { + let hdrs = []; + if (headers) { + for (let key in headers) { + if (headers[key] != null && headers[key] != '') { + push(hdrs, `${key}: ${headers[key]}`); + } + } + } + return length(hdrs) ? join('\r\n', hdrs) : ''; + }, + + // Parse the initial HTTP response, split into parts and header lines, and store as properties + initial_response_parser: function(response_buff) { + let parts = split(response_buff, /\r?\n\r?\n/, 2); + let header_lines = split(parts[0], /\r?\n/); + let status_line = header_lines[0]; + let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/); + let code = status_match ? int(status_match[1]) : 500; + this.response_parts = parts; + this.header_lines = header_lines; + this.status_line = status_line; + this.status_match = status_match; + this.code = code; + }, + + // Stream the rest of the response in chunks from the socket + stream_response_chunks: function(sock, blocksize) { + let chunk; + while ((chunk = sock.recv(blocksize))) { + if (chunk && length(chunk)) { + this.debug('Streaming chunk:', substr(chunk, 0, 10)); + stdout.write(chunk); + } + } + }, + + // Send a 200 OK response with headers and body + /* Write CGI response directly to stdout bypassing http.uc + The minimum to trigger a valid response via CGI is typically + Status: \r\n + + The Docker response contains the \r\n after its headers, and the browser can + handle the chunked encoding fine, so we just forward its output verbatim. + + Docker emits a x-docker-container-path-stat header with some meta-data for + the path, which we forward. uhttpd seems to coalesce headers, and inject its + own, so we occasionally have two Connection: headers. + */ + send_initial_200_response: function(headers, body) { + stdout.write('Status: 200 OK\r\n'); + if (headers && type(headers) == 'array') { + stdout.write(join('', headers)); + } + + if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) { + stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n'))); + } + }, + + // Debug output if &debug=... is present + debug: function(...args) { + let dbg = http.formvalue('debug'); + let tostr = function(x) { return `${x}`; }; + if (dbg != null && dbg != '') { + http.prepare_content('application/json'); + http.write_json({msg: join(' ', map(args, tostr)) + '\n' }); + } + }, + + // Generic error response helper + error_response: function(code, msg, detail) { + http.status(code ?? 500, msg ?? 'Internal Error'); + http.prepare_content('application/json'); + let out = { error: msg ?? 'Internal Error' }; + if (detail) + out.detail = detail; + http.write_json(out); + }, + + get_api_ver: function() { + let ctx = cursor(); + let version = ctx.get('dockerd', 'globals', 'api_version') || ''; + ctx.unload(); + return version ? `/${version}` : ''; + }, + + join_args_array: function(args) { + return (type(args) == "array") ? join('/', args) : args; + }, + + require_param: function(name) { + let val = http.formvalue(name); + if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` }); + return val; + }, + + // Reusable query string builder + build_query_str: function(query_params, skip_keys) { + let query_str = ''; + if (query_params) { + let parts = []; + for (let key in query_params) { + if (skip_keys && (key in skip_keys)) + continue; + let val = query_params[key]; + if (val == null || val == '') continue; + if (type(val) === 'array') { + for (let v in val) { + push(parts, `${key}=${v}`); + } + } else { + push(parts, `${key}=${val}`); + } + } + if (length(parts)) + query_str = '?' + join('&', parts); + } + return query_str; + }, + + get_archive: function(docker_path, id, docker_function, query_params, archive_name) { + this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name); + id = this.join_args_array(id); + let id_param = ''; + if (id) id_param = `/${id}`; + + const sock_dest = ds.get_socket_dest_compat(); + const sock = socket.create(sock_dest.family, socket.SOCK_STREAM); + + this.debug('Socket created:', !!sock); + + if (!sock) { + this.debug('Socket creation failed'); + this.error_response(500, 'Failed to create socket'); + return; + } + + if (!sock.connect(sock_dest)) { + this.debug('Socket connect failed'); + sock.close(); + this.error_response(503, 'Failed to connect to Docker daemon'); + return; + } + + let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`; + let url = `${docker_path}${id_param}${docker_function}${query_str}`; + let req = [ + `GET ${this.get_api_ver()}${url} ${PROTOCOL}`, + `Host: openwrt-docker-ui`, + `User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`, + `Connection: close`, + ``, + `` + ]; + + this.debug('Sending request:', req); + sock.send(join('\r\n', req)); + + let response_buff = sock.recv(BLOCKSIZE); + this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null'); + if (!response_buff || response_buff == '') { + this.debug('No response from Docker daemon'); + sock.close(); + this.error_response(500, 'No response from Docker daemon'); + return; + } + + this.initial_response_parser(response_buff); + + if (this.code != 200) { + this.debug('Docker error status:', this.code, this.status_line); + sock.close(); + this.error_response(this.code, 'Docker Error', this.status_line); + return; + } + + let filename = length(id) >= 64 ? substr(id, 0, 12) : id; + if (!filename) filename = 'multi'; + let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`]; + + this.send_initial_200_response(include_headers, response_buff); + this.stream_response_chunks(sock, BLOCKSIZE); + + sock.close(); + return; + }, + + docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) { + this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile); + const sock_dest = ds.get_socket_dest_compat(); + const sock = socket.create(sock_dest.family, socket.SOCK_STREAM); + + this.debug('Socket created:', !!sock); + + if (!sock) { + this.debug('Socket creation failed'); + this.error_response(500, 'Failed to create socket'); + return; + } + + if (!sock.connect(sock_dest)) { + this.debug('Socket connect failed'); + sock.close(); + this.error_response(503, 'Failed to connect to Docker daemon'); + return; + } + + let skip_keys = { + token: true, + 'X-Registry-Auth': true, + 'upload-name': true, + 'upload-archive': true, + 'upload-path': true, + }; + + let remote = false; + + if (query_params && type(query_params) === 'object' && + query_params['remote'] != null && query_params['remote'] != '') + remote = true; + + let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`; + + let hdr_str = this.build_headers(headers); + + let url = `${docker_path}${docker_function}${query_str}`; + + let req = [ + `${method} ${this.get_api_ver()}${url} ${PROTOCOL}`, + `Host: openwrt-docker-ui`, + `User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`, + `Connection: close`, + ]; + + if (hdr_str) + push(req, hdr_str); + if (haveFile) { + push(req, 'Content-Type: application/x-tar'); + push(req, 'Transfer-Encoding: chunked'); + } + push(req, ''); + push(req, ''); + + this.debug('Sending request:', req); + sock.send(join('\r\n', req)); + + if (haveFile) + this.handle_file_upload(sock); + else + sock.send('\r\n\r\n'); + + let response_buff = sock.recv(BLOCKSIZE); + this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null'); + if (!response_buff || response_buff == '') { + this.debug('No response from Docker daemon'); + sock.close(); + this.error_response(500, 'No response from Docker daemon'); + return; + } + + this.initial_response_parser(response_buff); + if (this.code != 200) { + this.debug('Docker error status:', this.code, this.status_line); + sock.close(); + this.error_response(this.code, 'Docker Error', this.status_line); + return; + } + + this.send_initial_200_response('', response_buff); + this.stream_response_chunks(sock, BLOCKSIZE); + sock.close(); + return; + }, + + // Handler methods + container_get_archive: function(id) { + this.require_param('path'); + this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar'); + }, + + container_export: function(id) { + this.get_archive('/containers', id, '/export', null, 'container_export.tar'); + }, + + containers_prune: function() { + this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false); + }, + + container_put_archive: function(id) { + this.require_param('path'); + this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true); + }, + + docker_events: function() { + this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false); + }, + + image_build: function(...args) { + let remote = http.formvalue('remote'); + this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote); + }, + + image_build_prune: function() { + this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false); + }, + + image_create: function() { + let headers = { + 'X-Registry-Auth': http.formvalue('X-Registry-Auth'), + }; + this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false); + }, + + image_get: function(...args) { + this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar'); + }, + + image_load: function() { + this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true); + }, + + images_prune: function() { + this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false); + }, + + image_push: function(...args) { + let headers = { + 'X-Registry-Auth': http.formvalue('X-Registry-Auth'), + }; + this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false); + }, + + networks_prune: function() { + this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false); + }, + + volumes_prune: function() { + this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false); + }, +}; + +// Export all handlers with automatic error wrapping +let controller = DockerController; +let exports = {}; +for (let k, v in controller) { + if (type(v) == 'function') + exports[k] = v; +} + +return exports; diff --git a/applications/luci-app-dockerman/ucode/docker_socket.uc b/applications/luci-app-dockerman/ucode/docker_socket.uc new file mode 100644 index 0000000000..ab4d149e8f --- /dev/null +++ b/applications/luci-app-dockerman/ucode/docker_socket.uc @@ -0,0 +1,87 @@ + +// Copyright 2025 Paul Donald / luci-lib-docker-js +// Licensed to the public under the Apache License 2.0. +// Built against the docker v1.47 API + +import { cursor } from 'uci'; +import * as socket from 'socket'; + +/** + * Get the Docker socket path from uci config, more backwards compatible. + */ +export function get_socket_dest_compat() { + const ctx = cursor(); + let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock'; + ctx.unload(); + + sock_entry = lc(sock_entry); + /* start ucode 2025-12-01 compatibility */ + let sock_split, addr = sock_entry, proto, proto_num, port = 0; + let family; + + if (index(sock_entry, '://') != -1) { + let sock_split = split(lc(sock_entry), '://', 2); + addr = sock_split?.[1]; + proto = sock_split?.[0]; + } + if (index(addr, '/') != -1) { + // we got '/var/run/docker.sock' format + return socket.sockaddr(addr); + } + + if (proto === 'tcp' || proto === 'udp' || proto === 'inet') { + family = socket.AF_INET; + if (proto === 'tcp') + proto_num = socket.IPPROTO_TCP; + else if (proto === 'udp') + proto_num = socket.IPPROTO_UDP; + } + else if (proto === 'tcp6' || proto === 'udp6' || proto === 'inet6') { + family = socket.AF_INET6; + if (proto === 'tcp6') + proto_num = socket.IPPROTO_TCP; + else if (proto === 'udp6') + proto_num = socket.IPPROTO_UDP; + } + else if (proto === 'unix') + family = socket.AF_UNIX; + else { + family = socket.AF_INET; // ipv4 + proto_num = socket.IPPROTO_TCP; // tcp + } + + let host = addr; + const l_bracket = index(host, '['); + const r_bracket = rindex(host, ']'); + if (l_bracket != -1 && r_bracket != -1) { + host = substr(host, l_bracket + 1, r_bracket - 1); + family = socket.AF_INET6; + } + + // find port based on addr, otherwise we find ':' in IPv6 + const port_index = rindex(addr, ':'); + if (port_index != -1) { + port = int(substr(addr, port_index + 1)) || 0; + host = substr(host, 0, port_index); + } + + const sock = socket.addrinfo(host, port, {protocol: proto_num}); + + return socket.sockaddr(sock[0].addr); + // return {family: family, address: host, port: port}; +}; + + +/** + * Get the Docker socket path from uci config + */ +export function get_socket_dest() { + + const ctx = cursor(); + let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock'; + sock_entry = lc(sock_entry); + let sock_addr = split(sock_entry, '://', 2)?.[1] ?? sock_entry; + ctx.unload(); + + return sock_addr; +};