luci-app-dockerman: convert to JS

This is a complete rewrite of the original Lua
dockerman in ECMAScript and ucode. Now with most of the
Lua gone, we can rename LuCI to JUCI. JavaScript ucode
Configuration Interface :)

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

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

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

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

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

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

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

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2025-07-06 02:33:02 +02:00
parent cb085c73dd
commit baa0f16bb3
20 changed files with 8392 additions and 12 deletions

View File

@@ -3,18 +3,16 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Support for docker
LUCI_DEPENDS:=@(aarch64||arm||x86_64) \
+luci-base \
+luci-compat \
+luci-lib-docker \
+docker \
+ttyd \
+dockerd \
+docker-compose
+docker-compose \
+ucode-mod-socket
PKG_LICENSE:=AGPL-3.0
PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com> \
PKG_MAINTAINER:=Paul Donald <newtwen+github@gmail.com> \
Florian Eckert <fe@dev.tdt.de>
PKG_VERSION:=0.5.13.20241008
include ../../luci.mk

View File

@@ -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
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Docker icon</title>
<path d="M4.82 17.275c-.684 0-1.304-.56-1.304-1.24s.56-1.243 1.305-1.243c.748 0 1.31.56 1.31 1.242s-.622 1.24-1.305 1.24zm16.012-6.763c-.135-.992-.75-1.8-1.56-2.42l-.315-.25-.254.31c-.494.56-.69 1.553-.63 2.295.06.562.24 1.12.554 1.554-.254.13-.568.25-.81.377-.57.187-1.124.25-1.68.25H.097l-.06.37c-.12 1.182.06 2.42.562 3.54l.244.435v.06c1.5 2.483 4.17 3.6 7.078 3.6 5.594 0 10.182-2.42 12.357-7.633 1.425.062 2.864-.31 3.54-1.676l.18-.31-.3-.187c-.81-.494-1.92-.56-2.85-.31l-.018.002zm-8.008-.992h-2.428v2.42h2.43V9.518l-.002.003zm0-3.043h-2.428v2.42h2.43V6.48l-.002-.003zm0-3.104h-2.428v2.42h2.43v-2.42h-.002zm2.97 6.147H13.38v2.42h2.42V9.518l-.007.003zm-8.998 0H4.383v2.42h2.422V9.518l-.01.003zm3.03 0h-2.4v2.42H9.84V9.518l-.015.003zm-6.03 0H1.4v2.42h2.428V9.518l-.03.003zm6.03-3.043h-2.4v2.42H9.84V6.48l-.015-.003zm-3.045 0H4.387v2.42H6.8V6.48l-.016-.003z" />
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 756.26 596.9">
<defs>
<style>
.cls-1 {
fill: #1d63ed;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M743.96,245.25c-18.54-12.48-67.26-17.81-102.68-8.27-1.91-35.28-20.1-65.01-53.38-90.95l-12.32-8.27-8.21,12.4c-16.14,24.5-22.94,57.14-20.53,86.81,1.9,18.28,8.26,38.83,20.53,53.74-46.1,26.74-88.59,20.67-276.77,20.67H.06c-.85,42.49,5.98,124.23,57.96,190.77,5.74,7.35,12.04,14.46,18.87,21.31,42.26,42.32,106.11,73.35,201.59,73.44,145.66.13,270.46-78.6,346.37-268.97,24.98.41,90.92,4.48,123.19-57.88.79-1.05,8.21-16.54,8.21-16.54l-12.3-8.27ZM189.67,206.39h-81.7v81.7h81.7v-81.7ZM295.22,206.39h-81.7v81.7h81.7v-81.7ZM400.77,206.39h-81.7v81.7h81.7v-81.7ZM506.32,206.39h-81.7v81.7h81.7v-81.7ZM84.12,206.39H2.42v81.7h81.7v-81.7ZM189.67,103.2h-81.7v81.7h81.7v-81.7ZM295.22,103.2h-81.7v81.7h81.7v-81.7ZM400.77,103.2h-81.7v81.7h81.7v-81.7ZM400.77,0h-81.7v81.7h81.7V0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,137 @@
'use strict';
'require form';
'require fs';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return L.view.extend({
render() {
const m = new form.Map('dockerd', _('Docker - Configuration'),
_('DockerMan is a simple docker manager client for LuCI'));
let o, t, s, ss;
s = m.section(form.NamedSection, 'globals', 'section', _('Global settings'));
t = s.tab('globals', _('Globals'));
o = s.taboption('globals', form.Value, 'ps_flags',
_('Default ps flags'),
_('Flags passed to docker top (ps). Leave empty to use the built-in default.'));
o.placeholder = '-ww';
o.rmempty = true;
o.optional = true;
o = s.taboption('globals', form.Value, 'api_version',
_('Api Version'),
_('Lock API endpoint to a specific version (helps guarantee behaviour).') + '<br/>' +
_('Causes errors when a chosen API > Docker endpoint API support.'));
o.rmempty = true;
o.optional = true;
o.value('v1.44');
o.value('v1.45');
o.value('v1.46');
o.value('v1.47');
o.value('v1.48');
o.value('v1.49');
o.value('v1.50');
o.value('v1.51');
o.value('v1.52');
// Check if local dockerd is available
o = s.taboption('globals', form.DirectoryPicker, 'data_root', _('Docker Root Dir'),
_('For local dockerd socket instances only.'));
o.datatype = 'folder';
o.default = '/opt/docker';
o.root_directory = '/';
o.show_hidden = true;
o = s.taboption('globals', form.Value, 'bip',
_('Default bridge'),
_('Configure the default bridge network'));
o.placeholder = '172.17.0.1/16';
o.datatype = 'ipaddr';
o = s.taboption('globals', form.DynamicList, 'registry_mirrors',
_('Registry Mirrors'),
_('It replaces the daemon registry mirrors with a new set of registry mirrors'));
o.placeholder = _('Example: ') + 'https://hub-mirror.c.163.com';
o.value('https://docker.io');
o.value('https://ghcr.io');
o.value('https://hub-mirror.c.163.com');
o = s.taboption('globals', form.ListValue, 'log_level',
_('Log Level'),
_('Set the logging level'));
o.value('debug', _('Debug'));
o.value('', _('Info')); // default
o.value('warn', _('Warning'));
o.value('error', _('Error'));
o.value('fatal', _('Fatal'));
o.rmempty = true;
o = s.taboption('globals', form.DynamicList, 'hosts',
_('Client connection'),
_('Specifies where the Docker daemon will listen for client connections. default: ') + 'unix:///var/run/docker.sock' + '<br />' +
_('Note that dockerd no longer listens on IP:port without TLS options after v27.'));
o.placeholder = _('Example: tcp://0.0.0.0:2375');
o.default = 'unix:///var/run/docker.sock';
o.rmempty = true;
o.value('unix:///var/run/docker.sock');
o.value('tcp://0.0.0.0:2375');
o.value('tcp://0.0.0.0:2376');
o.value('tcp6://[::]:2375');
o.value('tcp6://[::]:2376');
t = s.tab('auth', _('Registry Auth'));
o = s.taboption('auth', form.SectionValue, '__auth__', form.TableSection, 'auth', null,
_('Used for push/pull operations on custom registries.') + '<br/>' +
_('Destinations prefixed with a Registry host matching an entry in this table invoke its corresponding credentials.') + '<br/>' +
_('The first match is used.') + '<br/>' +
_('A Token is preferred over a Password.') + '<br/>' +
_('Tokes and Passwords are not encrypted in the uci configuration.'));
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = true;
ss.addremove = true;
ss.sortable = true;
o = ss.option(form.Value, 'username',
_('User'));
o.placeholder = 'jbloggs';
o.rmempty = false;
o = ss.option(form.Value, 'password',
_('Password'));
o.placeholder = 'foobar';
o.password = true;
o = ss.option(form.Value, 'serveraddress',
_('Registry'));
o.datatype = 'or(hostname,hostport,ipaddr,ipaddrport)';
o.placeholder = 'registry.foo.io[:443] | 192.0.2.1[:443]';
o.rmempty = false;
o.value('container-registry.oracle.com');
o.value('registry.docker.io');
o.value('gcr.io');
o.value('ghcr.io');
o.value('quay.io');
o.value('registry.gitlab.com');
o.value('registry.redhat.io');
o = ss.option(form.Value, 'token',
_('Token'));
o.password = true;
return m.render();
}
});

View File

@@ -0,0 +1,945 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/* API v1.52
POST /containers/create no longer supports configuring a container-wide MAC
address via the container's Config.MacAddress field. A container's MAC address
can now only be configured via endpoint settings when connecting to a network.
*/
return dm2.dv.extend({
load() {
const requestPath = L.env.requestpath;
const duplicateId = requestPath[requestPath.length-1];
const isDuplicate = requestPath[requestPath.length-2] === 'duplicate' && duplicateId;
const promises = [
dm2.image_list().then(images => {
return images.body || [];
}),
dm2.network_list().then(networks => {
return networks.body || [];
}),
dm2.volume_list().then(volumes => {
return volumes.body?.Volumes || [];
}),
dm2.docker_info().then(info => {
const numcpus = info.body?.NCPU || 1.0;
const memory = info.body?.MemTotal || 2**10;
return {numcpus: numcpus, memory: memory};
}),
];
if (isDuplicate) {
promises.push(
dm2.container_inspect({ id: duplicateId }).then(container => {
this.duplicateContainer = container.body || {};
this.isDuplicate = true;
})
);
}
return Promise.all(promises);
},
render([image_list, network_list, volume_list, cpus_mem]) {
this.volumes = volume_list;
const view = this; // Capture the view context
// Load duplicate container config if available
let containerData = {container: {}};
let pageTitle = _('Create new docker container');
if (this.isDuplicate && this.duplicateContainer) {
pageTitle = _('Duplicate/Edit Container: %s').format(this.duplicateContainer.Name?.substring(1) || '');
const resolveImageId = (imageRef) => {
if (!imageRef) return null;
const match = (image_list || []).find(img => img.Id === imageRef || (Array.isArray(img.RepoTags) && img.RepoTags.includes(imageRef)));
return match?.Id || null;
};
const c = this.duplicateContainer;
const hostConfig = c.HostConfig || {};
const resolvedImage = resolveImageId(c.Image) || resolveImageId(c.Config?.Image) || c.Image || c.Config?.Image || '';
const builtInNetworks = new Set(['none', 'bridge', 'host']);
const [netnames, nets] = Object.entries(c.NetworkSettings?.Networks || {});
containerData.container = {
name: c.Name?.substring(1) || '',
interactive: c.Config?.AttachStdin ? 1 : 0,
tty: c.Config?.Tty ? 1 : 0,
image: resolvedImage,
privileged: hostConfig.Privileged ? 1 : 0,
restart_policy: hostConfig.RestartPolicy?.Name || 'unless-stopped',
network: (() => {
return (netnames && (netnames.length > 0)) ? netnames[0] : '';
})(),
ipv4: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.IPAddress || '' : '';
})(),
ipv6: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.GlobalIPv6Address || '' : '';
})(),
ipv6: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.LinkLocalIPv6Address || '' : '';
})(),
link: hostConfig.Links || [],
dns: hostConfig.Dns || [],
user: c.Config?.User || '',
env: c.Config?.Env || [],
volume: (hostConfig.Mounts || c.Mounts || []).map(m => {
let source;
const destination = m.Destination || m.Target || '';
let opts = '';
if (m.Type === 'image') {
source = '@image';
if (m.ImageOptions && m.ImageOptions.Subpath)
opts = 'subpath=' + m.ImageOptions.Subpath;
} else if (m.Type === 'tmpfs') {
source = '@tmpfs';
const tmpOpts = [];
if (m.TmpfsOptions) {
if (m.TmpfsOptions.SizeBytes) tmpOpts.push('size=' + m.TmpfsOptions.SizeBytes);
if (m.TmpfsOptions.Mode) tmpOpts.push('mode=' + m.TmpfsOptions.Mode);
if (Array.isArray(m.TmpfsOptions.Options)) {
for (const o of m.TmpfsOptions.Options) {
if (Array.isArray(o) && o.length === 2) tmpOpts.push(`${o[0]}=${o[1]}`);
else if (Array.isArray(o) && o.length === 1) tmpOpts.push(o[0]);
}
}
}
opts = tmpOpts.join(',');
} else if (m.Type === 'volume') {
source = m.Source || '';
// opts = m.Mode || '';
} else {
source = m.Source || '';
opts = m.Mode || '';
}
return source + ':' + destination + (opts ? ':' + opts : '');
}),
publish: (() => {
const ports = [];
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
const hostPort = bindings[0].HostPort;
ports.push(hostPort + ':' + containerPort);
}
}
return ports;
})(),
command: c.Config?.Cmd ? c.Config?.Cmd.join(' ') : '',
hostname: c.Config?.Hostname || '',
publish_all: hostConfig.PublishAllPorts ? 1 : 0,
device: (hostConfig.Devices || []).map(d => d.PathOnHost + ':' + d.PathInContainer + (d.CgroupPermissions ? ':' + d.CgroupPermissions : '')),
tmpfs: (() => {
const list = [];
if (hostConfig.Tmpfs && typeof hostConfig.Tmpfs === 'object') {
for (const [path, opts] of Object.entries(hostConfig.Tmpfs)) {
list.push(path + (opts ? ':' + opts : ''));
}
}
return list;
})(),
sysctl: (() => {
const list = [];
if (hostConfig.Sysctls && typeof hostConfig.Sysctls === 'object') {
for (const [key, value] of Object.entries(hostConfig.Sysctls)) {
list.push(key + ':' + value);
}
}
return list;
})(),
cap_add: hostConfig.CapAdd || [],
cpus: hostConfig.NanoCPUs ? (hostConfig.NanoCPUs / (10 ** 9)).toFixed(3) : '',
cpu_shares: hostConfig.CpuShares || '',
cpu_period: hostConfig.CpuPeriod || '',
cpu_quota: hostConfig.CpuQuota || '',
memory: hostConfig.Memory || '',
memory_reservation: hostConfig.MemoryReservation || '',
blkio_weight: hostConfig.BlkioWeight || '',
log_opt: (() => {
const list = [];
const logConfig = hostConfig.LogConfig?.Config;
if (logConfig && typeof logConfig === 'object') {
for (const [key, value] of Object.entries(logConfig)) {
list.push(key + '=' + value);
}
}
return list;
})(),
};
}
// stuff JSONMap with container config
const m = new form.JSONMap(containerData, _('Docker - Containers'));
m.submit = true;
m.reset = true;
let s = m.section(form.NamedSection, 'container', pageTitle);
s.anonymous = true;
s.nodescriptions = true;
s.addremove = false;
let o;
o = s.option(form.Value, 'name', _('Container Name'),
_('Name of the container that can be selected during container creation'));
o.rmempty = true;
o = s.option(form.Flag, 'interactive', _('Interactive (-i)'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Flag, 'tty', _('TTY (-t)'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.ListValue, 'image', _('Docker Image'));
o.rmempty = true;
for (const image of image_list) {
o.value(image.Id, image?.RepoTags?.[0]);
}
o = s.option(form.Flag, 'pull', _('Always pull image first'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Flag, 'privileged', _('Privileged'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.ListValue, 'restart_policy', _('Restart Policy'));
o.rmempty = true;
o.default = 'unless-stopped';
o.value('no', _('No'));
o.value('unless-stopped', _('Unless stopped'));
o.value('always', _('Always'));
o.value('on-failure', _('On failure'));
o = s.option(form.ListValue, 'network', _('Networks'));
o.rmempty = true;
this.buildNetworkListValues(network_list, o);
function not_with_a_docker_net(section_id, value) {
if (!value || value === "") return true;
const builtInNetworks = new Set(['none', 'bridge', 'host']);
let dnet = this.section.getOption('network').getUIElement(section_id).getValue();
const disallowed = builtInNetworks.has(dnet);
if (disallowed) return _('Only for user-defined networks');
};
o = s.option(form.Value, 'ipv4', _('IPv4 Address'));
o.rmempty = true;
o.datatype = 'ip4addr';
o.validate = not_with_a_docker_net;
o = s.option(form.Value, 'ipv6', _('IPv6 Address'));
o.rmempty = true;
o.datatype = 'ip6addr';
o.validate = not_with_a_docker_net;
o = s.option(form.Value, 'ipv6_lla', _('IPv6 Link-Local Address'));
o.rmempty = true;
o.datatype = 'ip6ll';
o.validate = not_with_a_docker_net;
o = s.option(form.DynamicList, 'link', _('Links with other containers'));
o.rmempty = true;
o.placeholder='container_name:alias';
o = s.option(form.DynamicList, 'dns', _('Set custom DNS servers'));
o.rmempty = true;
o.placeholder='8.8.8.8';
o = s.option(form.Value, 'user', _('User(-u)'),
_('The user that commands are run as inside the container. (format: name|uid[:group|gid])'));
o.rmempty = true;
o.placeholder='1000:1000';
o = s.option(form.DynamicList, 'env', _('Environmental Variable(-e)'),
_('Set environment variables inside the container'));
o.rmempty = true;
o.placeholder='TZ=Europe/Paris';
o = s.option(form.DummyValue, 'volume', _('Mount(--mount)'),
_('Bind mount a volume'));
o.rmempty = true;
o.cfgvalue = () => {
const c_volumes = view.map.data.get('json', 'container', 'volume') || [];
const showVolumeModal = (index, initialEntry) => {
let typeSelect, bindPicker, bindSourceField, volumeNameInput, volumeSourceField, pathInput, pathField, optionsDropdown, optionsField, subpathInput;
let tmpfsSizeInput, tmpfsModeInput, tmpfsOptsInput, tmpfsSizeField, tmpfsModeField, tmpfsOptsField;
const isEdit = index !== null;
const modalTitle = isEdit ? _('Edit Mount') : _('Add Mount');
// Parse existing entry if editing and infer type from volumes list, image, or tmpfs
let initialType = 'bind', initialSource = '', initialPath = '', initialOptions = '';
if (isEdit && initialEntry) {
const parts = (typeof initialEntry === 'string' ? initialEntry : '').split(':');
initialSource = parts[0] || '';
initialPath = parts[1] || '';
initialOptions = parts[2] || '';
// Infer type: tmpfs, volume, image, else bind
const isTmpfs = (initialSource === '@tmpfs');
const isVolume = (volume_list || []).some(v => v.Name === initialSource || v.Id === initialSource);
const isImage = (initialSource === '@image');
initialType = isTmpfs ? 'tmpfs' : (isVolume ? 'volume' : (isImage ? 'image' : 'bind'));
}
const existingOptions = (typeof initialOptions === 'string' ? initialOptions : '').split(',').map(o => o.trim()).filter(Boolean);
// Type-specific options for dropdowns
const bindOptions = {
'ro': _('Read-only (ro)'),
'rw': _('Read-write (rw)'),
'private': _('Propagation: private'),
'rprivate': _('Propagation: rprivate'),
'shared': _('Propagation: shared'),
'rshared': _('Propagation: rshared'),
'slave': _('Propagation: slave'),
'rslave': _('Propagation: rslave')
};
const volumeOptions = {
// 'ro': _('Read-only (ro)'),
// 'rw': _('Read-write (rw)'),
'nocopy': _('No copy (nocopy)')
};
const getOptionsForType = (type) => type === 'bind' ? bindOptions : volumeOptions;
const namesListId = 'volname-list-' + Math.random().toString(36).substr(2, 9);
// Create dropdown for options - updates based on type
optionsDropdown = new ui.Dropdown(existingOptions, getOptionsForType(initialType), {
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
multiple: true,
optional: true,
display_items: 2,
placeholder: _('Select options...')
});
const createField = (label, input) => {
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, label),
E('div', { 'class': 'cbi-value-field' }, Array.isArray(input) ? input : [input])
]);
};
// Type select
const typeOptions = [
E('option', { value: 'bind' }, _('Bind (host directory)')),
E('option', { value: 'volume' }, _('Volume (named)')),
E('option', { value: 'image' }, _('Image (from image)')),
E('option', { value: 'tmpfs' }, _('Tmpfs'))
];
typeSelect = E('select', { 'class': 'cbi-input-select' }, typeOptions);
typeSelect.value = initialType;
// Bind directory picker using ui.FileUpload
bindPicker = new ui.FileUpload(initialType === 'bind' ? initialSource : '', {
browser: false,
directory_select: true,
directory_create: false,
enable_upload: false,
enable_remove: false,
enable_download: false,
root_directory: '/',
show_hidden: true
});
// Volume name input with datalist
volumeNameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('Enter volume name or pick existing'),
'list': namesListId,
'value': initialType === 'volume' ? initialSource : ''
});
volumeSourceField = createField(_('Volume Name'), [
E('div', { 'style': 'position: relative;' }, [
volumeNameInput,
E('span', { 'style': 'pointer-events: none;' }, '▼')
]),
E('datalist', { 'id': namesListId }, [
...volume_list.map(vol => E('option', { 'value': vol.Name }, vol.Name))
])
]);
// Tmpfs inputs - pre-populate if editing
let tmpfsSizeVal = '', tmpfsModeVal = '', tmpfsOptsVal = '';
if (initialType === 'tmpfs' && existingOptions.length) {
const rest = [];
existingOptions.forEach(o => {
if (o.startsWith('size=')) tmpfsSizeVal = o.slice('size='.length);
else if (o.startsWith('mode=')) tmpfsModeVal = view.modeToRwx(o.slice('mode='.length));
else rest.push(o);
});
tmpfsOptsVal = rest.join(',');
}
tmpfsSizeField = createField(_('Size'),
tmpfsSizeInput = E('input', {
'class': 'cbi-input-text',
'placeholder': '128m',
'value': tmpfsSizeVal
})
);
tmpfsModeField = createField(_('Mode'),
tmpfsModeInput = E('input', {
'class': 'cbi-input-text',
'placeholder': 'rwxr-xr-x or 1770',
'value': tmpfsModeVal
})
);
tmpfsOptsField = createField(_('tmpfs Options'),
tmpfsOptsInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'nr_blocks=blocks,...',
'value': tmpfsOptsVal
})
);
// Render bindPicker and show modal
Promise.resolve(bindPicker.render()).then(bindPickerNode => {
bindSourceField = createField(_('Host Directory'), bindPickerNode);
const updateOptions = (selectedType) => {
optionsField.querySelector('.cbi-value-field').innerHTML = '';
if (selectedType === 'image') {
// For image mounts, show a Subpath text input (only option)
subpathInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('/path/in/image'),
'value': (initialType === 'image' && existingOptions.find(o => o.startsWith('subpath='))) ? existingOptions.find(o => o.startsWith('subpath=')).slice('subpath='.length) : ''
});
optionsField.querySelector('.cbi-value-title').textContent = _('Subpath');
optionsField.querySelector('.cbi-value-field').appendChild(subpathInput);
} else if (selectedType === 'tmpfs') {
// Tmpfs fields are shown as main fields, hide options field
optionsField.style.display = 'none';
} else {
optionsField.querySelector('.cbi-value-title').textContent = _('Options');
// Recreate dropdown with new options
const currentValue = optionsDropdown.getValue();
optionsDropdown = new ui.Dropdown(currentValue, getOptionsForType(selectedType), {
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
multiple: true,
optional: true,
display_items: 2,
placeholder: _('Select options...')
});
optionsField.querySelector('.cbi-value-field').appendChild(optionsDropdown.render());
optionsField.style.display = '';
}
};
const toggleSources = () => {
const isBind = typeSelect.value === 'bind';
const isVolume = typeSelect.value === 'volume';
const isImage = typeSelect.value === 'image';
const isTmpfs = typeSelect.value === 'tmpfs';
bindSourceField.style.display = isBind ? '' : 'none';
volumeSourceField.style.display = isVolume ? '' : 'none';
pathField.style.display = isImage ? 'none' : '';
tmpfsSizeField.style.display = isTmpfs ? '' : 'none';
tmpfsModeField.style.display = isTmpfs ? '' : 'none';
tmpfsOptsField.style.display = isTmpfs ? '' : 'none';
updateOptions(typeSelect.value);
};
optionsField = createField(_('Options'), optionsDropdown.render());
ui.showModal(modalTitle, [
createField(_('Type'), typeSelect),
bindSourceField,
volumeSourceField,
pathField = createField(_('Mount Path'),
pathInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('/mnt/path'),
'value': initialPath
})
),
tmpfsSizeField,
tmpfsModeField,
tmpfsOptsField,
optionsField,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, [_('Cancel')]),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const selectedType = typeSelect.value;
const sourcePath = selectedType === 'bind'
? (bindPicker.getValue() || '').trim()
: (selectedType === 'volume'
? (volumeNameInput.value || '').trim()
: (selectedType === 'tmpfs' ? '@tmpfs' : '@image'));
const subpathVal = (selectedType === 'image') ? (subpathInput?.value || '').trim() : '';
const mountPath = (selectedType === 'image') ? subpathVal : pathInput.value.trim();
let selectedOptions;
if (selectedType === 'image') {
selectedOptions = subpathVal ? ('subpath=' + subpathVal) : '';
} else if (selectedType === 'tmpfs') {
const opts = [];
const sizeValRaw = (tmpfsSizeInput?.value || '').trim();
const modeValRaw = (tmpfsModeInput?.value || '').trim();
const extraVal = (tmpfsOptsInput?.value || '').trim();
const parsedSize = sizeValRaw ? view.parseMemory(sizeValRaw) : undefined;
const parsedMode = view.rwxToMode(modeValRaw);
if (parsedSize) opts.push('size=' + parsedSize);
else if (sizeValRaw) opts.push('size=' + sizeValRaw); // fallback if parse fails
if (parsedMode !== undefined) opts.push('mode=' + parsedMode);
if (extraVal) opts.push(...extraVal.split(',').map(o => o.trim()).filter(Boolean));
selectedOptions = opts.join(',');
} else {
selectedOptions = optionsDropdown.getValue().join(',');
}
if (!sourcePath) {
ui.addTimeLimitedNotification(null, [_('Please choose a directory or enter a volume name')], 3000, 'warning');
return;
}
if (selectedType !== 'image' && !mountPath) {
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
return;
}
if (selectedType === 'image' && !subpathVal) {
ui.addTimeLimitedNotification(null, [_('Please enter a subpath')], 3000, 'warning');
return;
}
if (selectedType === 'tmpfs' && !mountPath) {
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
return;
}
ui.hideModal();
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
const volumeEntry = selectedOptions ? (sourcePath + ':' + mountPath + ':' + selectedOptions) : (sourcePath + ':' + mountPath);
let updatedVolumes;
if (isEdit) {
updatedVolumes = [...currentVolumes];
updatedVolumes[index] = volumeEntry;
} else {
updatedVolumes = Array.isArray(currentVolumes) ? [...currentVolumes, volumeEntry] : [volumeEntry];
}
view.map.data.set('json', 'container', 'volume', updatedVolumes);
return view.map.render();
})
}, [isEdit ? _('Update') : _('Add')])
])
]);
toggleSources();
typeSelect.addEventListener('change', toggleSources);
});
};
return E('div', { 'class': 'cbi-dynlist' }, [
...(c_volumes.length > 0 ? c_volumes.map((v, idx) => E('div', {
'class': 'cbi-dynlist-item',
'style': 'display: flex; justify-content: space-between; align-items: center; padding: 8px 5px; margin-bottom: 8px; gap: 10px;'
}, [
E('span', {
'style': 'cursor: pointer; flex: 1;',
'click': ui.createHandlerFn(view, () => {
showVolumeModal(idx, v);
})
}, v),
E('button', {
'style': 'padding: 5px; color: #c44;',
'class': 'cbi-button-negative remove',
'title': _('Delete this volume mount'),
'click': ui.createHandlerFn(view, () => {
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
const updatedVolumes = currentVolumes.filter((_, i) => i !== idx);
view.map.data.set('json', 'container', 'volume', updatedVolumes);
return view.map.render();
})
}, ['✕'])
])) : [E('div', { 'style': 'padding: 5px; color: #999;' }, _('No volumes available'))]),
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(view, () => {
showVolumeModal(null, null);
})
}, [_('Add Mount')])
]);
};
o.rmempty = true;
o = s.option(form.DynamicList, 'publish', _('Exposed Ports(-p)'),
_("Publish container's port(s) to the host"));
o.rmempty = true;
o.placeholder='2200:22/tcp';
o = s.option(form.Value, 'command', _('Run command'));
o.rmempty = true;
o.placeholder='/bin/sh init.sh';
o = s.option(form.Flag, 'advanced', _('Advanced'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Value, 'hostname', _('Host Name'),
_('The hostname to use for the container'));
o.rmempty = true;
o.placeholder='/bin/sh init.sh';
o.depends('advanced', 1);
o = s.option(form.Flag, 'publish_all', _('Exposed All Ports(-P)'),
_("Allocates an ephemeral host port for all of a container's exposed ports"));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'device', _('Device(--device)'),
_('Add host device to the container'));
o.rmempty = true;
o.placeholder='/dev/sda:/dev/xvdc:rwm';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'tmpfs', _('Tmpfs(--tmpfs)'),
_('Mount tmpfs directory'));
o.rmempty = true;
o.placeholder='/run:rw,noexec,nosuid,size=65536k';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'sysctl', _('Sysctl(--sysctl)'),
_('Sysctls (kernel parameters) options'));
o.rmempty = true;
o.placeholder='net.ipv4.ip_forward=1';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'cap_add', _('CAP-ADD(--cap-add)'),
_('A list of kernel capabilities to add to the container'));
o.rmempty = true;
o.placeholder='NET_ADMIN';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpus', _('CPUs'),
_('Number of CPUs. Number is a fractional number. 0.000 means no limit'));
o.rmempty = true;
o.placeholder='1.5';
o.datatype = 'ufloat';
o.depends('advanced', 1);
o.validate = function(section_id, value) {
if (!value) return true;
if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
return true;
};
o = s.option(form.Value, 'cpu_period', _('CPU Period'),
_('The length of a CPU period in microseconds'));
o.rmempty = true;
o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpu_quota', _('CPU Quota'),
_('Microseconds of CPU time that the container can get in a CPU period'));
o.rmempty = true;
o.datatype = 'uinteger';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpu_shares', _('CPU Shares Weight'),
_('CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024'));
o.rmempty = true;
o.placeholder='1024';
o.datatype = 'uinteger';
o.depends('advanced', 1);
o = s.option(form.Value, 'memory', _('Memory'),
_('Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M'));
o.rmempty = true;
o.placeholder = '128m';
o.depends('advanced', 1);
o.write = function(section_id, value) {
if (!value || value == 0) return 0;
this.map.data.data[section_id].memory = view.parseMemory(value);;
return view.parseMemory(value);
};
o.validate = function(section_id, value) {
if (!value) return true;
if (value > view.memory) return _(`Only ${view.memory} bytes available`);
return true;
};
o = s.option(form.Value, 'memory_reservation', _('Memory Reservation'));
o.depends('advanced', 1);
o.placeholder = '128m';
o.cfgvalue = (sid, val) => {
const res = view.map.data.data[sid].memory_reservation;
return res ? '%1024.2m'.format(res) : 0;
};
o.write = function(section_id, value) {
if (!value || value == 0) return 0;
this.map.data.data[section_id].memory_reservation = view.parseMemory(value);;
return view.parseMemory(value);
};
o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'),
_('Block IO weight (relative weight) accepts a weight value between 10 and 1000.'));
o.rmempty = true;
o.placeholder='500';
o.datatype = 'and(uinteger,min(10),max(1000))';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'log_opt', _('Log driver options'),
_('The logging configuration for this container'));
o.rmempty = true;
o.placeholder='max-size=1m';
o.depends('advanced', 1);
this.map = m;
return m.render();
},
handleSave(ev) {
ev?.preventDefault();
const view = this; // Capture the view context
const map = this.map;
if (!map)
return Promise.reject(new Error(_('Form is not ready yet.')));
const listToKv = view.listToKv;
const toBool = (val) => (val === 1 || val === '1' || val === true);
const toInt = (val) => val ? Number.parseInt(val) : undefined;
const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
return map.parse()
.then(() => {
const get = (opt) => map.data.get('json', 'container', opt);
const name = get('name');
const pull = toBool(get('pull'));
const network = get('network');
const publish = get('publish');
const command = get('command');
const publish_all = toBool(get('publish_all'));
const device = get('device');
const tmpfs = get('tmpfs');
const sysctl = get('sysctl');
const log_opt = get('log_opt');
const createBody = {
Hostname: get('hostname'),
User: get('user'),
AttachStdin: toBool(get('interactive')),
Tty: toBool(get('tty')),
OpenStdin: toBool(get('interactive')),
Env: get('env'),
Cmd: command ? command.split(' ') : null,
Image: get('image'),
HostConfig: {
CpuShares: toInt(get('cpu_shares')),
Memory: toInt(get('memory')),
MemoryReservation: toInt(get('memory_reservation')),
BlkioWeight: toInt(get('blkio_weight')),
CapAdd: get('cap_add'),
CpuPeriod: toInt(get('cpu_period')),
CpuQuota: toInt(get('cpu_quota')),
NanoCPUs: toFloat(get('cpus')) * (10 ** 9),
Devices: device ? device
.filter(d => d && typeof d === 'string' && d.trim().length > 0)
.map(d => {
const parts = d.split(':');
return {
PathOnHost: parts[0],
PathInContainer: parts[1] || parts[0],
CgroupPermissions: parts[2] || 'rwm'
};
}) : undefined,
LogConfig: log_opt ? {
Type: 'json-file',
Config: listToKv(log_opt)
} : undefined,
NetworkMode: network,
PortBindings: publish ? Object.fromEntries(
(Array.isArray(publish) ? publish : [publish])
.filter(p => p && typeof p === 'string' && p.trim().length > 0)
.map(p => {
const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
return null;
}).filter(Boolean)
) : undefined,
Mounts: undefined,
Links: get('link'),
Privileged: toBool(get('privileged')),
PublishAllPorts: toBool(get('publish_all')),
RestartPolicy: { Name: get('restart_policy') },
Dns: get('dns'),
Tmpfs: tmpfs ? Object.fromEntries(
(Array.isArray(tmpfs) ? tmpfs : [tmpfs])
.filter(t => t && typeof t === 'string' && t.trim().length > 0)
.map(t => {
const parts = t.split(':');
return [parts[0], parts[1] || ''];
})
) : undefined,
Sysctls: sysctl ? listToKv(sysctl) : undefined,
},
NetworkingConfig: {
EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } },
}
};
// Parse volume entries and populate Mounts
const volumeEntries = get('volume') || [];
const volumeNames = new Set((view.volumes || []).map(v => v.Name));
const volumeIds = new Set((view.volumes || []).map(v => v.Id));
const mounts = [];
for (const entry of volumeEntries) {
let [source, target, options] = (typeof entry === 'string' ? entry : '')?.split(':')?.map(e => e && e.trim() || '');
if (!options) options = '';
// Validate source and target are not empty
if (!source || !target) {
console.warn('Invalid volume entry (empty source or target):', entry);
continue;
}
// Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind
let type = 'bind';
if (source === '@image') {
type = 'image';
} else if (source === '@tmpfs') {
type = 'tmpfs';
} else if (volumeNames.has(source) || volumeIds.has(source)) {
type = 'volume';
}
const mount = {
Type: type,
Source: source,
Target: target,
ReadOnly: options.split(',').includes('ro')
};
// Add type-specific options
if (type === 'bind') {
const bindOptions = {};
const propagation = options.split(',').find(opt =>
['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt)
);
if (propagation) bindOptions.Propagation = propagation;
if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions;
} else if (type === 'volume') {
const volumeOptions = {};
if (options.includes('nocopy')) volumeOptions.NoCopy = true;
if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions;
} else if (type === 'image') {
const imageOptions = {};
const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath='));
if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length);
if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions;
// Image source is implied by selected container image
mount.Source = createBody.Image;
} else if (type === 'tmpfs') {
const tmpfsOptions = {};
const optsList = options.split(',').map(o => o.trim()).filter(Boolean);
for (const opt of optsList) {
if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length));
else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length));
else {
if (!tmpfsOptions.Options) tmpfsOptions.Options = [];
const kv = opt.split('=');
if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]);
else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]);
}
}
mount.Source = '';
if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions;
}
mounts.push(mount);
}
createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined;
// Clean up undefined values
Object.keys(createBody.HostConfig).forEach(key => {
if (createBody.HostConfig[key] === undefined)
delete createBody.HostConfig[key];
});
if (!name)
return Promise.reject(new Error(_('No name specified.')));
return { name, createBody };
})
.then(({ name, createBody }) => view.executeDockerAction(
dm2.container_create,
{ query: { name: name }, body: createBody },
_('Create container'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
const isDuplicate = view.isDuplicate && view.duplicateContainer;
const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created');
const msgText = isDuplicate ?
_('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) :
_('New container has been created.');
if (response?.body?.Warnings) {
view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning');
} else {
view.showNotification(msgTitle, msgText, 4000, 'success');
}
window.location.href = `${this.dockerman_url}/containers`;
}
}
))
.catch((err) => {
view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error');
return false;
});
},
handleSaveApply: null,
handleReset: null,
});

