mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 10:51:51 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
206
applications/luci-app-dockerman/README.md
Normal file
206
applications/luci-app-dockerman/README.md
Normal 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
@@ -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 |
@@ -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();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -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)])
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" ]
|
||||
}
|
||||
}
|
||||
|
||||
507
applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc
Executable file
507
applications/luci-app-dockerman/root/usr/share/rpcd/ucode/docker_rpc.uc
Executable 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;
|
||||
393
applications/luci-app-dockerman/ucode/controller/docker.uc
Normal file
393
applications/luci-app-dockerman/ucode/controller/docker.uc
Normal 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;
|
||||
87
applications/luci-app-dockerman/ucode/docker_socket.uc
Normal file
87
applications/luci-app-dockerman/ucode/docker_socket.uc
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user