View File

@@ -0,0 +1,360 @@
'use strict';
'require form';
'require fs';
'require poll';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/* API v1.52:
GET /containers/{id}/json: the NetworkSettings no longer returns the deprecated
Bridge, HairpinMode, LinkLocalIPv6Address, LinkLocalIPv6PrefixLen,
SecondaryIPAddresses, SecondaryIPv6Addresses, EndpointID, Gateway,
GlobalIPv6Address, GlobalIPv6PrefixLen, IPAddress, IPPrefixLen, IPv6Gateway,
and MacAddress fields. These fields were deprecated in API v1.21 (docker
v1.9.0) but kept around for backward compatibility.
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.container_list({query: {all: true}}),
dm2.image_list({query: {all: true}}),
dm2.network_list({query: {all: true}}),
]);
},
render([containers, images, networks]) {
if (containers?.code !== 200) {
return E('div', {}, [ containers?.body?.message ]);
}
let container_list = containers.body;
let network_list = networks.body;
let image_list = images.body;
const view = this;
let containerTable;
const m = new form.JSONMap({container: view.getContainersTable(container_list, image_list, network_list), prune: {}},
_('Docker - Containers'),
_('This page displays all docker Containers that have been created on the connected docker host.') + '<br />' +
_('Note: docker provides no container import facility.'));
m.submit = false;
m.reset = false;
let s, o;
let pollPending = null;
let conSec = null;
const calculateTotals = () => {
return {
running_total: Array.isArray(container_list) ?
container_list.filter(c => c?.State === 'running').length : 0,
paused_total: Array.isArray(container_list) ?
container_list.filter(c => c?.State === 'paused').length : 0,
stopped_total: Array.isArray(container_list) ?
container_list.filter(c => ['exited', 'created'].includes(c?.State)).length : 0
};
};
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([containers2, images2, networks2]) => {
image_list = images2.body;
container_list = containers2.body;
network_list = networks2.body;
m.data = new m.data.constructor({ container: view.getContainersTable(container_list, image_list, network_list), prune: {} });
const totals = calculateTotals();
if (conSec) {
conSec.footer = [
`${_('Total')} ${container_list.length}`,
[
`${_('Running')} ${totals.running_total}`,
E('br'),
`${_('Paused')} ${totals.paused_total}`,
E('br'),
`${_('Stopped')} ${totals.stopped_total}`,
],
'',
'',
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
s = m.section(form.TableSection, 'prune', _('Containers overview'), null);
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(section_id, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/containers/prune',
commandDPath: '/containers/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch { }
},
noFileUpload: true,
}]);
}, this);
const totals = calculateTotals();
let running_total = totals.running_total;
let paused_total = totals.paused_total;
let stopped_total = totals.stopped_total;
conSec = m.section(form.TableSection, 'container');
conSec.anonymous = true;
conSec.nodescriptions = true;
conSec.addremove = true;
conSec.sortable = true;
conSec.filterrow = true;
conSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
conSec.footer = [
`${_('Total')} ${container_list.length}`,
[
`${_('Running')} ${running_total}`,
E('br'),
`${_('Paused')} ${paused_total}`,
E('br'),
`${_('Stopped')} ${stopped_total}`,
],
'',
'',
];
conSec.handleAdd = function(section_id, ev) {
window.location.href = `${view.dockerman_url}/container_new`;
};
conSec.renderRowActions = function(sid) {
const cont = this.map.data.data[sid];
return view.buildContainerActions(cont);
}
o = conSec.option(form.DummyValue, 'cid', _('Container'));
o = conSec.option(form.DummyValue, 'State', _('State'));
o = conSec.option(form.DummyValue, 'Networks', _('Networks'));
o.rawhtml = true;
// o = conSec.option(form.DummyValue, 'Ports', _('Ports'));
// o.rawhtml = true;
o = conSec.option(form.DummyValue, 'Command', _('Command'));
o.width = 200;
o = conSec.option(form.DummyValue, 'Created', _('Created'));
poll.add(L.bind(() => { refresh(); }, this), 10);
this.insertOutputFrame(conSec, m);
return m.render();
},
buildContainerActions(cont, idx) {
const view = this;
const isRunning = cont?.State === 'running';
const isPaused = cont?.State === 'paused';
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': () => view.executeDockerAction(
dm2.container_inspect,
{id: cont.Id},
dm2.ActionTypes['inspect'].i18n,
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['inspect'].e]),
E('button', {
'class': 'cbi-button cbi-button-positive edit',
'title': _('Edit this container'),
'click': () => window.location.href = `${view.dockerman_url}/container/${cont?.Id}`
}, [dm2.ActionTypes['edit'].e]),
(() => {
const icon = isRunning
? dm2.Types['container'].sub['pause'].e
: (isPaused
? dm2.Types['container'].sub['unpause'].e
: dm2.Types['container'].sub['start'].e);
const title = isRunning
? _('Pause this container')
: (isPaused ? _('Unpause this container') : _('Start this container'));
const handler = isRunning
? () => view.executeDockerAction(
dm2.container_pause,
{id: cont.Id},
dm2.Types['container'].sub['pause'].i18n,
{showOutput: true, showSuccess: false}
)
: (isPaused ? () => view.executeDockerAction(
dm2.container_unpause,
{id: cont.Id},
dm2.Types['container'].sub['unpause'].i18n,
{showOutput: true, showSuccess: false}
) : () => view.executeDockerAction(
dm2.container_start,
{id: cont.Id},
dm2.Types['container'].sub['start'].i18n,
{showOutput: true, showSuccess: false}
));
const btnClass = isRunning ? 'cbi-button cbi-button-neutral' : 'cbi-button cbi-button-positive start';
return E('button', {
'class': btnClass,
'title': title,
'click': handler,
}, [icon]);
})(),
E('button', {
'class': 'cbi-button cbi-button-neutral restart',
'title': _('Restart this container'),
'click': () => view.executeDockerAction(
dm2.container_restart,
{id: cont.Id},
_('Restart'),
{showOutput: true, showSuccess: false}
)
}, [dm2.Types['container'].sub['restart'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral stop',
'title': _('Stop this container'),
'click': () => view.executeDockerAction(
dm2.container_stop,
{id: cont.Id},
dm2.Types['container'].sub['stop'].i18n,
{showOutput: true, showSuccess: false}
),
'disabled' : !(isRunning || isPaused) ? true : null
}, [dm2.Types['container'].sub['stop'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative kill',
'title': _('Kill this container'),
'click': () => view.executeDockerAction(
dm2.container_kill,
{id: cont.Id},
dm2.Types['container'].sub['kill'].i18n,
{showOutput: true, showSuccess: false}
),
'disabled' : !(isRunning || isPaused) ? true : null
}, [dm2.Types['container'].sub['kill'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral export',
'title': _('Export this container'),
'click': () => {
window.location.href = `${view.dockerman_url}/container/export/${cont.Id}`;
}
}, [dm2.Types['container'].sub['export'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': () => view.executeDockerAction(
dm2.container_remove,
{id: cont.Id, query: { force: false }},
dm2.ActionTypes['remove'].i18n,
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': () => view.executeDockerAction(
dm2.container_remove,
{id: cont.Id, query: { force: true }},
_('Force Remove'),
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', {
'class': 'td',
}, E('div', btns));
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getContainersTable(containers, image_list, network_list) {
const data = [];
for (const cont of Array.isArray(containers) ? containers : []) {
// build Container ID: xxxxxxx image: xxxx
const names = Array.isArray(cont?.Names) ? cont.Names : [];
const cleanedNames = names
.map(n => (typeof n === 'string' ? n.substring(1) : ''))
.filter(Boolean)
.join(', ');
const statusColorName = this.wrapStatusText(cleanedNames, cont.State, 'font-weight:600;');
const imageName = this.getImageFirstTag(image_list, cont.ImageID);
const shortId = (cont?.Id || '').substring(0, 12);
const cid = E('div', {}, [
E('a', { href: `container/${cont.Id}`, title: dm2.ActionTypes['edit'].i18n }, [
statusColorName,
E('div', { 'style': 'font-size: 0.9em; font-family: monospace; ' }, [`ID: ${shortId}`]),
]),
E('div', { 'style': 'font-size: 0.85em;' }, [`${dm2.Types['image'].i18n}: ${imageName}`]),
])
// Just push plain data objects without UCI metadata
data.push({
...cont,
cid: cid,
_shortId: (cont?.Id || '').substring(0, 12),
Networks: this.parseNetworkLinksForContainer(network_list, cont?.NetworkSettings?.Networks || {}, true),
Created: this.buildTimeString(cont?.Created) || '',
Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
? cont.Ports.map(p => {
const ip = p.IP || '';
const pub = p.PublicPort || '';
const priv = p.PrivatePort || '';
const type = p.Type || '';
return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
}).join('<br/>')
: '',
});
}
return data;
},
});

View File

@@ -0,0 +1,327 @@
'use strict';
'require form';
'require fs';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
LICENSE: GPLv2.0
*/
/* API v1.52
GET /events supports content-type negotiation and can produce either
application/x-ndjson (Newline delimited JSON object stream) or
application/json-seq (RFC7464).
application/x-ndjson:
{"some":"thing\n"}
{"some2":"thing2\n"}
...
application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
␞{"some":"thing\n"}␊
␞{"some2":"thing2\n"}␊
...
*/
return dm2.dv.extend({
load() {
const now = Math.floor(Date.now() / 1000);
return Promise.all([
dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
]);
},
render([events]) {
if (events?.code !== 200) {
return E('div', {}, [ events?.body?.message ]);
}
this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : '';
const event_list = events?.body || [];
const view = this;
const mainContainer = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, [_('Docker - Events')])
]);
// Filters
const now = new Date();
const nowIso = now.toISOString().slice(0, 16);
const filtersSection = E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Type')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'event-type-filter',
'class': 'cbi-input-select',
'change': () => {
view.updateSubtypeFilter(this.value);
view.renderEventsTable(event_list);
}
}, [
E('option', { 'value': '' }, _('All Types')),
...Object.keys(dm2.Types).map(type =>
E('option', { 'value': type }, `${dm2.Types[type].e} ${dm2.Types[type].i18n}`)
)
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Subtype')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'event-subtype-filter',
'class': 'cbi-input-select',
'disabled': true,
'change': () => {
view.renderEventsTable(event_list);
}
}, [
E('option', { 'value': '' }, _('Select Type First'))
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('From')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'id': 'event-from-date',
'type': 'datetime-local',
'value': '1970-01-01T00:00',
'step': 60,
'style': 'width: 180px;',
'change': () => { view.renderEventsTable(event_list); }
}),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const now = new Date();
const iso = now.toISOString().slice(0,16);
document.getElementById('event-from-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('Now')),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const unixzero = new Date(0);
const iso = unixzero.toISOString().slice(0,16);
document.getElementById('event-from-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('0'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('To')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'id': 'event-to-date',
'type': 'datetime-local',
'value': nowIso,
'step': 60,
'style': 'width: 180px;',
'change': () => { view.renderEventsTable(event_list); }
}),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const now = new Date();
const iso = now.toISOString().slice(0,16);
document.getElementById('event-to-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('Now'))
])
])
])
]);
mainContainer.appendChild(filtersSection);
this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' });
mainContainer.appendChild(this.tableSection);
this.renderEventsTable(event_list);
mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null));
return mainContainer;
},
renderEventsTable(event_list) {
const view = this;
// Get filter values
const typeFilter = document.getElementById('event-type-filter')?.value || '';
const subtypeFilter = document.getElementById('event-subtype-filter')?.value || '';
// Build filters object for docker_events API
const filters = {};
if (typeFilter) {
filters.type = [typeFilter];
}
if (subtypeFilter) {
filters.event = [subtypeFilter];
}
// Show loading indicator
this.tableSection.innerHTML = '';
// Query docker events with filters and date range
const fromInput = document.getElementById('event-from-date');
const toInput = document.getElementById('event-to-date');
let since = '0';
let until = Math.floor(Date.now() / 1000).toString();
if (fromInput && fromInput.value) {
const fromDate = new Date(fromInput.value);
if (!isNaN(fromDate.getTime())) {
since = Math.floor(fromDate.getTime() / 1000).toString();
since = since < 0 ? 0 : since;
}
}
if (toInput && toInput.value) {
const toDate = new Date(toInput.value);
if (!isNaN(toDate.getTime())) {
const now = Date.now() / 1000;
until = Math.floor(toDate.getTime() / 1000).toString();
until = until > now ? now : until;
}
}
const queryParams = { since, until };
if (Object.keys(filters).length > 0) {
// docker pre v27: filters => docker *streams* events. v27, send events in body.
// Some older dockerd endpoints don't like encoded filter params, even if we can't stream.
queryParams.filters = JSON.stringify(filters);
}
event_list = new Set();
view.outputText = '';
let eventsTable = null;
function updateTable() {
const ev_array = Array.from(event_list.keys());
const rows = ev_array.map(event => {
const type = event.Type;
const typeInfo = dm2.Types[type];
const typeDisplay = typeInfo ? `${typeInfo.e} ${typeInfo.i18n}` : type;
const actionParts = event.Action?.split(':') || [];
const action = actionParts.length > 0 ? actionParts[0] : '';
const action_sub = actionParts.length > 1 ? actionParts[1] : null;
const actionInfo = typeInfo?.sub?.[action];
const actionDisplay = actionInfo ? `${actionInfo.e} ${actionInfo.i18n}${action_sub ? ':'+action_sub : ''}` : action;
return [
view.buildTimeString(event.time),
typeDisplay,
actionDisplay,
view.objectToText(event.Actor),
event.scope || ''
];
});
const output = JSON.stringify(ev_array, null, 2);
view.outputText = output + '\n';
view.insertOutput(view.outputText);
if (!eventsTable) {
eventsTable = new L.ui.Table(
[_('Time'), _('Type'), _('Action'), _('Actor'), _('Scope')],
{ id: 'events-table', style: 'width: 100%; table-layout: auto;' },
E('em', [_('No events found')])
);
view.tableSection.innerHTML = '';
view.tableSection.appendChild(eventsTable.render());
}
eventsTable.update(rows);
}
view.tableSection.innerHTML = '';
/* Partial transfers work but XHR times out waiting, even with xhr.timeout = 0 */
// view.handleXHRTransfer({
// q_params:{ query: queryParams },
// commandCPath: '/docker/events',
// commandDPath: '/events',
// commandTitle: dm2.ActionTypes['prune'].i18n,
// showProgress: false,
// onUpdate: (msg) => {
// try {
// if(msg.error)
// ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
// event_list.add(msg);
// updateTable();
// const output = JSON.stringify(msg, null, 2) + '\n';
// view.insertOutput(output);
// } catch {
// }
// },
// noFileUpload: true,
// });
view.executeDockerAction(
dm2.docker_events,
{ query: queryParams },
_('Load Events'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
if (response.body)
event_list = Array.isArray(response.body) ? new Set(response.body) : new Set([response.body]);
updateTable();
},
onError: (err) => {
view.tableSection.innerHTML = '';
view.tableSection.appendChild(E('em', { 'style': 'color: red;' }, _('Failed to load events: %s').format(err?.message || err)));
}
}
);
},
updateSubtypeFilter(selectedType) {
const subtypeSelect = document.getElementById('event-subtype-filter');
if (!subtypeSelect) return;
// Clear existing options
subtypeSelect.innerHTML = '';
if (!selectedType || !dm2.Types[selectedType] || !dm2.Types[selectedType].sub) {
subtypeSelect.disabled = true;
subtypeSelect.appendChild(E('option', { 'value': '' }, _('Select Type First')));
return;
}
// Enable and populate with subtypes
subtypeSelect.disabled = false;
subtypeSelect.appendChild(E('option', { 'value': '' }, _('All Subtypes')));
const subtypes = dm2.Types[selectedType].sub;
for (const action in subtypes) {
subtypeSelect.appendChild(
E('option', { 'value': action }, `${subtypes[action].e} ${subtypes[action].i18n}`)
);
}
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
});

View File

@@ -0,0 +1,724 @@
'use strict';
'require form';
'require fs';
'require poll';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.image_list(),
dm2.container_list({query: {all: true}}),
])
},
render([images, containers]) {
if (images?.code !== 200) {
return E('div', {}, [ images.body.message ]);
}
let image_list = this.getImagesTable(images.body);
let container_list = containers.body;
const view = this; // Capture the view context
view.selectedImages = {};
let s, o;
const m = new form.JSONMap({image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {}},
_('Docker - Images'),
_('On this page all images are displayed that are available on the system and with which a container can be created.'));
m.submit = false;
m.reset = false;
let pollPending = null;
let imgSec = null;
const calculateSizeTotal = () => {
return Array.isArray(image_list) ? image_list.map(c => c?.Size).reduce((acc, e) => acc + e, 0) : 0;
};
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([images2, containers2]) => {
image_list = view.getImagesTable(images2.body);
container_list = containers2.body;
m.data = new m.data.constructor({ image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {} });
const size_total = calculateSizeTotal();
if (imgSec) {
imgSec.footer = [
'',
`${_('Total')} ${image_list.length}`,
'',
`${'%1024mB'.format(size_total)}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
// Pull image
s = m.section(form.TableSection, 'pull', dm2.Types['image'].sub['pull'].i18n,
_('By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry.'));
s.anonymous = true;
s.addremove = false;
const splitImageTag = (value) => {
const input = String(value || '').trim();
if (!input || input.includes(' ')) return { name: '', tag: 'latest' };
const lastSlash = input.lastIndexOf('/');
const lastColon = input.lastIndexOf(':');
if (lastColon > lastSlash) {
return {
name: input.slice(0, lastColon) || input,
tag: input.slice(lastColon + 1) || 'latest'
};
}
return { name: input, tag: 'latest' };
};
let tagOpt = s.option(form.Value, '_image_tag_name');
tagOpt.placeholder = "[registry.io[:443]/]foobar/product:latest";
o = s.option(form.Button, '_pull');
o.inputtitle = `${dm2.Types['image'].sub['pull'].i18n} ${dm2.Types['image'].sub['pull'].e}`; // _('Pull') + ' ☁️⬇️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const raw = tagOpt.formvalue('pull') || '';
const input = String(raw).trim();
if (!input) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['pull'].i18n, _('Please enter an image tag'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(input);
return this.super('handleXHRTransfer', [{
q_params: { query: { fromImage: name, tag: ver } },
commandCPath: `/images/create`,
commandDPath: `/images/create`,
commandTitle: dm2.Types['image'].sub['pull'].i18n,
successMessage: _('Image create completed'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_create,
// { query: { fromImage: name, tag: ver } },
// dm2.Types['image'].sub['pull'].i18n,
// {
// showOutput: true,
// successMessage: _('Image create completed')
// }
// );
}, this);
// Push image
s = m.section(form.TableSection, 'push', dm2.Types['image'].sub['push'].i18n,
_('Push an image to a registry. Select an image tag from all available tags on the system.'));
s.anonymous = true;
s.addremove = false;
// Build a list of all available tags across all images
const allImageTags = [];
for (const image of image_list) {
const tags = Array.isArray(image.RepoTags) ? image.RepoTags : [];
for (const tag of tags) {
if (tag && tag !== '<none>:<none>') {
allImageTags.push(tag);
}
}
}
let pushTagOpt = s.option(form.Value, '_image_tag_push');
pushTagOpt.placeholder = _('Select image tag');
if (allImageTags.length === 0) {
pushTagOpt.value('', _('No image tags available'));
} else {
// Add all unique tags to the dropdown
const uniqueTags = [...new Set(allImageTags)].sort();
for (const tag of uniqueTags) {
pushTagOpt.value(tag, tag);
}
}
o = s.option(form.Button, '_push');
o.inputtitle = `${dm2.Types['image'].sub['push'].i18n} ${dm2.Types['image'].sub['push'].e}`; // _('Push') + ' ☁️⬆️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const selected = pushTagOpt.formvalue('push') || '';
if (!selected) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['push'].i18n, _('Please select an image tag to push'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(selected);
return this.super('handleXHRTransfer', [{
// Pass name in q_params to trigger building X-Registry-Auth header
q_params: { name: name, query: { tag: ver } },
commandCPath: `/images/push/${name}`,
commandDPath: `/images/${name}/push`,
commandTitle: dm2.Types['image'].sub['push'].i18n,
successMessage: _('Image push completed'),
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_push,
// { name: name, query: { tag: ver} },
// dm2.Types['image'].sub['push'].i18n,
// {
// showOutput: true,
// successMessage: _('Image push completed')
// }
// );
}, this);
s = m.section(form.TableSection, 'build', dm2.ActionTypes['build'].i18n,
_('Build an image.') + ' ' + _('git repositories require git installed on the docker host.'));
s.anonymous = true;
s.addremove = false;
let buildOpt = s.option(form.Value, '_image_build_uri');
buildOpt.placeholder = "https://host/foo/bar.git | https://host/foobar.tar";
let buildTagOpt = s.option(form.Value, '_image_build_tag');
buildTagOpt.placeholder = 'repository:tag';
o = s.option(form.Button, '_build');
o.inputtitle = `${dm2.ActionTypes['build'].i18n} ${dm2.ActionTypes['build'].e}`; // _('Build') + ' 🏗️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const uri = buildOpt.formvalue('build') || '';
const t = buildTagOpt.formvalue('build') || '';
const q_params = { q: encodeURIComponent('false'), t: t };
if (uri) q_params.remote = encodeURIComponent(uri);
return this.super('handleXHRTransfer', [{
q_params: { query: q_params },
commandCPath: '/images/build',
commandDPath: '/build',
commandTitle: dm2.ActionTypes['build'].i18n,
successMessage: _('Image loaded successfully'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: !!uri,
}]);
}, this);
o = s.option(form.Button, '_delete_cache', null);
o.inputtitle = `${dm2.ActionTypes['clean'].i18n} ${dm2.ActionTypes['clean'].e}`;
o.inputstyle = 'negative';
o.onclick = L.bind(function(ev, btn) {
return this.super('handleXHRTransfer', [{
q_params: { query: { all: 'true' } },
commandCPath: '/images/build/prune',
commandDPath: '/build/prune',
commandTitle: dm2.Types['builder'].sub['prune'].i18n,
successMessage: _('Cleaned build cache'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['clean'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
}, this);
// Import image
s = m.section(form.TableSection, 'import', dm2.Types['image'].sub['import'].i18n,
_('Download a valid remote image tar.'));
s.addremove = false;
s.anonymous = true;
let imgsrc = s.option(form.Value, '_image_source');
imgsrc.placeholder = 'https://host/image.tar';
let tagimpOpt = s.option(form.Value, '_import_image_tag_name');
tagimpOpt.placeholder = 'repository:tag';
let importBtn = s.option(form.Button, '_import');
importBtn.inputtitle = `${dm2.Types['image'].sub['import'].i18n} ${dm2.Types['image'].sub['import'].e}` //_('Import') + ' ➡️';
importBtn.inputstyle = 'add';
importBtn.onclick = L.bind(function(ev, btn) {
const rawtag = tagimpOpt.formvalue('import') || '';
const input = String(rawtag).trim();
if (!input) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image repo tag'), 4000, 'warning');
return false;
}
const rawremote = imgsrc.formvalue('import') || '';
let remote = String(rawremote).trim();
if (!remote) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image source'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(input);
return this.super('handleXHRTransfer', [{
q_params: { query: { fromSrc: remote, repo: ver } },
commandCPath: '/images/create',
commandDPath: '/images/create',
commandTitle: dm2.Types['image'].sub['create'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.Types['image'].sub['create'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_create,
// { query: { fromSrc: remote, repo: ver } },
// dm2.Types['image'].sub['import'].i18n,
// {
// showOutput: true,
// successMessage: _('Image create started/completed')
// }
// );
}, this);
s = m.section(form.TableSection, 'prune', _('Images overview'), );
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(ev, btn) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/images/prune',
commandDPath: '/images/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => refresh(),
// }
// );
}, this);
o = s.option(form.Button, '_export', null);
o.inputtitle = `${dm2.ActionTypes['save'].i18n} ${dm2.ActionTypes['save'].e}`;
o.inputstyle = 'cbi-button-positive';
o.onclick = L.bind(function(ev, btn) {
ev.preventDefault();
const selected = Object.keys(view.selectedImages).filter(k => view.selectedImages[k]);
if (!selected.length) {
ui.addTimeLimitedNotification(_('Export'), _('No images selected'), 3000, 'warning');
return;
}
// Get tags or IDs for selected images
const names = selected.map(sid => {
const image = s.map.data.data[sid];
const tag = image?.RepoTags?.[0];
return tag || image?.Id?.substr(12);
});
// http.uc does not yet handle parameter arrays, so /images/get needs access to the URL params
window.location.href = `${view.dockerman_url}/images/get?${names.map(e => `names=${e}`).join('&')}`;
}, this);
const size_total = calculateSizeTotal();
imgSec = m.section(form.TableSection, 'image');
imgSec.anonymous = true;
imgSec.nodescriptions = true;
imgSec.addremove = true;
imgSec.sortable = true;
imgSec.filterrow = true;
imgSec.addbtntitle = `${dm2.ActionTypes['upload'].i18n} ${dm2.ActionTypes['upload'].e}`;
imgSec.footer = [
'',
`${_('Total')} ${image_list.length}`,
'',
`${'%1024mB'.format(size_total)}`,
];
imgSec.handleAdd = function(sid, ev) {
return view.handleFileUpload();
};
imgSec.handleGet = function(image, ev) {
const tag = image.RepoTags?.[0];
const name = tag || image.Id.substr(12);
// Direct HTTP download - avoid RPC
window.location.href = `${view.dockerman_url}/images/get/${name}`;
return true;
};
imgSec.handleRemove = function(sid, image, force=false, ev) {
return view.executeDockerAction(
dm2.image_remove,
{ id: image.Id, query: { force: force } },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
delete this.map.data.data[sid];
return this.super('handleRemove', [ev]);
}
}
);
};
imgSec.handleInspect = function(image, ev) {
return view.executeDockerAction(
dm2.image_inspect,
{ id: image.Id },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
imgSec.handleHistory = function(image, ev) {
return view.executeDockerAction(
dm2.image_history,
{ id: image.Id },
dm2.ActionTypes['history'].i18n,
{ showOutput: true, showSuccess: false }
);
};
imgSec.renderRowActions = function (sid) {
const image = this.map.data.data[sid];
const btns = [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, image),
}, [dm2.ActionTypes['inspect'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': dm2.ActionTypes['history'].i18n,
'click': ui.createHandlerFn(this, this.handleHistory, image),
}, [dm2.ActionTypes['history'].e]),
E('button', {
'class': 'cbi-button cbi-button-positive save',
'title': dm2.ActionTypes['save'].i18n,
'click': ui.createHandlerFn(this, this.handleGet, image),
}, [dm2.ActionTypes['save'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, false),
'disabled': image?._disable_delete,
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, true),
'disabled': image?._disable_delete,
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
o = imgSec.option(form.Flag, '_selected');
o.onchange = function(ev, sid, value) {
if (value == 1) {
view.selectedImages[sid] = value;
}
else {
delete view.selectedImages[sid];
}
return;
}
o = imgSec.option(form.DummyValue, 'RepoTags', dm2.Types['image'].sub['tag'].e);
o.cfgvalue = function(sid) {
const image = this.map.data.data[sid];
const tags = Array.isArray(image?.RepoTags) ? image.RepoTags : [];
if (tags.length === 0 || (tags.length === 1 && tags[0] === '<none>:<none>'))
return '<none>';
const tagLinks = tags.map(tag => {
if (tag === '<none>:<none>')
return E('span', {}, tag);
/* last tag - don't link it - last tag removal == delete */
if (tags.length === 1)
return tag;
return E('a', {
'href': '#',
'title': _('Click to remove this tag'),
'click': ui.createHandlerFn(view, (tag, imageId, ev) => {
ev.preventDefault();
ui.showModal(_('Remove tag'), [
E('p', {}, _('Do you want to remove the tag "%s"?').format(tag)),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, '↩'),
' ',
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(view, () => {
ui.hideModal();
return view.executeDockerAction(
dm2.image_remove,
{ id: tag, query: { noprune: 'true' } },
dm2.Types['image'].sub['untag'].i18n,
{
showOutput: true,
successMessage: _('Tag removed successfully'),
successDuration: 4000,
onSuccess: () => refresh(),
}
);
})
}, dm2.Types['image'].sub['untag'].e)
])
]);
}, tag, image.Id)
}, tag);
});
// Join with commas and spaces
const content = [];
for (const [i, tag] of tagLinks.entries()) {
if (i > 0) content.push(', ');
content.push(tag);
}
return E('span', {}, content);
};
o = imgSec.option(form.DummyValue, 'Containers', _('Containers'));
o.cfgvalue = function(sid) {
const imageId = this.map.data.data[sid].Id;
// Collect all matching container name links for this image
const anchors = container_list.reduce((acc, container) => {
if (container?.ImageID !== imageId) return acc;
for (const name of container?.Names || [])
acc.push(E('a', { href: `container/${container.Id}` }, [ name.substring(1) ]));
return acc;
}, []);
// Interleave separators
if (!anchors.length) return E('div', {});
const content = [];
for (let i = 0; i < anchors.length; i++) {
if (i) content.push(' | ');
content.push(anchors[i]);
}
return E('div', {}, content);
};
o = imgSec.option(form.DummyValue, 'Size', _('Size'));
o.cfgvalue = function(sid) {
const s = this.map.data.data[sid].Size;
return '%1024mB'.format(s);
};
imgSec.option(form.DummyValue, 'Created', _('Created'));
o = imgSec.option(form.DummyValue, '_id', _('ID'));
/* Remember: we load a JSONMap - so uci config is non-existent for these
elements, so we must pull from this.map.data, otherwise o.load returns nothing */
o.cfgvalue = function(sid) {
const image = this.map.data.data[sid];
const shortId = image?._id || '';
const fullId = image?.Id || '';
return E('a', {
'href': '#',
'style': 'font-family: monospace',
'title': _('Click to add a new tag to this image'),
'click': ui.createHandlerFn(view, function(imageId, ev) {
ev.preventDefault();
let repoInput, tagInput;
ui.showModal(_('New tag'), [
E('p', {}, _('Enter a new tag for image %s:').format(imageId.slice(7, 19))),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Repository')),
E('div', { 'class': 'cbi-value-field' }, [
repoInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': '[registry.io[:443]/]myrepo/myimage'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Tag')),
E('div', { 'class': 'cbi-value-field' }, [
tagInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'latest',
'value': 'latest'
})
])
]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, ['↩']),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const repo = repoInput.value.trim();
const tag = tagInput.value.trim() || 'latest';
if (!repo) {
ui.addTimeLimitedNotification(null, [_('Repository cannot be empty')], 3000, 'warning');
return;
}
ui.hideModal();
return view.executeDockerAction(
dm2.image_tag,
{ id: imageId, query: { repo: repo, tag: tag } },
dm2.Types['image'].sub['tag'].i18n,
{
showOutput: true,
successMessage: _('Tag added successfully'),
successDuration: 4000,
onSuccess: () => refresh(),
}
);
})
}, [dm2.Types['image'].sub['tag'].e])
])
]);
}, fullId)
}, shortId);
};
this.insertOutputFrame(s, m);
poll.add(L.bind(() => { refresh(); }, this), 10);
return m.render();
},
handleFileUpload() {
// const uploadUrl = `?quiet=${encodeURIComponent('false')}`;
return this.super('handleXHRTransfer', [{
q_params: { query: { quiet: 'false' } },
commandCPath: `/images/load`,
commandDPath: `/images/load`,
commandTitle: _('Uploading…'),
commandMessage: _('Uploading image…'),
successMessage: _('Image loaded successfully'),
defaultPath: '/tmp'
}]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getImagesTable(images) {
const data = [];
for (const image of images) {
// Just push plain data objects without UCI metadata
data.push({
...image,
_disable_delete: null,
_id: (image.Id || '').substring(7, 20),
Created: this.buildTimeString(image.Created) || '',
});
}
return data;
},
});

View File

@@ -0,0 +1,165 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
const requestPath = L.env.requestpath;
const netId = requestPath[requestPath.length-1] || '';
this.networkId = netId;
return Promise.all([
dm2.network_inspect({ id: netId }),
dm2.container_list({query: {all: true}}),
]);
},
render([network, containers]) {
if (network?.code !== 200) {
window.location.href = `${this.dockerman_url}/networks`;
return;
}
const view = this;
const this_network = network.body || {};
const container_list = Array.isArray(containers.body) ? containers.body : [];
const m = new form.JSONMap({
network: this_network,
Driver: this_network?.IPAM?.Driver,
Config: this_network?.IPAM?.Config,
Containers: Object.entries(this_network?.Containers || {}).map(([id, info]) => ({ id, ...info })),
_inspect: {},
},
_('Docker - Networks'),
_('This page displays all docker networks that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
let s = m.section(form.NamedSection, 'network', _('Networks overview'));
s.anonymous = true;
s.addremove = false;
s.nodescriptions = true;
let o, t, ss;
// INFO TAB
t = s.tab('info', _('Info'));
o = s.taboption('info', form.DummyValue, 'Name', _('Network Name'));
o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
o = s.taboption('info', form.DummyValue, 'Scope', _('Scope'));
o = s.taboption('info', form.DummyValue, 'Driver', _('Driver'));
o = s.taboption('info', form.Flag, 'EnableIPv6', _('IPv6'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Internal', _('Internal'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Attachable', _('Attachable'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Ingress', _('Ingress'));
o.readonly = true;
o = s.taboption('info', form.DummyValue, 'ConfigFrom', _('ConfigFrom'));
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.Flag, 'ConfigOnly', _('Config Only'));
o.readonly = true;
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.DummyValue, 'Containers', _('Containers'));
o.load = function(sid) {
return view.parseContainerLinksForNetwork(this_network, container_list);
};
o = s.taboption('info', form.DummyValue, 'Options', _('Options'));
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.DummyValue, 'Labels', _('Labels'));
o.cfgvalue = view.objectCfgValueTT;
// CONFIGS TAB
t = s.tab('detail', _('Detail'));
o = s.taboption('detail', form.DummyValue, 'Driver', _('IPAM Driver'));
o = s.taboption('detail', form.SectionValue, '_conf_', form.TableSection, 'Config', _('Network Configurations'));
ss = o.subsection;
ss.anonymous = true;
ss.option(form.DummyValue, 'Subnet', _('Subnet'));
ss.option(form.DummyValue, 'Gateway', _('Gateway'));
o = s.taboption('detail', form.SectionValue, '_cont_', form.TableSection, 'Containers', _('Containers'));
ss = o.subsection;
ss.anonymous = true;
o = ss.option(form.DummyValue, 'Name', _('Name'));
o.cfgvalue = function(sid) {
const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
const containerId = container_list.find(c => c.Names.find(e => e.substring(1) === val)).Id;
return E('a', {
href: `${view.dockerman_url}/container/${containerId}`,
title: containerId,
style: 'white-space: nowrap;'
}, [val]);
};
ss.option(form.DummyValue, 'MacAddress', _('Mac Address'));
ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
// Show IPv6 column when at least one entry contains a non-empty IPv6Address
const _networkContainers = Object.values(this_network?.Containers || {});
const _hasIPv6 = _networkContainers.some(c => c?.IPv6Address && String(c.IPv6Address).trim() !== '');
if (_hasIPv6) {
ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
}
// INSPECT TAB
t = s.tab('inspect', _('Inspect'));
o = s.taboption('inspect', form.SectionValue, '__ins__', form.NamedSection, '_inspect', null);
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = true;
o = ss.option(form.Button, '_inspect_button', null);
o.inputtitle = `${dm2.ActionTypes['inspect'].i18n} ${dm2.ActionTypes['inspect'].e}`;
o.inputstyle = 'neutral';
o.onclick = L.bind(function(section_id, ev) {
return dm2.network_inspect({ id: this_network.Id }).then((response) => {
const inspectField = document.getElementById('inspect-output-text');
if (inspectField && response?.body) {
inspectField.textContent = JSON.stringify(response.body, null, 2);
}
});
}, this);
o = s.taboption('inspect', form.SectionValue, '__insoutput__', form.NamedSection, null, null);
o.render = L.bind(() => {
return this.insertOutputFrame(null, null);
}, this);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
});

View File

@@ -0,0 +1,257 @@
'use strict';
'require form';
'require fs';
'require ui';
'require tools.widgets as widgets';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
]);
},
render([]) {
// stuff JSONMap with {network: {}} to prime it with a new empty entry
const m = new form.JSONMap({network: {}}, _('Docker - New Network'));
m.submit = true;
m.reset = true;
let s = m.section(form.NamedSection, 'network', _('Create new docker network'));
s.anonymous = true;
s.nodescriptions = true;
s.addremove = false;
let o;
o = s.option(form.Value, 'name', _('Network Name'),
_('Name of the network that can be selected during container creation'));
o.rmempty = true;
o = s.option(form.ListValue, 'driver', _('Driver'));
o.rmempty = true;
o.value('bridge', _('Bridge device'));
o.value('macvlan', _('MAC VLAN'));
o.value('ipvlan', _('IP VLAN'));
o.value('overlay', _('Overlay network'));
o = s.option(widgets.DeviceSelect, 'parent', _('Base device'));
o.rmempty = true;
o.create = false
o.noaliases = true;
o.nocreate = true;
o.depends('driver', 'macvlan');
o = s.option(form.ListValue, 'macvlan_mode', _('Mode'));
o.rmempty = true;
o.depends('driver', 'macvlan');
o.default = 'bridge';
o.value('bridge', _('Bridge (Support direct communication between MAC VLANs)'));
o.value('private', _('Private (Prevent communication between MAC VLANs)'));
o.value('vepa', _('VEPA (Virtual Ethernet Port Aggregator)'));
o.value('passthru', _('Pass-through (Mirror physical device to single MAC VLAN)'));
o = s.option(form.ListValue, 'ipvlan_mode', _('Ipvlan Mode'));
o.rmempty = true;
o.depends('driver', 'ipvlan');
o.default='l3';
o.value('l2', _('L2 bridge'));
o.value('l3', _('L3 bridge'));
o = s.option(form.Flag, 'ingress',
_('Ingress'),
_('Ingress network is the network which provides the routing-mesh in swarm mode'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o.depends('driver', 'overlay');
o = s.option(form.DynamicList, 'options', _('Options'));
o.rmempty = true;
o.placeholder='com.docker.network.driver.mtu=1500';
o = s.option(form.DynamicList, 'labels', _('Labels'));
o.rmempty = true;
o.placeholder='foo=bar';
o = s.option(form.Flag, 'internal', _('Internal'), _('Restrict external access to the network'));
o.rmempty = true;
o.depends('driver', 'overlay');
o.disabled = 0;
o.enabled = 1;
o.default = o.disabled;
// if nixio.fs.access('/etc/config/network') and nixio.fs.access('/etc/config/firewall')then
// o = s.option(form.Flag, 'op_macvlan', _('Create macvlan interface'), _('Auto create macvlan interface in Openwrt'))
// o.depends('driver', 'macvlan')
// o.disabled = 0
// o.enabled = 1
// o.default = 1
// end
o = s.option(form.Value, 'subnet', _('Subnet'));
o.rmempty = true;
o.placeholder = '10.1.0.0/16';
o.datatype = 'ip4addr';
o = s.option(form.Value, 'gateway', _('Gateway'));
o.rmempty = true;
o.placeholder = '10.1.1.1';
o.datatype = 'ip4addr';
o = s.option(form.Value, 'ip_range', _('IP range'));
o.rmempty = true;
o.placeholder='10.1.1.0/24';
o.datatype = 'ip4addr';
o = s.option(form.DynamicList, 'aux_address', _('Exclude IPs'));
o.rmempty = true;
o.placeholder = 'my-route=10.1.1.1';
o = s.option(form.Flag, 'ipv6', _('Enable IPv6'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = o.disabled;
o = s.option(form.Value, 'subnet6', _('IPv6 Subnet'));
o.rmempty = true;
o.placeholder='fe80::/10'
o.datatype = 'ip6addr';
o.depends('ipv6', 1);
o = s.option(form.Value, 'gateway6', _('IPv6 Gateway'));
o.rmempty = true;
o.placeholder='fe80::1';
o.datatype = 'ip6addr';
o.depends('ipv6', 1);
this.map = m;
return m.render();
},
handleSave(ev) {
ev?.preventDefault();
const view = this;
const map = this.map;
if (!map)
return Promise.reject(new Error(_('Form is not ready yet.')));
const listToKv = view.listToKv;
const toBool = (val) => (val === 1 || val === '1' || val === true);
return map.parse()
.then(() => {
const get = (opt) => map.data.get('json', 'network', opt);
const name = get('name');
const driver = get('driver');
const internal = toBool(get('internal'));
const ingress = toBool(get('ingress'));
const ipv6 = toBool(get('ipv6'));
const subnet = get('subnet');
const gateway = get('gateway');
const ipRange = get('ip_range');
const auxAddress = listToKv(get('aux_address'));
const optionsList = listToKv(get('options'));
const labelsList = listToKv(get('labels'));
const subnet6 = get('subnet6');
const gateway6 = get('gateway6');
const createBody = {
Name: name,
Driver: driver,
EnableIPv6: ipv6,
IPAM: {
Driver: 'default'
},
Internal: internal,
Labels: labelsList,
};
if (subnet || gateway || ipRange
|| (auxAddress && typeof auxAddress === 'object' && Object.keys(auxAddress).length)) {
createBody.IPAM.Config = [{
Subnet: subnet,
Gateway: gateway,
IPRange: ipRange,
AuxAddress: auxAddress,
AuxiliaryAddresses: auxAddress,
}];
}
if (driver === 'macvlan') {
createBody.Options = {
macvlan_mode: get('macvlan_mode'),
parent: get('parent'),
};
}
else if (driver === 'ipvlan') {
createBody.Options = {
ipvlan_mode: get('ipvlan_mode'),
};
}
else if (driver === 'overlay') {
createBody.Ingress = ingress;
}
if (ipv6 && (subnet6 || gateway6)) {
createBody.IPAM.Config = createBody.IPAM.Config || [];
createBody.IPAM.Config.push({
Subnet: subnet6,
Gateway: gateway6,
});
}
if (optionsList && typeof optionsList === 'object' && Object.keys(optionsList).length) {
createBody.Options = Object.assign(createBody.Options || {}, optionsList);
}
if (labelsList && typeof labelsList === 'object' && Object.keys(labelsList).length) {
createBody.Labels = Object.assign(createBody.Labels || {}, labelsList);
}
return createBody;
})
.then((createBody) => view.executeDockerAction(
dm2.network_create,
{ body: createBody },
_('Create network'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
if (response?.body?.Warning) {
view.showNotification(_('Network created with warning'), response.body.Warning, 5000, 'warning');
} else {
view.showNotification(_('Network created'), _('OK'), 4000, 'success');
}
window.location.href = `${this.dockerman_url}/networks`;
}
}
))
.catch((err) => {
view.showNotification(_('Create network failed'), err?.message || String(err), 7000, 'error');
return false;
});
},
handleSaveApply: null,
handleReset: null,
});

View File

@@ -0,0 +1,236 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.network_list(),
dm2.container_list({query: {all: true}}),
]);
},
render([networks, containers]) {
if (networks?.code !== 200) {
return E('div', {}, [ networks?.body?.message ]);
}
let network_list = this.getNetworksTable(networks.body, containers.body);
// let container_list = containers.body;
const view = this; // Capture the view context
let pollPending = null;
let netSec = null;
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([networks2, containers2]) => {
network_list = view.getNetworksTable(networks2.body, containers2.body);
// container_list = containers2.body;
m.data = new m.data.constructor({network: network_list, prune: {}});
if (netSec) {
netSec.footer = [
`${_('Total')} ${network_list.length}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
let s, o;
const m = new form.JSONMap({network: network_list, prune: {}},
_('Docker - Networks'),
_('This page displays all docker networks that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
s = m.section(form.TableSection, 'prune', _('Networks overview'), null);
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(section_id, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/networks/prune',
commandDPath: '/networks/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.network_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => {
// setTimeout(() => window.location.href = `${this.dockerman_url}/networks`, 1000);
// }
// }
// );
}, this);
netSec = m.section(form.TableSection, 'network');
netSec.anonymous = true;
netSec.nodescriptions = true;
netSec.addremove = true;
netSec.sortable = true;
netSec.filterrow = true;
netSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
netSec.footer = [
`${_('Total')} ${network_list.length}`,
];
netSec.handleAdd = function(section_id, ev) {
window.location.href = `${view.dockerman_url}/network_new`;
};
netSec.handleRemove = function(section_id, force, ev) {
const network = network_list.find(net => net['.name'] === section_id);
if (!network?.Id) return false;
return view.executeDockerAction(
dm2.network_remove,
{ id: network.Id },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
};
netSec.handleInspect = function(section_id, ev) {
const network = network_list.find(net => net['.name'] === section_id);
if (!network?.Id) return false;
return view.executeDockerAction(
dm2.network_inspect,
{ id: network.Id },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
netSec.renderRowActions = function (section_id) {
const network = network_list.find(net => net['.name'] === section_id);
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, section_id),
}, [dm2.ActionTypes['inspect'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, section_id, false),
'disabled': network?._disable_delete,
}, dm2.ActionTypes['remove'].e),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, section_id, true),
'disabled': network?._disable_delete,
}, dm2.ActionTypes['force_remove'].e),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
o = netSec.option(form.DummyValue, '_shortId', _('ID'));
o = netSec.option(form.DummyValue, 'Name', _('Name'));
o = netSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
o.cfgvalue = view.objectCfgValueTT;
o = netSec.option(form.DummyValue, '_container', _('Containers'));
o = netSec.option(form.DummyValue, 'Driver', _('Driver'));
o = netSec.option(form.DummyValue, '_interface', _('Parent Interface'));
o = netSec.option(form.DummyValue, '_subnet', _('Subnet'));
o = netSec.option(form.DummyValue, '_gateway', _('Gateway'));
this.insertOutputFrame(s, m);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getNetworksTable(networks, containers) {
const data = [];
for (const [i, net] of (networks || []).entries()) {
const n = net.Name;
const _shortId = (net.Id || '').substring(0, 12);
const shortLink = E('a', {
'href': `${view.dockerman_url}/network/${net.Id}`,
'style': 'font-family: monospace;',
'title': _('Click to view this network'),
}, [_shortId]);
// Just push plain data objects without UCI metadata
const configs = Array.isArray(net?.IPAM?.Config) ? net.IPAM.Config : [];
data.push({
...net,
_gateway: configs.map(o => o.Gateway).filter(o => o).join(', ') || '',
_subnet: configs.map(o => o.Subnet).filter(o => o).join(', ') || '',
_disable_delete: ( n === 'bridge' || n === 'none' || n === 'host' ) ? true : null,
_shortId: shortLink,
_container: this.parseContainerLinksForNetwork(net, containers),
_interface: (net.Driver === 'bridge')
? net.Options?.['com.docker.network.bridge.name'] || ''
: (net.Driver === 'macvlan')
? net?.Options?.parent
: '',
});
}
return data;
},
});

View File

@@ -0,0 +1,280 @@
'use strict';
'require form';
'require fs';
'require uci';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/**
* Returns a Set of image IDs in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of image IDs
*/
function getImagesInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
if (c.ImageID) inUse.add(c.ImageID);
else if (c.Image) inUse.add(c.Image);
}
return inUse;
}
/**
* Returns a Set of network IDs in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of network IDs
*/
function getNetworksInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
const networks = c.NetworkSettings?.Networks;
if (networks && typeof networks === 'object') {
for (const netName in networks) {
const net = networks[netName];
if (net.NetworkID) inUse.add(net.NetworkID);
else if (netName) inUse.add(netName);
}
}
}
return inUse;
}
/**
* Returns a Set of volume mountpoints in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of volume names or mountpoints
*/
function getVolumesInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
const mounts = c.Mounts;
if (Array.isArray(mounts)) {
for (const m of mounts) {
if (m.Type === 'volume' && m.Name) inUse.add(m.Name);
}
}
}
return inUse;
}
return dm2.dv.extend({
load() {
const now = Math.floor(Date.now() / 1000);
return Promise.all([
dm2.docker_version(),
dm2.docker_info(),
// dm2.docker_df(), // takes > 20 seconds on large docker environments
dm2.container_list().then(r => r.body || []),
dm2.image_list().then(r => r.body || []),
dm2.network_list().then(r => r.body || []),
dm2.volume_list().then(r => r.body || []),
dm2.callMountPoints(),
]);
},
handleAction(name, action, ev) {
return dm2.callRcInit(name, action).then(function(ret) {
if (ret)
throw _('Command failed');
return true;
}).catch(function(e) {
L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning');
});
},
render([version_response,
info_response,
// df_response,
container_list,
image_list,
network_list,
volume_list,
mounts,
]) {
const version_headers = [];
const version_body = [];
const info_body = [];
// const df_body = [];
const docker_ep = uci.get('dockerd', 'globals', 'hosts');
let isLocal = false;
if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1)
isLocal = true;
if (info_response?.code !== 200) {
return E('div', {}, [ info_response?.body?.message ]);
}
this.parseHeaders(version_response.headers, version_headers);
this.parseBody(version_response.body, version_body);
this.parseBody(info_response.body, info_body);
// this.parseBody(df_response.body, df_body);
const view = this;
const info = info_response.body;
this.concount = info?.Containers || 0;
this.conactivecount = info?.ContainersRunning || 0;
/* Because the df function that reconciles Volumes, Networks and Containers
is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */
this.imgcount = image_list.length;
this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0;
this.netcount = network_list.length;
this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0;
this.volcount = volume_list?.Volumes?.length;
this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0;
this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0;
if (isLocal && this.freespace !== 0)
this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')';
const mainContainer = E('div', { 'class': 'cbi-map' });
// Add heading and description first
mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
_('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
E('br'),
E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README'])
]));
if (isLocal)
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')),
E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')),
])
]));
// Create the info table
const summaryTable = new L.ui.Table(
[_('Info'), ''],
{ id: 'containers-table', style: 'width: 100%; table-layout: auto;' },
[]
);
summaryTable.update([
[ _('Docker Version'), version_response.body.Version ],
[ _('Api Version'), version_response.body.ApiVersion ],
[ _('CPUs'), info_response.body.NCPU ],
[ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ],
[ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ],
[ _('Index Server Address'), info_response.body.IndexServerAddress ],
[ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ],
]);
// Wrap the table in a cbi-section
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
summaryTable.render()
]));
// Create a container div with grid layout for the status badges
let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [
this.overviewBadge(`${this.dockerman_url}/containers`,
E('img', {
src: L.resource('dockerman/containers.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Containers'),
_('Total: '),
view.concount,
_('Running: '),
view.conactivecount),
this.overviewBadge(`${this.dockerman_url}/images`,
E('img', {
src: L.resource('dockerman/images.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Images'),
_('Total: '),
view.imgcount,
view.imgactivecount ? _('In Use: ') : '',
view.imgactivecount ? view.imgactivecount : ''),
this.overviewBadge(`${this.dockerman_url}/networks`,
E('img', {
src: L.resource('dockerman/networks.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Networks'),
_('Total: '),
view.netcount,
view.netactivecount ? _('In Use: ') : '',
view.netactivecount ? view.netactivecount : ''),
this.overviewBadge(`${this.dockerman_url}/volumes`,
E('img', {
src: L.resource('dockerman/volumes.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Volumes'),
_('Total: '),
view.volcount,
view.volactivecount ? _('In Use: ') : '',
view.volactivecount ? view.volactivecount : ''),
]);
// Add badges section
mainContainer.appendChild(statusContainer);
const m = new form.JSONMap({
// df: df_body,
vb: version_body,
ib: info_body
});
m.readonly = true;
m.tabbed = false;
let s, o, v;
// Add Version and Environment tables
s = m.section(form.TableSection, 'vb', _('Version'));
s.anonymous = true;
o = s.option(form.DummyValue, 'entry', _('Name'));
o = s.option(form.DummyValue, 'value', _('Value'));
s = m.section(form.TableSection, 'ib', _('Environment'));
s.anonymous = true;
s.filterrow = true;
o = s.option(form.DummyValue, 'entry', _('Entry'));
o = s.option(form.DummyValue, 'value', _('Value'));
// Render the form sections and append them
return m.render()
.then(fe => {
mainContainer.appendChild(fe);
return mainContainer;
});
},
overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) {
return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [
E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [
E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [
resource_div,
]),
E('div', { style: 'flex: 1;' }, [
E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption),
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)])
]),
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)])
])
])
])
])
}
});

View File

@@ -0,0 +1,332 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.volume_list(),
dm2.container_list({query: {all: true}}),
]);
},
render([volumes, containers]) {
if (volumes?.code !== 200) {
return E('div', {}, [ volumes.body.message ]);
}
// this.volumes = volumes || {};
let container_list = containers.body || [];
let volume_list = this.getVolumesTable(volumes.body);
const view = this; // Capture the view context
let pollPending = null;
let volSec = null;
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([volumes2, containers2]) => {
volume_list = view.getVolumesTable(volumes2.body);
container_list = containers2.body;
m.data = new m.data.constructor({volume: volume_list, prune: {}});
if (volSec) {
volSec.footer = [
`${_('Total')} ${volume_list.length}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
let s, o;
const m = new form.JSONMap({volume: volume_list, prune: {}},
_('Docker - Volumes'),
_('This page displays all docker volumes that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
s = m.section(form.TableSection, 'prune', null, _('Volumes overview'));
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(sid, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/volumes/prune',
commandDPath: '/volumes/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.volume_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => {
// setTimeout(() => window.location.href = `${this.dockerman_url}/volumes`, 1000);
// }
// }
// );
}, this);
volSec = m.section(form.TableSection, 'volume');
volSec.anonymous = true;
volSec.nodescriptions = true;
volSec.addremove = true;
volSec.sortable = true;
volSec.filterrow = true;
volSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
volSec.footer = [
`${_('Total')} ${volume_list.length}`,
];
volSec.handleAdd = function(ev) {
ev.preventDefault();
let nameInput, labelsInput;
return ui.showModal(_('New volume'), [
E('p', {}, _('Enter an optional name and labels for the new volume')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Name')),
E('div', { 'class': 'cbi-value-field' }, [
nameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('volume name'),
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Labels')),
E('div', { 'class': 'cbi-value-field' }, [
labelsInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'key=value, key2=value2, ...',
})
// labelsInput = new ui.DynamicList([], [], {}).render(),
])
]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, ['↩']),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const name = nameInput.value.trim();
const labels = Object.fromEntries(
(labelsInput.value.trim()?.split(',') || [])
.map(e => e.trim())
.filter(Boolean)
.map(e => e.split('='))
.filter(pair => pair.length === 2)
);
ui.hideModal();
return view.executeDockerAction(
dm2.volume_create,
{ opts: { Name: name, Labels: labels } },
dm2.Types['volume'].sub['create'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
})
}, [dm2.Types['volume'].sub['create'].e])
])
]);
};
volSec.handleRemove = function(sid, force, ev) {
const volume = volume_list.find(net => net['.name'] === sid);
if (!volume?.Name) return false;
return view.executeDockerAction(
dm2.volume_remove,
{ id: volume.Name, query: { force: force } },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
};
volSec.handleInspect = function(sid, ev) {
const volume = volume_list.find(net => net['.name'] === sid);
if (!volume?.Name) return false;
return view.executeDockerAction(
dm2.volume_inspect,
{ id: volume.Name },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
volSec.renderRowActions = function (sid) {
const volume = volume_list.find(net => net['.name'] === sid);
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, sid),
}, [dm2.ActionTypes['inspect'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, false),
'disabled': volume?._disable_delete,
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, true),
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
volSec.option(form.DummyValue, '_name', _('Name'));
o = volSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
o.cfgvalue = view.objectCfgValueTT;
volSec.option(form.DummyValue, 'Driver', _('Driver'));
o = volSec.option(form.DummyValue, 'Containers', _('Containers'));
o.cfgvalue = function(sid) {
const vol = this.map.data.data[sid] || {};
return view.parseContainerLinksForVolume(vol, container_list);
};
o = volSec.option(form.DummyValue, 'Mountpoint', _('Mount Point'));
o.cfgvalue = function(sid) {
const mp = this.map.data.get(this.map.config, sid, this.option);
if (!mp) return;
// Try to match Docker volume mountpoint pattern: /var/lib/docker/volumes/<id>/_data
const match = mp.match(/^(.*\/volumes\/)([^/]+)(\/.*)?$/);
if (match && match[2].length > 36) {
// Show the first 12 characters of the ID portion
return match[1] + match[2].substring(0, 12) + '...' + (match[3] || '');
}
return mp;
};
o = volSec.option(form.DummyValue, 'CreatedAt', _('Created'));
this.insertOutputFrame(s, m);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getVolumesTable(volumes) {
const data = [];
for (const [i, vol] of (volumes?.Volumes || []).entries()) {
const n = vol.Name;
const labels = vol?.Labels || {};
// Just push plain data objects without UCI metadata
data.push({
...vol,
Labels: labels,
_name: (vol.Name || '').substring(0, 12),
Containers: vol.Containers || '',
});
}
return data;
},
parseContainerLinksForVolume(volume, containers) {
const links = [];
for (const cont of containers || []) {
const mounts = cont?.Mounts || [];
const usesVolume = mounts.some(m => {
if (m?.Type !== 'volume' && m?.Type !== 'bind') return false;
const byName = !!volume?.Name && m?.Name === volume.Name;
const bySource = !!volume?.Mountpoint && (m?.Source === volume.Mountpoint || (m?.Source || '').startsWith(volume.Mountpoint));
return byName || bySource;
});
if (usesVolume) {
const containerName = cont?.Names?.[0]?.replace(/^\//, '') || (cont?.Id || '').substring(0, 12);
const containerId = cont?.Id;
links.push(E('a', {
href: `${this.dockerman_url}/container/${containerId}`,
title: containerId,
style: 'white-space: nowrap;'
}, [containerName]));
}
}
if (!links.length)
return '-';
const out = [];
for (let i = 0; i < links.length; i++) {
out.push(links[i]);
if (i < links.length - 1)
out.push(' | ');
}
return E('div', {}, out);
},
});

View File

@@ -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"
}
}
}

View File

@@ -2,9 +2,25 @@
"luci-app-dockerman": {
"description": "Grant UCI access for luci-app-dockerman",
"read": {
"file_comment": "so directory picker can browse the FS",
"file": {
"/*": ["list", "read"]
},
"ubus": {
"docker": [ "*" ],
"docker.*": [ "*" ],
"file": [ "*" ],
"luci": [ "getMountPoints" ],
"rc": [ "init" ]
},
"uci": [ "dockerd" ]
},
"write": {
"ubus": {
"docker": [ "*" ],
"docker.*": [ "*" ],
"rc": [ "init" ]
},
"uci": [ "dockerd" ]
}
}

View File

@@ -0,0 +1,507 @@
#!/usr/bin/env ucode
// Copyright 2025 Paul Donald / luci-lib-docker-js
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
'use strict';
import * as http from 'luci.http';
import * as fs from 'fs';
import * as socket from 'socket';
import { cursor } from 'uci';
import * as ds from 'luci.docker_socket';
// const cmdline = fs.readfile('/proc/self/cmdline');
// const args = split(cmdline, '\0');
const caller = trim(fs.readfile('/proc/self/comm'));
const BLOCKSIZE = 8192;
const POLL_TIMEOUT = 8000; // default; can be overridden per request
// const API_VER = '/v1.47';
const PROTOCOL = 'HTTP/1.1';
const CLIENT_VER = '1';
function merge(a, b) {
let c = {};
for (let k, v in a)
c[k] = v;
for (let k, v in b)
c[k] = v;
return c;
};
function chunked_body_reader(sock, initial_buffer) {
let state = 0, chunklen = 0, buffer = initial_buffer || '';
function poll_and_recv() {
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
if (!ready || !length(ready)) return null;
let data = sock.recv(BLOCKSIZE);
if (!data) return null;
buffer += data;
return true;
}
return () => {
while (true) {
if (state === 0) {
let m = match(buffer, /^([0-9a-fA-F]+)\r\n/);
if (!m || length(m) < 2) {
if (!poll_and_recv()) return null;
continue;
}
chunklen = int(m[1], 16);
buffer = substr(buffer, length(m[0]));
if (chunklen === 0) return null;
state = 1;
}
if (state === 1 && length(buffer) >= chunklen + 2) {
let chunk = substr(buffer, 0, chunklen);
buffer = substr(buffer, chunklen + 2);
state = 0;
return chunk;
} else {
if (!poll_and_recv()) return null;
continue;
}
}
};
};
function read_http_headers(response_headers, response) {
const lines = split(response, /\r?\n/);
for (let l in lines) {
let kv = match(l, /([^:]+):\s*(.*)/);
if (kv && length(kv) === 3)
response_headers[lc(kv[1])] = kv[2];
}
return response_headers;
};
function get_api_ver() {
const ctx = cursor();
const version = ctx.get('dockerd', 'globals', 'api_version') || '';
const version_str = version ? `/${version}` : '';
ctx.unload();
return version_str;
};
function coerce_values_to_string(obj) {
for (let k, v in obj) {
v = `${v}`;
obj[k]=v;
}
return obj;
};
function call_docker(method, path, options) {
options = options || {};
const headers = options.headers || {};
let payload = options.payload || null;
/* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g.
'127.0.0.1:2375'.
We use get_socket_dest_compat() which builds the SockAddress manually to
avoid this.
Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without
--tls* options.
A solution is a reverse proxy or ssh port forwarding to a remote host that
uses the unix socket, and you still connect to a 'local port', or socket:
ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client)
or (dropbear)
socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock
ssh -L 2375:localhost:12375 user@remote-host
*/
/* works on ucode 2025-12-01 */
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
/* works on ucode 2026-01-16 */
// const sock_dest = ds.get_socket_dest();
// const sock_addr = socket.sockaddr(sock_dest);
// const sock = socket.create(sock_addr.family, socket.SOCK_STREAM);
if (caller != 'rpcd') {
print('sock_dest:', sock_dest, '\n');
// print('sock_addr:', sock_addr, '\n');
}
if (!sock) {
return {
code: 500,
headers: {},
body: { message: "Failed to create socket" }
};
}
let conn_result = sock.connect(sock_dest);
let err_msg = `Failed to connect to docker host at ${sock_dest}`;
if (!conn_result) {
sock.close();
return {
code: 500,
headers: {},
body: { message: err_msg}
};
}
if (caller != 'rpcd')
print("query: ", options.query, '\n');
const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : '';
const url = path + query;
const req_headers = [
`${method} ${get_api_ver()}${url} ${PROTOCOL}`,
`Host: luci-host`,
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
`Connection: close`
];
if (payload) {
if (type(payload) === 'object') {
payload = sprintf('%J', payload);
headers['Content-Type'] = 'application/json';
}
headers['Content-Length'] = '' + length(payload);
}
for (let k, v in headers)
push(req_headers, `${k}: ${v}`);
push(req_headers, '', '');
if (caller != 'rpcd')
print(join('\r\n', req_headers), "\n");
sock.send(join('\r\n', req_headers));
if (payload) sock.send(payload);
const response_buff = sock.recv(BLOCKSIZE);
if (!response_buff || response_buff === '') {
sock.close();
return {
code: 500,
headers: {},
body: { message: "No response from Docker socket" }
};
}
const response_parts = split(response_buff, /\r?\n\r?\n/, 2);
const response_headers = read_http_headers({}, response_parts[0]);
let response_body;
let is_chunked = (response_headers['transfer-encoding'] === 'chunked');
let reader;
if (is_chunked) {
reader = chunked_body_reader(sock, response_parts[1]);
}
else if (response_headers['content-length']) {
let content_length = int(response_headers['content-length']);
let buf = response_parts[1];
reader = () => {
if (content_length <= 0) return null;
if (buf && length(buf)) {
let chunk = substr(buf, 0, content_length);
buf = substr(buf, length(chunk));
content_length -= length(chunk);
return chunk;
}
let data = sock.recv(min(BLOCKSIZE, content_length));
if (!data || data === '') return null;
content_length -= length(data);
return data;
};
}
else {
// Fallback for HTTP/1.0 or no content-length: read until close or timeout
reader = () => {
// Poll with 2 second timeout
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
if (!ready || !length(ready)) return null; // Timeout or error
let data = sock.recv(BLOCKSIZE);
if (!data || data === '') return null;
return data;
};
}
let chunks = [], chunk;
while ((chunk = reader())) {
push(chunks, chunk);
}
sock.close();
response_body = join('', chunks);
// Parse HTTP status code
let status_line = split(response_parts[0], /\r?\n/)[0];
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
let code = status_match ? int(status_match[1]) : 0;
// Docker events endpoint returns newline-delimited JSON, not a single JSON object
if (response_headers['content-type'] === 'application/json' && response_body) {
// Single JSON object
let data;
try { data = json(rtrim(response_body)); }
catch { data = null; }
// Check if this is newline-delimited JSON (multiple lines with JSON objects)
if (!data) {
// Parse each line as a separate JSON object
let lines = split(trim(response_body), /\n/);
let events = [];
for (let line in lines) {
line = trim(line);
if (line) {
try { push(events, json(line)); }
catch { /* skip invalid lines */ }
}
}
response_body = events;
} else {
response_body = data;
}
}
return {
code: code,
headers: response_headers,
body: response_body
};
};
function run_ttyd(request) {
const id = request.args.id || '';
const cmd = request.args.cmd || '/bin/sh';
const port = request.args.port || 7682;
const uid = request.args.uid || '';
if (!id) {
return { error: 'Container ID is required' };
}
let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`;
const sock_addr = ds.get_socket_dest();
/* Build the full command:
ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD
if the socket is /var/run/docker.sock, prefix unix://
Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls*
*/
const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr;
ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`;
if (uid && uid !== '') {
ttyd_cmd = `${ttyd_cmd} -u ${uid}`;
}
ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`;
// Try to kill any existing ttyd processes on this port
system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true');
// Start ttyd
system(ttyd_cmd);
return { status: 'ttyd started', command: ttyd_cmd };
}
// https://docs.docker.com/reference/api/engine/version/v1.47/
/* Note: methods here are included for structural reference. Some rpcd methods
are not suitable to be called from the GUI because they are streaming endpoints
or the operations in a busy dockerd cluster take a *long* time which causes
timeouts at the front end. Good examples of this are:
- /system/df
- push
- pull
- all /prune
We include them here because they can be useful from the command line.
*/
const core_methods = {
version: { call: () => call_docker('GET', '/version') },
info: { call: () => call_docker('GET', '/info') },
ping: { call: () => call_docker('GET', '/_ping') },
df: { call: () => call_docker('GET', '/system/df') },
events: { args: { query: { 'since': '', 'until': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) },
};
const exec_methods = {
start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) },
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) },
};
const container_methods = {
list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) },
create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) },
inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) },
top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) },
logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) },
changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) },
export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) },
stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) },
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) },
start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) },
stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) },
restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) },
kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) },
update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) },
rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) },
pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) },
unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) },
// attach
// attach websocket
// wait
remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) },
// archive info
info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
// archive get
get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
// archive extract
put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) },
exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.query }) },
// Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker
ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) },
};
const image_methods = {
list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request.args.query }) },
// build is long-running, and will likely cause time-out on the call. Function only here for reference.
build: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) },
build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) },
create: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) },
history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) },
push: { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) },
tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) },
remove: { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) },
search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) },
// create/commit
get: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) },
// get == export several
load: { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) },
};
const network_methods = {
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) },
inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) },
remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) },
create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) },
connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) },
disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) },
};
const volume_methods = {
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) },
create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) },
update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) },
remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.query }) },
};
const methods = {
'docker': core_methods,
'docker.container': container_methods,
'docker.exec': exec_methods,
'docker.image': image_methods,
'docker.network': network_methods,
'docker.volume': volume_methods,
};
// CLI test mode - check if script is run directly (not loaded by rpcd)
if (caller != 'rpcd') {
// Usage: ./docker_rpc.uc <object.method> <json-args>
// Example: ./docker_rpc.uc docker.network.list '{"query":{"filters":""}}'
// Example: ./docker_rpc.uc docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'
const scr_name = split(SCRIPT_NAME, '/')[-1] || 'docker_rpc.uc';
if (length(ARGV) < 1) {
print(`Usage: ${scr_name} <object.method> [json-args]\n`);
print("Available methods:\n");
for (let obj in methods) {
for (let name, info in methods[obj]) {
let sig = name;
if (info && info.args) {
try {
sig = `${sig} ${sprintf('\'%J\'', info.args)}`;
} catch {
sig = `${sig} <args>`;
}
}
print(` ${obj}.${sig}\n`);
}
}
print("\nExamples:\n");
print(` ${scr_name} docker.version\n`);
print(` ${scr_name} docker.network.list '{"query":{}}'\n`);
print(` ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`);
print(` ${scr_name} docker.container.list '{"query":{"all":true}}'\n`);
exit(1);
}
const method_path = split(ARGV[0], '.');
if (length(method_path) < 1) {
die(`Invalid method path: ${ARGV[0]}\n`);
}
// Build object path (e.g., "docker.network")
const obj_parts = slice(method_path, 0, -1);
const obj_name = join('.', obj_parts);
const method_name = method_path[length(method_path) - 1];
if (!methods[obj_name]) {
die(`Unknown object: ${obj_name}\n`);
}
if (!methods[obj_name][method_name]) {
die(`Unknown method: ${obj_name}.${method_name}\n`);
}
// Parse args if provided
let args = {};
if (length(ARGV) > 1) {
try {
args = json(ARGV[1]);
} catch (e) {
die(`Invalid JSON args: ${e}\n`);
}
}
// Call the method
const request = { args: args };
const result = methods[obj_name][method_name].call(request);
// Pretty print result
print(result, "\n");
exit(0);
};
return methods;

View File

@@ -0,0 +1,393 @@
// Docker HTTP streaming endpoint
// Copyright 2025 Paul Donald <newtwen+github@gmail.com>
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
'use strict';
import { stdout } from 'fs';
import * as ds from 'luci.docker_socket';
import * as socket from 'socket';
import { cursor } from 'uci';
const BUFF_HEAD = 6; // 8000\r\n
const BUFF_TAIL = 2; // \r\n
const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776
// const API_VER = 'v1.47';
const PROTOCOL = 'HTTP/1.1';
const CLIENT_VER = '1';
let DockerController = {
// Handle file upload for chunked transfer
handle_file_upload: function(sock) {
let total_bytes = 0;
http.setfilehandler(function(meta, chunk, eof) {
if (meta.file && meta.name === 'upload-archive') {
if (chunk && length(chunk) > 0) {
let hex_size = sprintf('%x', length(chunk));
sock.send(hex_size + '\r\n');
sock.send(chunk);
sock.send('\r\n');
total_bytes += length(chunk);
}
if (eof) {
sock.send('0\r\n\r\n');
}
}
});
return total_bytes;
},
// Reusable header builder
build_headers: function(headers) {
let hdrs = [];
if (headers) {
for (let key in headers) {
if (headers[key] != null && headers[key] != '') {
push(hdrs, `${key}: ${headers[key]}`);
}
}
}
return length(hdrs) ? join('\r\n', hdrs) : '';
},
// Parse the initial HTTP response, split into parts and header lines, and store as properties
initial_response_parser: function(response_buff) {
let parts = split(response_buff, /\r?\n\r?\n/, 2);
let header_lines = split(parts[0], /\r?\n/);
let status_line = header_lines[0];
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
let code = status_match ? int(status_match[1]) : 500;
this.response_parts = parts;
this.header_lines = header_lines;
this.status_line = status_line;
this.status_match = status_match;
this.code = code;
},
// Stream the rest of the response in chunks from the socket
stream_response_chunks: function(sock, blocksize) {
let chunk;
while ((chunk = sock.recv(blocksize))) {
if (chunk && length(chunk)) {
this.debug('Streaming chunk:', substr(chunk, 0, 10));
stdout.write(chunk);
}
}
},
// Send a 200 OK response with headers and body
/* Write CGI response directly to stdout bypassing http.uc
The minimum to trigger a valid response via CGI is typically
Status: \r\n
The Docker response contains the \r\n after its headers, and the browser can
handle the chunked encoding fine, so we just forward its output verbatim.
Docker emits a x-docker-container-path-stat header with some meta-data for
the path, which we forward. uhttpd seems to coalesce headers, and inject its
own, so we occasionally have two Connection: headers.
*/
send_initial_200_response: function(headers, body) {
stdout.write('Status: 200 OK\r\n');
if (headers && type(headers) == 'array') {
stdout.write(join('', headers));
}
if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) {
stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n')));
}
},
// Debug output if &debug=... is present
debug: function(...args) {
let dbg = http.formvalue('debug');
let tostr = function(x) { return `${x}`; };
if (dbg != null && dbg != '') {
http.prepare_content('application/json');
http.write_json({msg: join(' ', map(args, tostr)) + '\n' });
}
},
// Generic error response helper
error_response: function(code, msg, detail) {
http.status(code ?? 500, msg ?? 'Internal Error');
http.prepare_content('application/json');
let out = { error: msg ?? 'Internal Error' };
if (detail)
out.detail = detail;
http.write_json(out);
},
get_api_ver: function() {
let ctx = cursor();
let version = ctx.get('dockerd', 'globals', 'api_version') || '';
ctx.unload();
return version ? `/${version}` : '';
},
join_args_array: function(args) {
return (type(args) == "array") ? join('/', args) : args;
},
require_param: function(name) {
let val = http.formvalue(name);
if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` });
return val;
},
// Reusable query string builder
build_query_str: function(query_params, skip_keys) {
let query_str = '';
if (query_params) {
let parts = [];
for (let key in query_params) {
if (skip_keys && (key in skip_keys))
continue;
let val = query_params[key];
if (val == null || val == '') continue;
if (type(val) === 'array') {
for (let v in val) {
push(parts, `${key}=${v}`);
}
} else {
push(parts, `${key}=${val}`);
}
}
if (length(parts))
query_str = '?' + join('&', parts);
}
return query_str;
},
get_archive: function(docker_path, id, docker_function, query_params, archive_name) {
this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name);
id = this.join_args_array(id);
let id_param = '';
if (id) id_param = `/${id}`;
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`;
let url = `${docker_path}${id_param}${docker_function}${query_str}`;
let req = [
`GET ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
`Connection: close`,
``,
``
];
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
let filename = length(id) >= 64 ? substr(id, 0, 12) : id;
if (!filename) filename = 'multi';
let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`];
this.send_initial_200_response(include_headers, response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) {
this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile);
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let skip_keys = {
token: true,
'X-Registry-Auth': true,
'upload-name': true,
'upload-archive': true,
'upload-path': true,
};
let remote = false;
if (query_params && type(query_params) === 'object' &&
query_params['remote'] != null && query_params['remote'] != '')
remote = true;
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`;
let hdr_str = this.build_headers(headers);
let url = `${docker_path}${docker_function}${query_str}`;
let req = [
`${method} ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`,
`Connection: close`,
];
if (hdr_str)
push(req, hdr_str);
if (haveFile) {
push(req, 'Content-Type: application/x-tar');
push(req, 'Transfer-Encoding: chunked');
}
push(req, '');
push(req, '');
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
if (haveFile)
this.handle_file_upload(sock);
else
sock.send('\r\n\r\n');
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
this.send_initial_200_response('', response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
// Handler methods
container_get_archive: function(id) {
this.require_param('path');
this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar');
},
container_export: function(id) {
this.get_archive('/containers', id, '/export', null, 'container_export.tar');
},
containers_prune: function() {
this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false);
},
container_put_archive: function(id) {
this.require_param('path');
this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true);
},
docker_events: function() {
this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false);
},
image_build: function(...args) {
let remote = http.formvalue('remote');
this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote);
},
image_build_prune: function() {
this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_create: function() {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false);
},
image_get: function(...args) {
this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar');
},
image_load: function() {
this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true);
},
images_prune: function() {
this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_push: function(...args) {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false);
},
networks_prune: function() {
this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false);
},
volumes_prune: function() {
this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false);
},
};
// Export all handlers with automatic error wrapping
let controller = DockerController;
let exports = {};
for (let k, v in controller) {
if (type(v) == 'function')
exports[k] = v;
}
return exports;

View File

@@ -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;
};