mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-29 09:35:13 +08:00
🍕 Sync 2026-03-20 22:17:54
This commit is contained in:
1 parent
e67803267a
commit
6680797fe5
91 files changed
+82161
-19938
No files matched your search
-1
@@ -6,4 +6,3 @@ uci -q batch <<-EOF >/dev/null
|
||||
EOF
|
||||
|
||||
/etc/init.d/dockerd restart
|
||||
|
||||
+3
-3
@@ -5,12 +5,12 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=gn
|
||||
PKG_RELEASE:=1
|
||||
PKG_RELEASE:=2
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://gn.googlesource.com/gn.git
|
||||
PKG_SOURCE_DATE:=2025-09-18
|
||||
PKG_SOURCE_VERSION:=81b24e01531ecf0eff12ec9359a555ec3944ec4e
|
||||
PKG_SOURCE_DATE:=2025-11-07
|
||||
PKG_SOURCE_VERSION:=e7f3202128bdb2429872fdb138626a010b2bff7f
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=BSD 3-Clause
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#ifndef OUT_LAST_COMMIT_POSITION_H_
|
||||
#define OUT_LAST_COMMIT_POSITION_H_
|
||||
|
||||
#define LAST_COMMIT_POSITION_NUM 2286
|
||||
#define LAST_COMMIT_POSITION "2286 (81b24e01531e)"
|
||||
#define LAST_COMMIT_POSITION_NUM 2292
|
||||
#define LAST_COMMIT_POSITION "2292 (e7f3202128bd)"
|
||||
|
||||
#endif // OUT_LAST_COMMIT_POSITION_H_
|
||||
@@ -1,23 +1,18 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_FLAGS:=hold
|
||||
|
||||
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.20250109
|
||||
PKG_RELEASE:=2
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# 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 | (Proxy+)JS API | Controller |
|
||||
|------------------|----------|----------------|------------|
|
||||
| API | ✅ | ✅ | ✅ |
|
||||
| File Stream | ❌ | ✅ | ✅ |
|
||||
| Console Start | ✅ | ❌ | ❌ |
|
||||
| WS Console | ❌ | ✅ | ❌ |
|
||||
| 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 and to open up the JS API.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## JS API
|
||||
|
||||
A JS API is included in the front-end to connect to API endpoints, and it
|
||||
will detect how dockerd is configured. If dockerd is configured with any
|
||||
|
||||
`xxx://x.x.x.x:2375` or `xxx://x.x.x.x:2376` (or `xxx://[2001:db8::1]:2375`)
|
||||
|
||||
the front end will attempt to connect using the JS API. More features are
|
||||
available with a more direct connection to the API (via Proxy or using
|
||||
[browser plugin](#browser-plug-in)), like WebSockets to connect to container
|
||||
terminals. WebSocket connections are not currently available in LuCI, or the
|
||||
LuCI CGI proxy.
|
||||
|
||||
CGI's job is to parse the request, send the response and disconnect.
|
||||
|
||||
|
||||
## Browser plug-in
|
||||
|
||||
To avoid setting up a Proxy, and attempt to communicate directly with the API
|
||||
endpoint, whether or not configured with `-tls*` options, you can use a plug-in.
|
||||
One which overrides (the absence of) `Access-Control-Allow-Origin` CORS headers
|
||||
(dockerd does not add these headers).
|
||||
For example:
|
||||
|
||||
https://addons.mozilla.org/en-US/firefox/addon/cors-everywhere/
|
||||
|
||||
https://addons.mozilla.org/en-US/firefox/addon/access-control-allow-origin/
|
||||
|
||||
https://addons.mozilla.org/en-US/firefox/addon/cors-unblock/
|
||||
|
||||
https://addons.mozilla.org/en-US/firefox/addon/cross-domain-cors/
|
||||
|
||||
|
||||
The browser plug-in does not magically fix TLS problems when you have mTLS
|
||||
configured on dockerd (mutual CA based certificate authentication).
|
||||
|
||||
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,534 @@
|
||||
'use strict';
|
||||
'require rpc';
|
||||
'require uci';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
const callNetworkInterfaceDump = rpc.declare({
|
||||
object: 'network.interface',
|
||||
method: 'dump',
|
||||
expect: { 'interface': [] }
|
||||
});
|
||||
|
||||
|
||||
let dockerHosts = null;
|
||||
let dockerHost = null;
|
||||
let localIPv4 = null;
|
||||
let localIPv6 = null;
|
||||
let js_api_available = false;
|
||||
|
||||
|
||||
// Load both UCI config and network interfaces in parallel
|
||||
const loadPromise = Promise.all([
|
||||
callNetworkInterfaceDump(),
|
||||
uci.load('dockerd'),
|
||||
]).then(([interfaceData]) => {
|
||||
|
||||
const lan_device = uci.get('dockerd', 'globals', '_luci_lan') || 'lan';
|
||||
|
||||
// Find local IPs from network interfaces
|
||||
if (interfaceData) {
|
||||
interfaceData.forEach(iface => {
|
||||
// console.log(iface.up)
|
||||
if (!iface.up || iface.interface !== lan_device) return;
|
||||
|
||||
// Get IPv4 address
|
||||
if (!localIPv4 && iface['ipv4-address']) {
|
||||
const addr4 = iface['ipv4-address'].find(a =>
|
||||
a.address && !a.address.startsWith('127.')
|
||||
);
|
||||
if (addr4) localIPv4 = addr4.address;
|
||||
}
|
||||
|
||||
// Get IPv6 address
|
||||
if (!localIPv6) {
|
||||
// Try ipv6-address array first
|
||||
if (iface['ipv6-address']) {
|
||||
const addr6 = iface['ipv6-address'].find(a =>
|
||||
a.address && a.address !== '::1' && !a.address.startsWith('fe80:')
|
||||
);
|
||||
if (addr6) localIPv6 = addr6.address;
|
||||
}
|
||||
|
||||
// Try ipv6-prefix-assignment if no address found
|
||||
if (!localIPv6 && iface['ipv6-prefix-assignment']) {
|
||||
const prefix = iface['ipv6-prefix-assignment'].find(p =>
|
||||
p['local-address'] && p['local-address'].address
|
||||
);
|
||||
if (prefix) localIPv6 = prefix['local-address'].address;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dockerHosts = uci.get_first('dockerd', 'globals', 'hosts');
|
||||
|
||||
// Find and convert first tcp:// or tcp6:// host
|
||||
const hostsList = Array.isArray(dockerHosts) ? dockerHosts : [];
|
||||
const dh = hostsList.find(h => h
|
||||
&& (h.startsWith('tcp://')
|
||||
|| h.startsWith('tcp6://')
|
||||
|| h.startsWith('inet6://')
|
||||
|| h.startsWith('http://')
|
||||
|| h.startsWith('https://')
|
||||
));
|
||||
|
||||
if (dh) {
|
||||
// const isTcp6 = dh.startsWith('tcp6://');
|
||||
const protocol = dh.includes(':2376') ? 'https://' : 'http://';
|
||||
dockerHost = dh.replace(/^(tcp|inet)6?:\/\//, protocol);
|
||||
|
||||
// Replace 0.0.0.0 or :: with appropriate local IP
|
||||
if (localIPv6) {
|
||||
dockerHost = dockerHost.replace(/\[::1?\]/, `[${localIPv6}]`);
|
||||
// dockerHost = dockerHost.replace(/::/, localIPv6);
|
||||
}
|
||||
|
||||
if (localIPv4) {
|
||||
dockerHost = dockerHost.replace(/0\.0\.0\.0/, localIPv4);
|
||||
}
|
||||
|
||||
console.log('Docker configured to use JS API to:', dockerHost);
|
||||
}
|
||||
|
||||
return dockerHost;
|
||||
});
|
||||
|
||||
|
||||
// Helper to process NDJSON or line-delimited JSON chunks
|
||||
function processLines(buffer, onChunk) {
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
onChunk(json);
|
||||
} catch (e) {
|
||||
onChunk({ raw: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
function call_docker(method, path, options = {}) {
|
||||
return loadPromise.then(() => {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const payload = options.payload || null;
|
||||
const query = options.query || null;
|
||||
const host = dockerHost;
|
||||
const onChunk = options.onChunk || null; // Optional callback for streaming NDJSON
|
||||
const api_ver = uci.get('dockerd', 'globals', 'api_version') || '';
|
||||
const api_ver_str = api_ver ? `/${api_ver}` : '';
|
||||
|
||||
|
||||
if (!host) {
|
||||
return Promise.reject(new Error('Docker host not configured'));
|
||||
}
|
||||
|
||||
// Check if WebSocket upgrade is requested
|
||||
const isWebSocketUpgrade = headers['Connection']?.toLowerCase() === 'upgrade' ||
|
||||
headers['connection']?.toLowerCase() === 'upgrade';
|
||||
|
||||
if (isWebSocketUpgrade) {
|
||||
return createWebSocketConnection(host, path, query);
|
||||
}
|
||||
|
||||
// Build URL
|
||||
let url = `${host}${api_ver_str}${path}`;
|
||||
if (query) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
if (query[key] != null) {
|
||||
params.append(key, query[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// dockerd does not like encoded params here.
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build fetch options
|
||||
const fetchOptions = {
|
||||
method,
|
||||
headers: {
|
||||
...headers // Always include custom headers
|
||||
},
|
||||
};
|
||||
|
||||
if (payload) {
|
||||
fetchOptions.body = JSON.stringify(payload);
|
||||
if (!fetchOptions.headers['Content-Type']) {
|
||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
// Make the request
|
||||
return fetch(url, fetchOptions)
|
||||
.then(response => {
|
||||
// If streaming callback provided, use streaming response
|
||||
if (onChunk) {
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Process any remaining data in buffer
|
||||
buffer = processLines(buffer, onChunk);
|
||||
break;
|
||||
}
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// Use generic processor for NDJSON/line chunks
|
||||
buffer = processLines(buffer, onChunk);
|
||||
}
|
||||
|
||||
// Return final response
|
||||
resolve({
|
||||
code: response.status,
|
||||
headers: response.headers
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Streaming error:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
processStream();
|
||||
});
|
||||
}
|
||||
|
||||
// Normal buffered response
|
||||
if (response?.status >= 304) {
|
||||
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const headersObj = {};
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
headersObj[key] = value;
|
||||
}
|
||||
|
||||
return response.text().then(text => {
|
||||
const safeText = (typeof text === 'string') ? text : '';
|
||||
let parsed = safeText || text;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Try normal JSON parse first
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
// If the payload is newline-delimited JSON (Docker events), split and parse each line
|
||||
if (['application/json',
|
||||
'application/x-ndjson',
|
||||
'application/json-seq'].includes(contentType) || safeText.includes('\n')) {
|
||||
const lines = safeText.split(/\r?\n/).filter(Boolean);
|
||||
try {
|
||||
parsed = lines.map(l => JSON.parse(l));
|
||||
} catch (err2) {
|
||||
// Fall back to raw text if parsing fails
|
||||
parsed = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: response.status,
|
||||
body: parsed,
|
||||
headers: headersObj,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Docker API error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createWebSocketConnection(host, path, query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Convert http/https to ws/wss
|
||||
const wsHost = host
|
||||
.replace(/^https:/, 'wss:')
|
||||
.replace(/^http:/, 'ws:');
|
||||
|
||||
// Build WebSocket URL
|
||||
let wsUrl = `${wsHost}${path}`;
|
||||
if (query) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
if (query[key] != null) {
|
||||
params.append(key, query[key]);
|
||||
}
|
||||
}
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
wsUrl += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Opening WebSocket connection to:', wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let resolved = false;
|
||||
|
||||
// Handle connection open
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
// Return a Response-like object with WebSocket support
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Map(),
|
||||
body: ws,
|
||||
ws: ws,
|
||||
// Add helper for sending messages
|
||||
send: (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
},
|
||||
// Add helper for receiving messages as async iterator
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (ws.readyState === WebSocket.OPEN) {
|
||||
yield new Promise((res, rej) => {
|
||||
const messageHandler = (event) => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.removeEventListener('error', errorHandler);
|
||||
res(event.data);
|
||||
};
|
||||
const errorHandler = (error) => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.removeEventListener('error', errorHandler);
|
||||
rej(error);
|
||||
};
|
||||
ws.addEventListener('message', messageHandler);
|
||||
ws.addEventListener('error', errorHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection error
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`WebSocket connection failed: ${error.message || 'Unknown error'}`));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close (including handshake failures)
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket closed');
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`WebSocket closed before open (${event?.code || 'unknown'})`));
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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': `${Date.now()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
};
|
||||
|
||||
/*
|
||||
const exec_methods = {
|
||||
start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request?.id}/start`, { payload: request?.body }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request?.id}/resize`, { query: request?.query }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request?.id}/json`) },
|
||||
};
|
||||
*/
|
||||
|
||||
const container_methods = {
|
||||
list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request?.query }) },
|
||||
create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request?.query, payload: request?.body }) },
|
||||
inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/json`, { query: request?.query }) },
|
||||
top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/top`, { query: request?.query }) },
|
||||
logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request?.id}/logs`, { query: request?.query, onChunk: request?.onChunk }) },
|
||||
changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/changes`) },
|
||||
export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/export`) },
|
||||
stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/stats`, { query: request?.query }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/resize`, { query: request?.query }) },
|
||||
start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/start`, { query: request?.query }) },
|
||||
stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/stop`, { query: request?.query }) },
|
||||
restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/restart`, { query: request?.query }) },
|
||||
kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/kill`, { query: request?.query }) },
|
||||
update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/update`, { payload: request?.body }) },
|
||||
rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/rename`, { query: request?.query }) },
|
||||
pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/pause`) },
|
||||
unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/unpause`) },
|
||||
// attach
|
||||
// attach websocket
|
||||
attach_ws: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/attach/ws`, { query: request?.query, headers: { 'Connection': 'Upgrade' } }) },
|
||||
// wait
|
||||
remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request?.id}`, { query: request?.query }) },
|
||||
// archive info
|
||||
info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request?.id}/archive`, { query: request?.query }) },
|
||||
// archive get
|
||||
get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/archive`, { query: request?.query }) },
|
||||
// archive extract
|
||||
put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request?.id}/archive`, { query: request?.query, payload: request?.body }) },
|
||||
exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/exec`, { payload: request?.opts }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request?.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?.query }) },
|
||||
build: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
build_prune: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build/prune', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
create: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/images/create', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/json`) },
|
||||
history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/history`) },
|
||||
push: { args: { name: '', query: { tag: '', platform: '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', `/images/${request?.name}/push`, { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request?.id}/tag`, { query: request?.query }) },
|
||||
remove: { args: { id: '', query: { 'force': false, 'noprune': false }, onChunk: null }, call: (request) => call_docker('DELETE', `/images/${request?.id}`, { query: request?.query, onChunk: request?.onChunk }) },
|
||||
search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request?.query }) },
|
||||
prune: { args: { query: { 'filters': '' }, onChunk: null }, call: (request) => call_docker('POST', '/images/prune', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
// create/commit
|
||||
get: { args: { id: '', onChunk: null }, call: (request) => call_docker('GET', `/images/${request?.id}/get`, { onChunk: request?.onChunk }) },
|
||||
// get == export several
|
||||
load: { args: { query: { 'quiet': false }, onChunk: null }, call: (request) => call_docker('POST', '/images/load', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
};
|
||||
|
||||
|
||||
const network_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request?.query }) },
|
||||
inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request?.id}`, { query: request?.query }) },
|
||||
remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request?.id}`) },
|
||||
create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request?.body }) },
|
||||
connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/connect`, { payload: request?.body }) },
|
||||
disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/disconnect`, { payload: request?.body }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request?.query }) },
|
||||
};
|
||||
|
||||
|
||||
const volume_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request?.query }) },
|
||||
create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request?.opts }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request?.id}`) },
|
||||
update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request?.id}`, { query: request?.query, payload: request?.spec }) },
|
||||
remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request?.id}`, { query: request?.query }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request?.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,
|
||||
// };
|
||||
|
||||
|
||||
// Determine JS API availability after core methods are ready
|
||||
const apiAvailabilityPromise = loadPromise.then(() => {
|
||||
if (!dockerHost) {
|
||||
js_api_available = false;
|
||||
return [js_api_available, dockerHost];
|
||||
}
|
||||
|
||||
return core_methods.ping.call()
|
||||
.then(res => {
|
||||
// ping returns raw 'OK' text; treat any truthy/OK as success
|
||||
const body = res?.body;
|
||||
js_api_available = body === 'OK';
|
||||
return [js_api_available, dockerHost];
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('JS API unavailable (likely CORS or network):', error?.message || error);
|
||||
js_api_available = false;
|
||||
return [js_api_available, dockerHost];
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return L.Class.extend({
|
||||
js_api_available: () => apiAvailabilityPromise.then(() => [js_api_available, dockerHost]),
|
||||
container_attach_ws: container_methods.attach_ws.call,
|
||||
container_changes: container_methods.changes.call,
|
||||
container_create: container_methods.create.call,
|
||||
// container_export: container_export, // use controller instead
|
||||
container_info_archive: container_methods.info_archive.call,
|
||||
container_inspect: container_methods.inspect.call,
|
||||
container_kill: container_methods.kill.call,
|
||||
container_list: container_methods.list.call,
|
||||
container_logs: container_methods.logs.call,
|
||||
container_pause: container_methods.pause.call,
|
||||
container_prune: container_methods.prune.call,
|
||||
container_remove: container_methods.remove.call,
|
||||
container_rename: container_methods.rename.call,
|
||||
container_restart: container_methods.restart.call,
|
||||
container_start: container_methods.start.call,
|
||||
container_stats: container_methods.stats.call,
|
||||
container_stop: container_methods.stop.call,
|
||||
container_top: container_methods.top.call,
|
||||
// container_ttyd_start: container_methods.ttyd_start.call,
|
||||
container_unpause: container_methods.unpause.call,
|
||||
container_update: container_methods.update.call,
|
||||
docker_version: core_methods.version.call,
|
||||
docker_info: core_methods.info.call,
|
||||
docker_ping: core_methods.ping.call,
|
||||
docker_df: core_methods.df.call,
|
||||
docker_events: core_methods.events.call,
|
||||
image_build: image_methods.build.call,
|
||||
image_create: image_methods.create.call,
|
||||
image_history: image_methods.history.call,
|
||||
image_inspect: image_methods.inspect.call,
|
||||
image_list: image_methods.list.call,
|
||||
image_prune: image_methods.prune.call,
|
||||
image_push: image_methods.push.call,
|
||||
image_remove: image_methods.remove.call,
|
||||
image_tag: image_methods.tag.call,
|
||||
network_connect: network_methods.connect.call,
|
||||
network_create: network_methods.create.call,
|
||||
network_disconnect: network_methods.disconnect.call,
|
||||
network_inspect: network_methods.inspect.call,
|
||||
network_list: network_methods.list.call,
|
||||
network_prune: network_methods.prune.call,
|
||||
network_remove: network_methods.remove.call,
|
||||
volume_create: volume_methods.create.call,
|
||||
volume_inspect: volume_methods.inspect.call,
|
||||
volume_list: volume_methods.list.call,
|
||||
volume_prune: volume_methods.prune.call,
|
||||
volume_remove: volume_methods.remove.call,
|
||||
|
||||
});
|
||||
|
||||
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,144 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/*
|
||||
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');
|
||||
|
||||
o = s.taboption('globals', widgets.NetworkSelect, '_luci_lan',
|
||||
_('LAN connection'),
|
||||
_('Set your LAN interface when docker listens on all addresses like 0.0.0.0 or ::.'));
|
||||
o.rmempty = true;
|
||||
o.noaliases = true;
|
||||
o.nocreate = true;
|
||||
|
||||
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,950 @@
|
||||
'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_lla: (() => {
|
||||
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 e = typeof entry === 'string' ? entry : '';
|
||||
let f = e.split(':')?.map(e => e && e.trim() || '');
|
||||
let source = f[0];
|
||||
let target = f[1];
|
||||
let options = f[2];
|
||||
|
||||
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,361 @@
|
||||
'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 `${pub ? pub + ':' : ''}${priv}/${type}`;
|
||||
// return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
|
||||
}).join('<br/>')
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
'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);
|
||||
this.js_api = false;
|
||||
|
||||
return Promise.all([
|
||||
dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
|
||||
dm2.js_api_ready.then(([ok, ]) => this.js_api = ok),
|
||||
]);
|
||||
},
|
||||
|
||||
render([events, js_api_available]) {
|
||||
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 = !this.js_api ? until > now ? now : until : 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;
|
||||
// Batching for speed
|
||||
let batchBuffer = new Set();
|
||||
let batchTimer = null;
|
||||
const BATCH_SIZE = 256;
|
||||
const BATCH_INTERVAL = 500; // ms
|
||||
|
||||
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 = '';
|
||||
|
||||
function flushBatch() {
|
||||
if (batchBuffer.size) {
|
||||
batchBuffer = new Set();
|
||||
}
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
batchTimer = null;
|
||||
}
|
||||
updateTable();
|
||||
}
|
||||
|
||||
function handleEventChunk(event) {
|
||||
event_list.add(event);
|
||||
batchBuffer.add(event);
|
||||
if (batchBuffer.size >= BATCH_SIZE) {
|
||||
flushBatch();
|
||||
} else if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, BATCH_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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, onChunk: handleEventChunk },
|
||||
_('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();
|
||||
flushBatch();
|
||||
},
|
||||
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, ss;
|
||||
|
||||
// INFO TAB
|
||||
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
|
||||
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
|
||||
|
||||
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 [ , 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;
|
||||
|
||||
// Add Version and Environment tables
|
||||
s = m.section(form.TableSection, 'vb', _('Version'));
|
||||
s.anonymous = true;
|
||||
|
||||
s.option(form.DummyValue, 'entry', _('Name'));
|
||||
s.option(form.DummyValue, 'value', _('Value'));
|
||||
|
||||
s = m.section(form.TableSection, 'ib', _('Environment'));
|
||||
s.anonymous = true;
|
||||
s.filterrow = true;
|
||||
|
||||
s.option(form.DummyValue, 'entry', _('Entry'));
|
||||
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,331 @@
|
||||
'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 [ , vol] of (volumes?.Volumes || []).entries()) {
|
||||
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);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
module("luci.controller.dockerman",package.seeall)
|
||||
|
||||
function index()
|
||||
entry({"admin", "docker"},
|
||||
firstchild(),
|
||||
_("Docker"),
|
||||
40).acl_depends = { "luci-app-dockerman" }
|
||||
|
||||
entry({"admin", "docker", "config"},cbi("dockerman/configuration"),_("Configuration"), 1).leaf=true
|
||||
|
||||
local remote = luci.model.uci.cursor():get_bool("dockerd", "globals", "remote_endpoint")
|
||||
if remote then
|
||||
local host = luci.model.uci.cursor():get("dockerd", "globals", "remote_host")
|
||||
local port = luci.model.uci.cursor():get("dockerd", "globals", "remote_port")
|
||||
if not host or not port then
|
||||
return
|
||||
end
|
||||
else
|
||||
local socket = luci.model.uci.cursor():get("dockerd", "globals", "socket_path") or "/var/run/docker.sock"
|
||||
if socket and not nixio.fs.access(socket) then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if (require "luci.model.docker").new():_ping().code ~= 200 then
|
||||
return
|
||||
end
|
||||
|
||||
entry({"admin", "docker", "overview"}, form("dockerman/overview"),_("Overview"), 2).leaf=true
|
||||
entry({"admin", "docker", "containers"}, form("dockerman/containers"), _("Containers"), 3).leaf=true
|
||||
entry({"admin", "docker", "images"}, form("dockerman/images"), _("Images"), 4).leaf=true
|
||||
entry({"admin", "docker", "networks"}, form("dockerman/networks"), _("Networks"), 5).leaf=true
|
||||
entry({"admin", "docker", "volumes"}, form("dockerman/volumes"), _("Volumes"), 6).leaf=true
|
||||
entry({"admin", "docker", "events"}, call("action_events"), _("Events"), 7)
|
||||
|
||||
entry({"admin", "docker", "newcontainer"}, form("dockerman/newcontainer")).leaf=true
|
||||
entry({"admin", "docker", "newnetwork"}, form("dockerman/newnetwork")).leaf=true
|
||||
entry({"admin", "docker", "container"}, form("dockerman/container")).leaf=true
|
||||
|
||||
entry({"admin", "docker", "container_stats"}, call("action_get_container_stats")).leaf=true
|
||||
entry({"admin", "docker", "container_get_archive"}, call("download_archive")).leaf=true
|
||||
entry({"admin", "docker", "container_put_archive"}, call("upload_archive")).leaf=true
|
||||
entry({"admin", "docker", "images_save"}, call("save_images")).leaf=true
|
||||
entry({"admin", "docker", "images_load"}, call("load_images")).leaf=true
|
||||
entry({"admin", "docker", "images_import"}, call("import_images")).leaf=true
|
||||
entry({"admin", "docker", "images_get_tags"}, call("get_image_tags")).leaf=true
|
||||
entry({"admin", "docker", "images_tag"}, call("tag_image")).leaf=true
|
||||
entry({"admin", "docker", "images_untag"}, call("untag_image")).leaf=true
|
||||
entry({"admin", "docker", "confirm"}, call("action_confirm")).leaf=true
|
||||
end
|
||||
|
||||
function action_events()
|
||||
local logs = ""
|
||||
local query ={}
|
||||
|
||||
local dk = docker.new()
|
||||
query["until"] = os.time()
|
||||
local events = dk:events({query = query})
|
||||
|
||||
if events.code == 200 then
|
||||
for _, v in ipairs(events.body) do
|
||||
local date = "unknown"
|
||||
if v and v.time then
|
||||
date = os.date("%Y-%m-%d %H:%M:%S", v.time)
|
||||
end
|
||||
|
||||
local name = v.Actor.Attributes.name or "unknown"
|
||||
local action = v.Action or "unknown"
|
||||
|
||||
if v and v.Type == "container" then
|
||||
local id = v.Actor.ID or "unknown"
|
||||
logs = logs .. string.format("[%s] %s %s Container ID: %s Container Name: %s\n", date, v.Type, action, id, name)
|
||||
elseif v.Type == "network" then
|
||||
local container = v.Actor.Attributes.container or "unknown"
|
||||
local network = v.Actor.Attributes.type or "unknown"
|
||||
logs = logs .. string.format("[%s] %s %s Container ID: %s Network Name: %s Network type: %s\n", date, v.Type, action, container, name, network)
|
||||
elseif v.Type == "image" then
|
||||
local id = v.Actor.ID or "unknown"
|
||||
logs = logs .. string.format("[%s] %s %s Image: %s Image name: %s\n", date, v.Type, action, id, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
luci.template.render("dockerman/logs", {self={syslog = logs, title="Events"}})
|
||||
end
|
||||
|
||||
local calculate_cpu_percent = function(d)
|
||||
if type(d) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local cpu_count = tonumber(d["cpu_stats"]["online_cpus"])
|
||||
local cpu_percent = 0.0
|
||||
local cpu_delta = tonumber(d["cpu_stats"]["cpu_usage"]["total_usage"]) - tonumber(d["precpu_stats"]["cpu_usage"]["total_usage"])
|
||||
local system_delta = tonumber(d["cpu_stats"]["system_cpu_usage"]) - tonumber(d["precpu_stats"]["system_cpu_usage"])
|
||||
if system_delta > 0.0 then
|
||||
cpu_percent = string.format("%.2f", cpu_delta / system_delta * 100.0 * cpu_count)
|
||||
end
|
||||
|
||||
return cpu_percent
|
||||
end
|
||||
|
||||
local get_memory = function(d)
|
||||
if type(d) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local limit =tonumber(d["memory_stats"]["limit"])
|
||||
local usage = tonumber(d["memory_stats"]["usage"]) - tonumber(d["memory_stats"]["stats"]["total_cache"])
|
||||
|
||||
return usage, limit
|
||||
end
|
||||
|
||||
local get_rx_tx = function(d)
|
||||
if type(d) ~="table" then
|
||||
return
|
||||
end
|
||||
|
||||
local data = {}
|
||||
if type(d["networks"]) == "table" then
|
||||
for e, v in pairs(d["networks"]) do
|
||||
data[e] = {
|
||||
bw_tx = tonumber(v.tx_bytes),
|
||||
bw_rx = tonumber(v.rx_bytes)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
function action_get_container_stats(container_id)
|
||||
if container_id then
|
||||
local dk = docker.new()
|
||||
local response = dk.containers:inspect({id = container_id})
|
||||
if response.code == 200 and response.body.State.Running then
|
||||
response = dk.containers:stats({id = container_id, query = {stream = false}})
|
||||
if response.code == 200 then
|
||||
local container_stats = response.body
|
||||
local cpu_percent = calculate_cpu_percent(container_stats)
|
||||
local mem_useage, mem_limit = get_memory(container_stats)
|
||||
local bw_rxtx = get_rx_tx(container_stats)
|
||||
luci.http.status(response.code, response.body.message)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
cpu_percent = cpu_percent,
|
||||
memory = {
|
||||
mem_useage = mem_useage,
|
||||
mem_limit = mem_limit
|
||||
},
|
||||
bw_rxtx = bw_rxtx
|
||||
})
|
||||
else
|
||||
luci.http.status(response.code, response.body.message)
|
||||
luci.http.prepare_content("text/plain")
|
||||
luci.http.write(response.body.message)
|
||||
end
|
||||
else
|
||||
if response.code == 200 then
|
||||
luci.http.status(500, "container "..container_id.." not running")
|
||||
luci.http.prepare_content("text/plain")
|
||||
luci.http.write("Container "..container_id.." not running")
|
||||
else
|
||||
luci.http.status(response.code, response.body.message)
|
||||
luci.http.prepare_content("text/plain")
|
||||
luci.http.write(response.body.message)
|
||||
end
|
||||
end
|
||||
else
|
||||
luci.http.status(404, "No container name or id")
|
||||
luci.http.prepare_content("text/plain")
|
||||
luci.http.write("No container name or id")
|
||||
end
|
||||
end
|
||||
|
||||
function action_confirm()
|
||||
local data = docker:read_status()
|
||||
if data then
|
||||
data = data:gsub("\n","<br />"):gsub(" "," ")
|
||||
code = 202
|
||||
msg = data
|
||||
else
|
||||
code = 200
|
||||
msg = "finish"
|
||||
data = "finish"
|
||||
end
|
||||
|
||||
luci.http.status(code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({info = data})
|
||||
end
|
||||
|
||||
function download_archive()
|
||||
local id = luci.http.formvalue("id")
|
||||
local path = luci.http.formvalue("path")
|
||||
local dk = docker.new()
|
||||
local first
|
||||
|
||||
local cb = function(res, chunk)
|
||||
if res.code == 200 then
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.header('Content-Disposition', 'inline; filename="archive.tar"')
|
||||
luci.http.header('Content-Type', 'application\/x-tar')
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
else
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.prepare_content("text/plain")
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
end
|
||||
end
|
||||
|
||||
local res = dk.containers:get_archive({
|
||||
id = id,
|
||||
query = {
|
||||
path = path
|
||||
}
|
||||
}, cb)
|
||||
end
|
||||
|
||||
function upload_archive(container_id)
|
||||
local path = luci.http.formvalue("upload-path")
|
||||
local dk = docker.new()
|
||||
local ltn12 = require "luci.ltn12"
|
||||
|
||||
local rec_send = function(sinkout)
|
||||
luci.http.setfilehandler(function (meta, chunk, eof)
|
||||
if chunk then
|
||||
ltn12.pump.step(ltn12.source.string(chunk), sinkout)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local res = dk.containers:put_archive({
|
||||
id = container_id,
|
||||
query = {
|
||||
path = path
|
||||
},
|
||||
body = rec_send
|
||||
})
|
||||
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
|
||||
function save_images(container_id)
|
||||
local names = luci.http.formvalue("names")
|
||||
local dk = docker.new()
|
||||
local first
|
||||
|
||||
local cb = function(res, chunk)
|
||||
if res.code == 200 then
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.status(res.code, res.message)
|
||||
luci.http.header('Content-Disposition', 'inline; filename="images.tar"')
|
||||
luci.http.header('Content-Type', 'application\/x-tar')
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
else
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.prepare_content("text/plain")
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
end
|
||||
end
|
||||
|
||||
docker:write_status("Images: saving" .. " " .. container_id .. "...")
|
||||
local res = dk.images:get({
|
||||
id = container_id,
|
||||
query = {
|
||||
names = names
|
||||
}
|
||||
}, cb)
|
||||
docker:clear_status()
|
||||
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
|
||||
function load_images()
|
||||
local path = luci.http.formvalue("upload-path")
|
||||
local dk = docker.new()
|
||||
local ltn12 = require "luci.ltn12"
|
||||
|
||||
local rec_send = function(sinkout)
|
||||
luci.http.setfilehandler(function (meta, chunk, eof)
|
||||
if chunk then
|
||||
ltn12.pump.step(ltn12.source.string(chunk), sinkout)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
docker:write_status("Images: loading...")
|
||||
local res = dk.images:load({body = rec_send})
|
||||
local msg = res and res.body and ( res.body.message or res.body.stream or res.body.error ) or nil
|
||||
if res.code == 200 and msg and msg:match("Loaded image ID") then
|
||||
docker:clear_status()
|
||||
luci.http.status(res.code, msg)
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. msg)
|
||||
luci.http.status(300, msg)
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
|
||||
function import_images()
|
||||
local src = luci.http.formvalue("src")
|
||||
local itag = luci.http.formvalue("tag")
|
||||
local dk = docker.new()
|
||||
local ltn12 = require "luci.ltn12"
|
||||
|
||||
local rec_send = function(sinkout)
|
||||
luci.http.setfilehandler(function (meta, chunk, eof)
|
||||
if chunk then
|
||||
ltn12.pump.step(ltn12.source.string(chunk), sinkout)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
docker:write_status("Images: importing".. " ".. itag .."...\n")
|
||||
local repo = itag and itag:match("^([^:]+)")
|
||||
local tag = itag and itag:match("^[^:]-:([^:]+)")
|
||||
local res = dk.images:create({
|
||||
query = {
|
||||
fromSrc = src or "-",
|
||||
repo = repo or nil,
|
||||
tag = tag or nil
|
||||
},
|
||||
body = not src and rec_send or nil
|
||||
}, docker.import_image_show_status_cb)
|
||||
|
||||
local msg = res and res.body and ( res.body.message )or nil
|
||||
if not msg and #res.body == 0 then
|
||||
msg = res.body.status or res.body.error
|
||||
elseif not msg and #res.body >= 1 then
|
||||
msg = res.body[#res.body].status or res.body[#res.body].error
|
||||
end
|
||||
|
||||
if res.code == 200 and msg and msg:match("sha256:") then
|
||||
docker:clear_status()
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. msg)
|
||||
end
|
||||
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
|
||||
function get_image_tags(image_id)
|
||||
if not image_id then
|
||||
luci.http.status(400, "no image id")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = "no image id"})
|
||||
return
|
||||
end
|
||||
|
||||
local dk = docker.new()
|
||||
local res = dk.images:inspect({
|
||||
id = image_id
|
||||
})
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
|
||||
if res.code == 200 then
|
||||
local tags = res.body.RepoTags
|
||||
luci.http.write_json({tags = tags})
|
||||
else
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
end
|
||||
|
||||
function tag_image(image_id)
|
||||
local src = luci.http.formvalue("tag")
|
||||
local image_id = image_id or luci.http.formvalue("id")
|
||||
|
||||
if type(src) ~= "string" or not image_id then
|
||||
luci.http.status(400, "no image id or tag")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = "no image id or tag"})
|
||||
return
|
||||
end
|
||||
|
||||
local repo = src:match("^([^:]+)")
|
||||
local tag = src:match("^[^:]-:([^:]+)")
|
||||
local dk = docker.new()
|
||||
local res = dk.images:tag({
|
||||
id = image_id,
|
||||
query={
|
||||
repo=repo,
|
||||
tag=tag
|
||||
}
|
||||
})
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
|
||||
if res.code == 201 then
|
||||
local tags = res.body.RepoTags
|
||||
luci.http.write_json({tags = tags})
|
||||
else
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
end
|
||||
|
||||
function untag_image(tag)
|
||||
local tag = tag or luci.http.formvalue("tag")
|
||||
|
||||
if not tag then
|
||||
luci.http.status(400, "no tag name")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = "no tag name"})
|
||||
return
|
||||
end
|
||||
|
||||
local dk = docker.new()
|
||||
local res = dk.images:inspect({name = tag})
|
||||
|
||||
if res.code == 200 then
|
||||
local tags = res.body.RepoTags
|
||||
if #tags > 1 then
|
||||
local r = dk.images:remove({name = tag})
|
||||
local msg = r and r.body and r.body.message or nil
|
||||
luci.http.status(r.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
else
|
||||
luci.http.status(500, "Cannot remove the last tag")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = "Cannot remove the last tag"})
|
||||
end
|
||||
else
|
||||
local msg = res and res.body and res.body.message or nil
|
||||
luci.http.status(res.code, msg)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({message = msg})
|
||||
end
|
||||
end
|
||||
@@ -1,79 +0,0 @@
|
||||
-- Copyright 2021 Florian Eckert <fe@dev.tdt.de>
|
||||
-- Licensed to the public under the Apache License 2.0.
|
||||
|
||||
local m, s, o
|
||||
|
||||
m = Map("dockerd",
|
||||
translate("Docker - Configuration"),
|
||||
translate("DockerMan is a simple docker manager client for LuCI"))
|
||||
|
||||
s = m:section(NamedSection, "globals", "section", translate("Global settings"))
|
||||
|
||||
o = s:option(Flag, "remote_endpoint",
|
||||
translate("Remote Endpoint"),
|
||||
translate("Connect to remote endpoint"))
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "remote_host",
|
||||
translate("Remote Host"),
|
||||
translate("Host or IP Address for the connection to a remote docker instance"))
|
||||
o.datatype = "host"
|
||||
o.rmempty = false
|
||||
o.optional = false
|
||||
o.placeholder = "10.1.1.2"
|
||||
o:depends("remote_endpoint", 1)
|
||||
|
||||
o = s:option(Value, "remote_port",
|
||||
translate("Remote Port"))
|
||||
o.placeholder = "2375"
|
||||
o.datatype = "port"
|
||||
o.rmempty = false
|
||||
o.optional = false
|
||||
o:depends("remote_endpoint", 1)
|
||||
|
||||
if nixio.fs.access("/usr/bin/dockerd") then
|
||||
o = s:option(Value, "data_root",
|
||||
translate("Docker Root Dir"))
|
||||
o.placeholder = "/opt/docker/"
|
||||
o:depends("remote_endpoint", 0)
|
||||
|
||||
o = s:option(Value, "bip",
|
||||
translate("Default bridge"),
|
||||
translate("Configure the default bridge network"))
|
||||
o.placeholder = "172.17.0.1/16"
|
||||
o.datatype = "ipaddr"
|
||||
o:depends("remote_endpoint", 0)
|
||||
|
||||
o = s:option(DynamicList, "registry_mirrors",
|
||||
translate("Registry Mirrors"),
|
||||
translate("It replaces the daemon registry mirrors with a new set of registry mirrors"))
|
||||
o.placeholder = translate("Example: https://hub-mirror.c.163.com")
|
||||
o:value("https://hub-mirror.c.163.com", "https://hub-mirror.c.163.com")
|
||||
o:value("https://ustc-edu-cn.mirror.aliyuncs.com/", "https://ustc-edu-cn.mirror.aliyuncs.com/")
|
||||
o:depends("remote_endpoint", 0)
|
||||
|
||||
o = s:option(Value, "max_concurrent_downloads", translate("Max Concurrent Downloads"), translate("Set threads for docker pull, 0 means default"))
|
||||
o.placeholder = 0
|
||||
o.datatype = "uinteger"
|
||||
o:depends("remote_endpoint", 0)
|
||||
|
||||
o = s:option(ListValue, "log_level",
|
||||
translate("Log Level"),
|
||||
translate('Set the logging level'))
|
||||
o:value("debug", translate("Debug"))
|
||||
o:value("", translate("Info")) -- This is the default debug level from the deamon is optin is not set
|
||||
o:value("warn", translate("Warning"))
|
||||
o:value("error", translate("Error"))
|
||||
o:value("fatal", translate("Fatal"))
|
||||
o.rmempty = true
|
||||
o:depends("remote_endpoint", 0)
|
||||
|
||||
o = s:option(DynamicList, "hosts",
|
||||
translate("Client connection"),
|
||||
translate('Specifies where the Docker daemon will listen for client connections (default: unix:///var/run/docker.sock)'))
|
||||
o.placeholder = translate("Example: tcp://0.0.0.0:2375")
|
||||
o.rmempty = true
|
||||
o:depends("remote_endpoint", 0)
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,832 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
require "luci.util"
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
local dk = docker.new()
|
||||
|
||||
container_id = arg[1]
|
||||
local action = arg[2] or "info"
|
||||
|
||||
local m, s, o
|
||||
local images, networks, container_info, res
|
||||
|
||||
if not container_id then
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:inspect({id = container_id})
|
||||
if res.code < 300 then
|
||||
container_info = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.networks:list()
|
||||
if res.code < 300 then
|
||||
networks = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local get_ports = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.PortBindings then
|
||||
for inter, out in pairs(d.HostConfig.PortBindings) do
|
||||
if out[1] and out[1]["HostPort"] then
|
||||
local host_ip = ""
|
||||
if out[1].HostIp and out[1].HostIp ~= "" then
|
||||
host_ip = out[1].HostIp
|
||||
else
|
||||
host_ip = "*"
|
||||
end
|
||||
local line = host_ip .. ":" .. out[1]["HostPort"] .. ":" .. inter
|
||||
data = (data and (data .. "<br />") or "") .. line
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_env = function(d)
|
||||
local data
|
||||
|
||||
if d.Config and d.Config.Env then
|
||||
for _,v in ipairs(d.Config.Env) do
|
||||
data = (data and (data .. "<br />") or "") .. v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_command = function(d)
|
||||
local data
|
||||
|
||||
if d.Config and d.Config.Cmd then
|
||||
for _,v in ipairs(d.Config.Cmd) do
|
||||
data = (data and (data .. " ") or "") .. v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_mounts = function(d)
|
||||
local data
|
||||
|
||||
if d.Mounts then
|
||||
for _,v in ipairs(d.Mounts) do
|
||||
local v_sorce_d, v_dest_d
|
||||
local v_sorce = ""
|
||||
local v_dest = ""
|
||||
for v_sorce_d in v["Source"]:gmatch('[^/]+') do
|
||||
if v_sorce_d and #v_sorce_d > 12 then
|
||||
v_sorce = v_sorce .. "/" .. v_sorce_d:sub(1,12) .. "..."
|
||||
else
|
||||
v_sorce = v_sorce .."/".. v_sorce_d
|
||||
end
|
||||
end
|
||||
for v_dest_d in v["Destination"]:gmatch('[^/]+') do
|
||||
if v_dest_d and #v_dest_d > 12 then
|
||||
v_dest = v_dest .. "/" .. v_dest_d:sub(1,12) .. "..."
|
||||
else
|
||||
v_dest = v_dest .."/".. v_dest_d
|
||||
end
|
||||
end
|
||||
data = (data and (data .. "<br />") or "") .. v_sorce .. ":" .. v["Destination"] .. (v["Mode"] ~= "" and (":" .. v["Mode"]) or "")
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_device = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.Devices then
|
||||
for _,v in ipairs(d.HostConfig.Devices) do
|
||||
data = (data and (data .. "<br />") or "") .. v["PathOnHost"] .. ":" .. v["PathInContainer"] .. (v["CgroupPermissions"] ~= "" and (":" .. v["CgroupPermissions"]) or "")
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_links = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.Links then
|
||||
for _,v in ipairs(d.HostConfig.Links) do
|
||||
data = (data and (data .. "<br />") or "") .. v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_tmpfs = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.Tmpfs then
|
||||
for k, v in pairs(d.HostConfig.Tmpfs) do
|
||||
data = (data and (data .. "<br />") or "") .. k .. (v~="" and ":" or "")..v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_dns = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.Dns then
|
||||
for _, v in ipairs(d.HostConfig.Dns) do
|
||||
data = (data and (data .. "<br />") or "") .. v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_sysctl = function(d)
|
||||
local data
|
||||
|
||||
if d.HostConfig and d.HostConfig.Sysctls then
|
||||
for k, v in pairs(d.HostConfig.Sysctls) do
|
||||
data = (data and (data .. "<br />") or "") .. k..":"..v
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local get_networks = function(d)
|
||||
local data={}
|
||||
|
||||
if d.NetworkSettings and d.NetworkSettings.Networks and type(d.NetworkSettings.Networks) == "table" then
|
||||
for k,v in pairs(d.NetworkSettings.Networks) do
|
||||
data[k] = v.IPAddress or ""
|
||||
end
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
|
||||
local start_stop_remove = function(m, cmd)
|
||||
local res
|
||||
|
||||
docker:clear_status()
|
||||
docker:append_status("Containers: " .. cmd .. " " .. container_id .. "...")
|
||||
|
||||
if cmd ~= "upgrade" then
|
||||
res = dk.containers[cmd](dk, {id = container_id})
|
||||
else
|
||||
res = dk.containers_upgrade(dk, {id = container_id})
|
||||
end
|
||||
|
||||
if res and res.code >= 300 then
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id))
|
||||
else
|
||||
docker:clear_status()
|
||||
if cmd ~= "remove" and cmd ~= "upgrade" then
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id))
|
||||
else
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
m=SimpleForm("docker",
|
||||
translatef("Docker - Container (%s)", container_info.Name:sub(2)),
|
||||
translate("On this page, the selected container can be managed."))
|
||||
m.redirect = luci.dispatcher.build_url("admin/docker/containers")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template = "cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "_start")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Start")
|
||||
o.inputstyle = "apply"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"start")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_restart")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Restart")
|
||||
o.inputstyle = "reload"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"restart")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_stop")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Stop")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"stop")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_kill")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Kill")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"kill")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_upgrade")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Upgrade")
|
||||
o.inputstyle = "reload"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"upgrade")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_duplicate")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Duplicate/Edit")
|
||||
o.inputstyle = "add"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer/duplicate/"..container_id))
|
||||
end
|
||||
|
||||
o = s:option(Button, "_remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Remove")
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"remove")
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/container"
|
||||
|
||||
if action == "info" then
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
table_info = {
|
||||
["01name"] = {
|
||||
_key = translate("Name"),
|
||||
_value = container_info.Name:sub(2) or "-",
|
||||
_button=translate("Update")
|
||||
},
|
||||
["02id"] = {
|
||||
_key = translate("ID"),
|
||||
_value = container_info.Id or "-"
|
||||
},
|
||||
["03image"] = {
|
||||
_key = translate("Image"),
|
||||
_value = container_info.Config.Image .. "<br />" .. container_info.Image
|
||||
},
|
||||
["04status"] = {
|
||||
_key = translate("Status"),
|
||||
_value = container_info.State and container_info.State.Status or "-"
|
||||
},
|
||||
["05created"] = {
|
||||
_key = translate("Created"),
|
||||
_value = container_info.Created or "-"
|
||||
},
|
||||
}
|
||||
|
||||
if container_info.State.Status == "running" then
|
||||
table_info["06start"] = {
|
||||
_key = translate("Start Time"),
|
||||
_value = container_info.State and container_info.State.StartedAt or "-"
|
||||
}
|
||||
else
|
||||
table_info["06start"] = {
|
||||
_key = translate("Finish Time"),
|
||||
_value = container_info.State and container_info.State.FinishedAt or "-"
|
||||
}
|
||||
end
|
||||
|
||||
table_info["07healthy"] = {
|
||||
_key = translate("Healthy"),
|
||||
_value = container_info.State and container_info.State.Health and container_info.State.Health.Status or "-"
|
||||
}
|
||||
table_info["08restart"] = {
|
||||
_key = translate("Restart Policy"),
|
||||
_value = container_info.HostConfig and container_info.HostConfig.RestartPolicy and container_info.HostConfig.RestartPolicy.Name or "-",
|
||||
_button=translate("Update")
|
||||
}
|
||||
table_info["081user"] = {
|
||||
_key = translate("User"),
|
||||
_value = container_info.Config and (container_info.Config.User ~="" and container_info.Config.User or "-") or "-"
|
||||
}
|
||||
table_info["09mount"] = {
|
||||
_key = translate("Mount/Volume"),
|
||||
_value = get_mounts(container_info) or "-"
|
||||
}
|
||||
table_info["10cmd"] = {
|
||||
_key = translate("Command"),
|
||||
_value = get_command(container_info) or "-"
|
||||
}
|
||||
table_info["11env"] = {
|
||||
_key = translate("Env"),
|
||||
_value = get_env(container_info) or "-"
|
||||
}
|
||||
table_info["12ports"] = {
|
||||
_key = translate("Ports"),
|
||||
_value = get_ports(container_info) or "-"
|
||||
}
|
||||
table_info["13links"] = {
|
||||
_key = translate("Links"),
|
||||
_value = get_links(container_info) or "-"
|
||||
}
|
||||
table_info["14device"] = {
|
||||
_key = translate("Device"),
|
||||
_value = get_device(container_info) or "-"
|
||||
}
|
||||
table_info["15tmpfs"] = {
|
||||
_key = translate("Tmpfs"),
|
||||
_value = get_tmpfs(container_info) or "-"
|
||||
}
|
||||
table_info["16dns"] = {
|
||||
_key = translate("DNS"),
|
||||
_value = get_dns(container_info) or "-"
|
||||
}
|
||||
table_info["17sysctl"] = {
|
||||
_key = translate("Sysctl"),
|
||||
_value = get_sysctl(container_info) or "-"
|
||||
}
|
||||
|
||||
info_networks = get_networks(container_info)
|
||||
list_networks = {}
|
||||
for _, v in ipairs (networks) do
|
||||
if v.Name then
|
||||
local parent = v.Options and v.Options.parent or nil
|
||||
local ip = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
|
||||
ipv6 = v.IPAM and v.IPAM.Config and v.IPAM.Config[2] and v.IPAM.Config[2].Subnet or nil
|
||||
local network_name = v.Name .. " | " .. v.Driver .. (parent and (" | " .. parent) or "") .. (ip and (" | " .. ip) or "").. (ipv6 and (" | " .. ipv6) or "")
|
||||
list_networks[v.Name] = network_name
|
||||
end
|
||||
end
|
||||
|
||||
if type(info_networks)== "table" then
|
||||
for k,v in pairs(info_networks) do
|
||||
table_info["14network"..k] = {
|
||||
_key = translate("Network"),
|
||||
value = k.. (v~="" and (" | ".. v) or ""),
|
||||
_button=translate("Disconnect")
|
||||
}
|
||||
list_networks[k]=nil
|
||||
end
|
||||
end
|
||||
|
||||
table_info["15connect"] = {
|
||||
_key = translate("Connect Network"),
|
||||
_value = list_networks ,_opts = "",
|
||||
_button=translate("Connect")
|
||||
}
|
||||
|
||||
s = m:section(Table,table_info)
|
||||
s.nodescr=true
|
||||
s.formvalue=function(self, section)
|
||||
return table_info
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_key", translate("Info"))
|
||||
o.width = "20%"
|
||||
|
||||
o = s:option(ListValue, "_value")
|
||||
o.render = function(self, section, scope)
|
||||
if table_info[section]._key == translate("Name") then
|
||||
self:reset_values()
|
||||
self.template = "cbi/value"
|
||||
self.size = 30
|
||||
self.keylist = {}
|
||||
self.vallist = {}
|
||||
self.default=table_info[section]._value
|
||||
Value.render(self, section, scope)
|
||||
elseif table_info[section]._key == translate("Restart Policy") then
|
||||
self.template = "cbi/lvalue"
|
||||
self:reset_values()
|
||||
self.size = nil
|
||||
self:value("no", "No")
|
||||
self:value("unless-stopped", "Unless stopped")
|
||||
self:value("always", "Always")
|
||||
self:value("on-failure", "On failure")
|
||||
self.default=table_info[section]._value
|
||||
ListValue.render(self, section, scope)
|
||||
elseif table_info[section]._key == translate("Connect Network") then
|
||||
self.template = "cbi/lvalue"
|
||||
self:reset_values()
|
||||
self.size = nil
|
||||
for k,v in pairs(list_networks) do
|
||||
if k ~= "host" then
|
||||
self:value(k,v)
|
||||
end
|
||||
end
|
||||
self.default=table_info[section]._value
|
||||
ListValue.render(self, section, scope)
|
||||
else
|
||||
self:reset_values()
|
||||
self.rawhtml=true
|
||||
self.template = "cbi/dvalue"
|
||||
self.default=table_info[section]._value
|
||||
DummyValue.render(self, section, scope)
|
||||
end
|
||||
end
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section, value)
|
||||
table_info[section]._value=value
|
||||
end
|
||||
o.validate = function(self, value)
|
||||
return value
|
||||
end
|
||||
|
||||
o = s:option(Value, "_opts")
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section, value)
|
||||
table_info[section]._opts=value
|
||||
end
|
||||
o.validate = function(self, value)
|
||||
return value
|
||||
end
|
||||
o.render = function(self, section, scope)
|
||||
if table_info[section]._key==translate("Connect Network") then
|
||||
self.template = "cbi/value"
|
||||
self.keylist = {}
|
||||
self.vallist = {}
|
||||
self.placeholder = "10.1.1.254"
|
||||
self.datatype = "ip4addr"
|
||||
self.default=table_info[section]._opts
|
||||
Value.render(self, section, scope)
|
||||
else
|
||||
self.rawhtml=true
|
||||
self.template = "cbi/dvalue"
|
||||
self.default=table_info[section]._opts
|
||||
DummyValue.render(self, section, scope)
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Button, "_button")
|
||||
o.forcewrite = true
|
||||
o.render = function(self, section, scope)
|
||||
if table_info[section]._button and table_info[section]._value ~= nil then
|
||||
self.inputtitle=table_info[section]._button
|
||||
self.template = "cbi/button"
|
||||
self.inputstyle = "edit"
|
||||
Button.render(self, section, scope)
|
||||
else
|
||||
self.template = "cbi/dvalue"
|
||||
self.default=""
|
||||
DummyValue.render(self, section, scope)
|
||||
end
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
local res
|
||||
|
||||
docker:clear_status()
|
||||
|
||||
if section == "01name" then
|
||||
docker:append_status("Containers: rename " .. container_id .. "...")
|
||||
local new_name = table_info[section]._value
|
||||
res = dk.containers:rename({
|
||||
id = container_id,
|
||||
query = {
|
||||
name=new_name
|
||||
}
|
||||
})
|
||||
elseif section == "08restart" then
|
||||
docker:append_status("Containers: update " .. container_id .. "...")
|
||||
local new_restart = table_info[section]._value
|
||||
res = dk.containers:update({
|
||||
id = container_id,
|
||||
body = {
|
||||
RestartPolicy = {
|
||||
Name = new_restart
|
||||
}
|
||||
}
|
||||
})
|
||||
elseif table_info[section]._key == translate("Network") then
|
||||
local _,_,leave_network
|
||||
|
||||
_, _, leave_network = table_info[section]._value:find("(.-) | .+")
|
||||
leave_network = leave_network or table_info[section]._value
|
||||
docker:append_status("Network: disconnect " .. leave_network .. container_id .. "...")
|
||||
res = dk.networks:disconnect({
|
||||
name = leave_network,
|
||||
body = {
|
||||
Container = container_id
|
||||
}
|
||||
})
|
||||
elseif section == "15connect" then
|
||||
local connect_network = table_info[section]._value
|
||||
local network_opiton
|
||||
if connect_network ~= "none"
|
||||
and connect_network ~= "bridge"
|
||||
and connect_network ~= "host" then
|
||||
|
||||
network_opiton = table_info[section]._opts ~= "" and {
|
||||
IPAMConfig={
|
||||
IPv4Address=table_info[section]._opts
|
||||
}
|
||||
} or nil
|
||||
end
|
||||
docker:append_status("Network: connect " .. connect_network .. container_id .. "...")
|
||||
res = dk.networks:connect({
|
||||
name = connect_network,
|
||||
body = {
|
||||
Container = container_id,
|
||||
EndpointConfig= network_opiton
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
if res and res.code > 300 then
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
|
||||
else
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id.."/info"))
|
||||
end
|
||||
elseif action == "resources" then
|
||||
s = m:section(SimpleSection)
|
||||
o = s:option( Value, "cpus",
|
||||
translate("CPUs"),
|
||||
translate("Number of CPUs. Number is a fractional number. 0.000 means no limit."))
|
||||
o.placeholder = "1.5"
|
||||
o.rmempty = true
|
||||
o.datatype="ufloat"
|
||||
o.default = container_info.HostConfig.NanoCpus / (10^9)
|
||||
|
||||
o = s:option(Value, "cpushares",
|
||||
translate("CPU Shares Weight"),
|
||||
translate("CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024."))
|
||||
o.placeholder = "1024"
|
||||
o.rmempty = true
|
||||
o.datatype="uinteger"
|
||||
o.default = container_info.HostConfig.CpuShares
|
||||
|
||||
o = s:option(Value, "memoryswap",
|
||||
translate("Total Memory"),
|
||||
translate("Memory + Swap limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M. -1 means unlimited."))
|
||||
o.placeholder = "-1"
|
||||
o.rmempty = true
|
||||
o.default = container_info.HostConfig.MemorySwap > 0 and ((container_info.HostConfig.MemorySwap / 1024 /1024) .. "M") or "-1"
|
||||
|
||||
o = s:option(Value, "memory",
|
||||
translate("Memory"),
|
||||
translate("Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M. Must less than Total Memory"))
|
||||
o.placeholder = "128m"
|
||||
o.rmempty = true
|
||||
o.default = container_info.HostConfig.Memory ~=0 and ((container_info.HostConfig.Memory / 1024 /1024) .. "M") or 0
|
||||
|
||||
o = s:option(Value, "blkioweight",
|
||||
translate("Block IO Weight"),
|
||||
translate("Block IO weight (relative weight) accepts a weight value between 10 and 1000."))
|
||||
o.placeholder = "500"
|
||||
o.rmempty = true
|
||||
o.datatype="uinteger"
|
||||
o.default = container_info.HostConfig.BlkioWeight
|
||||
|
||||
m.handle = function(self, state, data)
|
||||
if state == FORM_VALID then
|
||||
local memory = data.memory
|
||||
if memory and memory ~= 0 then
|
||||
_,_,n,unit = memory:find("([%d%.]+)([%l%u]+)")
|
||||
if n then
|
||||
unit = unit and unit:sub(1,1):upper() or "B"
|
||||
if unit == "M" then
|
||||
memory = tonumber(n) * 1024 * 1024
|
||||
elseif unit == "G" then
|
||||
memory = tonumber(n) * 1024 * 1024 * 1024
|
||||
elseif unit == "K" then
|
||||
memory = tonumber(n) * 1024
|
||||
else
|
||||
memory = tonumber(n)
|
||||
end
|
||||
end
|
||||
end
|
||||
local memoryswap = data.memoryswap
|
||||
if memoryswap and memoryswap ~= 0 and memoryswap ~= "-1" then
|
||||
_,_,n,unit = memoryswap:find("([%d%.]+)([%l%u]+)")
|
||||
if n then
|
||||
unit = unit and unit:sub(1,1):upper() or "B"
|
||||
if unit == "M" then
|
||||
memoryswap = tonumber(n) * 1024 * 1024
|
||||
elseif unit == "G" then
|
||||
memoryswap = tonumber(n) * 1024 * 1024 * 1024
|
||||
elseif unit == "K" then
|
||||
memoryswap = tonumber(n) * 1024
|
||||
else
|
||||
memoryswap = tonumber(n)
|
||||
end
|
||||
end
|
||||
else
|
||||
memoryswap = -1
|
||||
end
|
||||
|
||||
request_body = {
|
||||
BlkioWeight = tonumber(data.blkioweight),
|
||||
NanoCPUs = tonumber(data.cpus)*10^9,
|
||||
MemorySwap = tonumber(memoryswap),
|
||||
Memory = tonumber(memory),
|
||||
CpuShares = tonumber(data.cpushares)
|
||||
}
|
||||
|
||||
docker:write_status("Containers: update " .. container_id .. "...")
|
||||
local res = dk.containers:update({id = container_id, body = request_body})
|
||||
if res and res.code >= 300 then
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
|
||||
else
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/container/"..container_id.."/resources"))
|
||||
end
|
||||
end
|
||||
|
||||
elseif action == "file" then
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/container_file"
|
||||
s.container = container_id
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
elseif action == "inspect" then
|
||||
s = m:section(SimpleSection)
|
||||
s.syslog = luci.jsonc.stringify(container_info, true)
|
||||
s.title = translate("Container Inspect")
|
||||
s.template = "dockerman/logs"
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
elseif action == "logs" then
|
||||
local logs = ""
|
||||
local query ={
|
||||
stdout = 1,
|
||||
stderr = 1,
|
||||
tail = 1000
|
||||
}
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
|
||||
logs = dk.containers:logs({id = container_id, query = query})
|
||||
if logs.code == 200 then
|
||||
s.syslog=logs.body
|
||||
else
|
||||
s.syslog="Get Logs ERROR\n"..logs.code..": "..logs.body
|
||||
end
|
||||
|
||||
s.title=translate("Container Logs")
|
||||
s.template = "dockerman/logs"
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
elseif action == "console" then
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
local cmd_docker = luci.util.exec("command -v docker"):match("^.+docker") or nil
|
||||
local cmd_ttyd = luci.util.exec("command -v ttyd"):match("^.+ttyd") or nil
|
||||
|
||||
if cmd_docker and cmd_ttyd and container_info.State.Status == "running" then
|
||||
local cmd = "/bin/sh"
|
||||
local uid
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
|
||||
o = s:option(Value, "command", translate("Command"))
|
||||
o:value("/bin/sh", "/bin/sh")
|
||||
o:value("/bin/ash", "/bin/ash")
|
||||
o:value("/bin/bash", "/bin/bash")
|
||||
o.default = "/bin/sh"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section, value)
|
||||
cmd = value
|
||||
end
|
||||
|
||||
o = s:option(Value, "uid", translate("UID"))
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section, value)
|
||||
uid = value
|
||||
end
|
||||
|
||||
o = s:option(Button, "connect")
|
||||
o.render = function(self, section, scope)
|
||||
self.inputstyle = "add"
|
||||
self.title = " "
|
||||
self.inputtitle = translate("Connect")
|
||||
Button.render(self, section, scope)
|
||||
end
|
||||
o.write = function(self, section)
|
||||
local cmd_docker = luci.util.exec("command -v docker"):match("^.+docker") or nil
|
||||
local cmd_ttyd = luci.util.exec("command -v ttyd"):match("^.+ttyd") or nil
|
||||
|
||||
if not cmd_docker or not cmd_ttyd or cmd_docker:match("^%s+$") or cmd_ttyd:match("^%s+$")then
|
||||
return
|
||||
end
|
||||
|
||||
local pid = luci.util.trim(luci.util.exec("netstat -lnpt | grep :7682 | grep ttyd | tr -s ' ' | cut -d ' ' -f7 | cut -d'/' -f1"))
|
||||
if pid and pid ~= "" then
|
||||
luci.util.exec("kill -9 " .. pid)
|
||||
end
|
||||
|
||||
local hosts
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local remote = uci:get_bool("dockerd", "globals", "remote_endpoint") or false
|
||||
local host = nil
|
||||
local port = nil
|
||||
local socket = nil
|
||||
|
||||
if remote then
|
||||
host = uci:get("dockerd", "globals", "remote_host") or nil
|
||||
port = uci:get("dockerd", "globals", "remote_port") or nil
|
||||
else
|
||||
socket = uci:get("dockerd", "globals", "socket_path") or "/var/run/docker.sock"
|
||||
end
|
||||
|
||||
if remote and host and port then
|
||||
hosts = host .. ':'.. port
|
||||
elseif socket then
|
||||
hosts = socket
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
if uid and uid ~= "" then
|
||||
uid = "-u " .. uid
|
||||
else
|
||||
uid = ""
|
||||
end
|
||||
|
||||
local start_cmd = string.format('%s -d 2 --once -p 7682 %s -H "unix://%s" exec -it %s %s %s&', cmd_ttyd, cmd_docker, hosts, uid, container_id, cmd)
|
||||
|
||||
os.execute(start_cmd)
|
||||
|
||||
o = s:option(DummyValue, "console")
|
||||
o.container_id = container_id
|
||||
o.template = "dockerman/container_console"
|
||||
end
|
||||
end
|
||||
elseif action == "stats" then
|
||||
local response = dk.containers:top({id = container_id, query = {ps_args="-aux"}})
|
||||
local container_top
|
||||
|
||||
if response.code ~= 409 then
|
||||
if response.code ~= 200 then
|
||||
response = dk.containers:top({id = container_id})
|
||||
end
|
||||
|
||||
if response.code ~= 200 then
|
||||
response = dk.containers:top({id = container_id, query = {ps_args="-ww"}})
|
||||
end
|
||||
|
||||
if response.code == 200 then
|
||||
container_top = response.body
|
||||
end
|
||||
|
||||
local table_stats = {
|
||||
cpu = {
|
||||
key=translate("CPU Usage"),
|
||||
value='-'
|
||||
},
|
||||
memory = {
|
||||
key=translate("Memory Usage"),
|
||||
value='-'
|
||||
}
|
||||
}
|
||||
s = m:section(Table, table_stats, translate("Stats"))
|
||||
s:option(DummyValue, "key", translate("Stats")).width="33%"
|
||||
s:option(DummyValue, "value")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.container_id = container_id
|
||||
s.template = "dockerman/container_stats"
|
||||
end
|
||||
|
||||
if type(container_top) == "table" then
|
||||
local top_section = m:section(Table, container_top.Processes, translate("TOP"))
|
||||
for i, v in ipairs(container_top.Titles) do
|
||||
top_section:option(DummyValue, i, translate(v))
|
||||
end
|
||||
end
|
||||
|
||||
m.submit = false
|
||||
m.reset = false
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,257 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local http = require "luci.http"
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
local images, networks, containers, res
|
||||
|
||||
local dk = docker.new()
|
||||
res = dk.images:list()
|
||||
if res.code <300 then
|
||||
images = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.networks:list()
|
||||
if res.code <300 then
|
||||
networks = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
})
|
||||
if res.code <300 then
|
||||
containers = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local urlencode = luci.http.protocol and luci.http.protocol.urlencode or luci.util.urlencode
|
||||
|
||||
function get_containers()
|
||||
local data = {}
|
||||
|
||||
if type(containers) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i, v in ipairs(containers) do
|
||||
local index = v.Id
|
||||
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["_id"] = v.Id:sub(1,12)
|
||||
data[index]["_name"] = v.Names[1]:sub(2)
|
||||
data[index]["_status"] = v.Status
|
||||
|
||||
if v.Status:find("^Up") then
|
||||
data[index]["_status"] = '<font color="green">'.. data[index]["_status"] .. "</font>"
|
||||
else
|
||||
data[index]["_status"] = '<font color="red">'.. data[index]["_status"] .. "</font>"
|
||||
end
|
||||
|
||||
if (type(v.NetworkSettings) == "table" and type(v.NetworkSettings.Networks) == "table") then
|
||||
for networkname, netconfig in pairs(v.NetworkSettings.Networks) do
|
||||
data[index]["_network"] = (data[index]["_network"] ~= nil and (data[index]["_network"] .." | ") or "").. networkname .. (netconfig.IPAddress ~= "" and (": " .. netconfig.IPAddress) or "")
|
||||
end
|
||||
end
|
||||
|
||||
if v.Ports and next(v.Ports) ~= nil then
|
||||
data[index]["_ports"] = nil
|
||||
for _, v2 in ipairs(v.Ports) do
|
||||
local port_string = ""
|
||||
|
||||
-- Add clickable link for TCP ports
|
||||
if v2.PublicPort and v2.Type == "tcp" then
|
||||
local url = (v2.IP and v2.IP ~= "" and v2.IP ~= "0.0.0.0" and v2.IP ~= "::")
|
||||
and ("'http://" .. (v2.IP:find(":") and ("[" .. v2.IP .. "]") or v2.IP) .. ":" .. v2.PublicPort .. "'")
|
||||
or ("(window.location.origin.match(/^(.+):\\\\d+$/) && window.location.origin.match(/^(.+):\\\\d+$/)[1] || window.location.origin) + ':" .. v2.PublicPort .. "'")
|
||||
port_string = '<a href="javascript:void(0);" onclick="window.open(' .. url .. ', \'_blank\');">'
|
||||
end
|
||||
|
||||
-- Build display string: IP:PublicPort:PrivatePort/Type
|
||||
port_string = port_string .. (v2.IP and v2.IP ~= ""
|
||||
and v2.IP ~= "0.0.0.0" and v2.IP ~= "::" and (v2.IP .. ":") or "*:")
|
||||
.. (v2.PublicPort and (v2.PublicPort .. ":") or "")
|
||||
.. (v2.PrivatePort and (v2.PrivatePort .. "/") or "")
|
||||
.. (v2.Type or "")
|
||||
.. (v2.PublicPort and v2.Type == "tcp" and "</a>" or "")
|
||||
|
||||
data[index]["_ports"] = (data[index]["_ports"] and (data[index]["_ports"] .. ", ") or "") .. port_string
|
||||
end
|
||||
end
|
||||
|
||||
for ii,iv in ipairs(images) do
|
||||
if iv.Id == v.ImageID then
|
||||
if iv.RepoTags and iv.RepoTags[1] then
|
||||
data[index]["_image"] = iv.RepoTags[1]
|
||||
elseif iv.RepoDigests and iv.RepoDigests[1] then
|
||||
data[index]["_image"] = iv.RepoDigests[1]:gsub("(.-)@.+", "%1") .. ":<none>"
|
||||
else
|
||||
data[index]["_image"] = "<none>"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
data[index]["_image_id"] = v.ImageID:sub(8,20)
|
||||
data[index]["_command"] = v.Command
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local container_list = get_containers()
|
||||
|
||||
m = SimpleForm("docker",
|
||||
translate("Docker - Containers"),
|
||||
translate("This page displays all containers that have been created on the connected docker host."))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table, container_list, translate("Containers overview"))
|
||||
s.addremove = false
|
||||
s.sectionhead = translate("Containers")
|
||||
s.sortable = false
|
||||
s.template = "cbi/tblsection"
|
||||
s.extedit = luci.dispatcher.build_url("admin", "docker", "container","%s")
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write=function(self, section, value)
|
||||
container_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
o.width="10%"
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Container Name"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_status", translate("Status"))
|
||||
o.width="15%"
|
||||
o.rawhtml=true
|
||||
|
||||
o = s:option(DummyValue, "_network", translate("Network"))
|
||||
o.width="15%"
|
||||
|
||||
o = s:option(DummyValue, "_ports", translate("Ports"))
|
||||
o.width="10%"
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_image", translate("Image"))
|
||||
o.width="10%"
|
||||
|
||||
o = s:option(DummyValue, "_command", translate("Command"))
|
||||
o.width="20%"
|
||||
|
||||
local start_stop_remove = function(m,cmd)
|
||||
local container_selected = {}
|
||||
|
||||
for k in pairs(container_list) do
|
||||
if container_list[k]._selected == 1 then
|
||||
container_selected[#container_selected + 1] = container_list[k]._name
|
||||
end
|
||||
end
|
||||
|
||||
if #container_selected > 0 then
|
||||
local success = true
|
||||
|
||||
docker:clear_status()
|
||||
for _, cont in ipairs(container_selected) do
|
||||
docker:append_status("Containers: " .. cmd .. " " .. cont .. "...")
|
||||
local res = dk.containers[cmd](dk, {id = cont})
|
||||
if res and res.code >= 300 then
|
||||
success = false
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
|
||||
end
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "_new")
|
||||
o.inputtitle= translate("Add")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "add"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
|
||||
end
|
||||
|
||||
o = s:option(Button, "_start")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Start")
|
||||
o.inputstyle = "apply"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"start")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_restart")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Restart")
|
||||
o.inputstyle = "reload"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"restart")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_stop")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Stop")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"stop")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_kill")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Kill")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"kill")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Remove")
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"remove")
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,280 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
local dk = docker.new()
|
||||
|
||||
local containers, images, res
|
||||
local m, s, o
|
||||
|
||||
res = dk.images:list()
|
||||
if res.code < 300 then
|
||||
images = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
})
|
||||
if res.code < 300 then
|
||||
containers = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
function get_images()
|
||||
local data = {}
|
||||
|
||||
for i, v in ipairs(images) do
|
||||
local index = v.Created .. v.Id
|
||||
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["id"] = v.Id:sub(8)
|
||||
data[index]["_id"] = '<a href="javascript:new_tag(\''..v.Id:sub(8,20)..'\')" class="dockerman-link" title="'..translate("New tag")..'">' .. v.Id:sub(8,20) .. '</a>'
|
||||
|
||||
if v.RepoTags and next(v.RepoTags)~=nil then
|
||||
for i, v1 in ipairs(v.RepoTags) do
|
||||
data[index]["_tags"] =(data[index]["_tags"] and ( data[index]["_tags"] .. "<br />" )or "") .. ((v1:match("<none>") or (#v.RepoTags == 1)) and v1 or ('<a href="javascript:un_tag(\''..v1..'\')" class="dockerman_link" title="'..translate("Remove tag")..'" >' .. v1 .. '</a>'))
|
||||
|
||||
if not data[index]["tag"] then
|
||||
data[index]["tag"] = v1
|
||||
end
|
||||
end
|
||||
else
|
||||
data[index]["_tags"] = v.RepoDigests[1] and v.RepoDigests[1]:match("^(.-)@.+")
|
||||
data[index]["_tags"] = (data[index]["_tags"] and data[index]["_tags"] or "<none>" ).. ":<none>"
|
||||
end
|
||||
|
||||
data[index]["_tags"] = data[index]["_tags"]:gsub("<none>","<none>")
|
||||
for ci,cv in ipairs(containers) do
|
||||
if v.Id == cv.ImageID then
|
||||
data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
|
||||
'<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2).."</a>"
|
||||
end
|
||||
end
|
||||
|
||||
data[index]["_size"] = string.format("%.2f", tostring(v.Size/1024/1024)).."MB"
|
||||
data[index]["_created"] = os.date("%Y/%m/%d %H:%M:%S",v.Created)
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local image_list = get_images()
|
||||
|
||||
m = SimpleForm("docker",
|
||||
translate("Docker - Images"),
|
||||
translate("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
|
||||
|
||||
local pull_value={
|
||||
_image_tag_name="",
|
||||
_registry="index.docker.io"
|
||||
}
|
||||
|
||||
s = m:section(SimpleSection,
|
||||
translate("Pull Image"),
|
||||
translate("By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry."))
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Value, "_image_tag_name")
|
||||
o.template = "dockerman/cbi/inlinevalue"
|
||||
o.placeholder="lisaac/luci:latest"
|
||||
o.write = function(self, section, value)
|
||||
local hastag = value:find(":")
|
||||
|
||||
if not hastag then
|
||||
value = value .. ":latest"
|
||||
end
|
||||
pull_value["_image_tag_name"] = value
|
||||
end
|
||||
|
||||
o = s:option(Button, "_pull")
|
||||
o.inputtitle= translate("Pull")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "add"
|
||||
o.write = function(self, section)
|
||||
local tag = pull_value["_image_tag_name"]
|
||||
local json_stringify = luci.jsonc and luci.jsonc.stringify
|
||||
|
||||
if tag and tag ~= "" then
|
||||
docker:write_status("Images: " .. "pulling" .. " " .. tag .. "...\n")
|
||||
local res = dk.images:create({query = {fromImage=tag}}, docker.pull_image_show_status_cb)
|
||||
|
||||
if res and res.code == 200 and (res.body[#res.body] and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. tag)) then
|
||||
docker:clear_status()
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)).. "\n")
|
||||
end
|
||||
else
|
||||
docker:append_status("code: 400 please input the name of image name!")
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection,
|
||||
translate("Import Image"),
|
||||
translate("When pressing the Import button, both a local image can be loaded onto the system and a valid image tar can be downloaded from remote."))
|
||||
|
||||
o = s:option(DummyValue, "_image_import")
|
||||
o.template = "dockerman/images_import"
|
||||
|
||||
s = m:section(Table, image_list, translate("Images overview"))
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write = function(self, section, value)
|
||||
image_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_tags", translate("RepoTags"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_containers", translate("Containers"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_size", translate("Size"))
|
||||
|
||||
o = s:option(DummyValue, "_created", translate("Created"))
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
o.rawhtml = true
|
||||
|
||||
local remove_action = function(force)
|
||||
local image_selected = {}
|
||||
|
||||
for k in pairs(image_list) do
|
||||
if image_list[k]._selected == 1 then
|
||||
image_selected[#image_selected+1] = (image_list[k]["_tags"]:match("<br />") or image_list[k]["_tags"]:match("<none>")) and image_list[k].id or image_list[k].tag
|
||||
end
|
||||
end
|
||||
|
||||
if next(image_selected) ~= nil then
|
||||
local success = true
|
||||
|
||||
docker:clear_status()
|
||||
for _, img in ipairs(image_selected) do
|
||||
local query
|
||||
docker:append_status("Images: " .. "remove" .. " " .. img .. "...")
|
||||
|
||||
if force then
|
||||
query = {force = true}
|
||||
end
|
||||
|
||||
local msg = dk.images:remove({
|
||||
id = img,
|
||||
query = query
|
||||
})
|
||||
if msg.code ~= 200 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
|
||||
end
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err = docker:read_status()
|
||||
s.err = s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
remove_action()
|
||||
end
|
||||
|
||||
o = s:option(Button, "forceremove")
|
||||
o.inputtitle= translate("Force Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
remove_action(true)
|
||||
end
|
||||
|
||||
o = s:option(Button, "save")
|
||||
o.inputtitle= translate("Save")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "edit"
|
||||
o.forcewrite = true
|
||||
o.write = function (self, section)
|
||||
local image_selected = {}
|
||||
|
||||
for k in pairs(image_list) do
|
||||
if image_list[k]._selected == 1 then
|
||||
image_selected[#image_selected + 1] = image_list[k].id
|
||||
end
|
||||
end
|
||||
|
||||
if next(image_selected) ~= nil then
|
||||
local names, first
|
||||
|
||||
for _, img in ipairs(image_selected) do
|
||||
names = names and (names .. "&names=".. img) or img
|
||||
end
|
||||
|
||||
local cb = function(res, chunk)
|
||||
if res.code == 200 then
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.header('Content-Disposition', 'inline; filename="images.tar"')
|
||||
luci.http.header('Content-Type', 'application\/x-tar')
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
else
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.prepare_content("text/plain")
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
end
|
||||
end
|
||||
|
||||
docker:write_status("Images: " .. "save" .. " " .. table.concat(image_selected, "\n") .. "...")
|
||||
local msg = dk.images:get({query = {names = names}}, cb)
|
||||
|
||||
if msg.code ~= 200 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:clear_status()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Button, "load")
|
||||
o.inputtitle= translate("Load")
|
||||
o.template = "dockerman/images_load"
|
||||
o.inputstyle = "add"
|
||||
|
||||
return m
|
||||
@@ -1,154 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
local networks, dk, res
|
||||
|
||||
dk = docker.new()
|
||||
res = dk.networks:list()
|
||||
if res.code < 300 then
|
||||
networks = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local get_networks = function ()
|
||||
local data = {}
|
||||
|
||||
if type(networks) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i, v in ipairs(networks) do
|
||||
local index = v.Created .. v.Id
|
||||
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["_id"] = v.Id:sub(1,12)
|
||||
data[index]["_name"] = v.Name
|
||||
data[index]["_driver"] = v.Driver
|
||||
|
||||
if v.Driver == "bridge" then
|
||||
data[index]["_interface"] = v.Options["com.docker.network.bridge.name"]
|
||||
elseif v.Driver == "macvlan" then
|
||||
data[index]["_interface"] = v.Options.parent
|
||||
end
|
||||
|
||||
data[index]["_subnet"] = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
|
||||
data[index]["_gateway"] = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Gateway or nil
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local network_list = get_networks()
|
||||
|
||||
m = SimpleForm("docker",
|
||||
translate("Docker - Networks"),
|
||||
translate("This page displays all docker networks that have been created on the connected docker host."))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
s = m:section(Table, network_list, translate("Networks overview"))
|
||||
s.nodescr=true
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.template = "dockerman/cbi/xfvalue"
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.render = function(self, section, scope)
|
||||
self.disable = 0
|
||||
if network_list[section]["_name"] == "bridge" or network_list[section]["_name"] == "none" or network_list[section]["_name"] == "host" then
|
||||
self.disable = 1
|
||||
end
|
||||
Flag.render(self, section, scope)
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
network_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Network Name"))
|
||||
|
||||
o = s:option(DummyValue, "_driver", translate("Driver"))
|
||||
|
||||
o = s:option(DummyValue, "_interface", translate("Parent Interface"))
|
||||
|
||||
o = s:option(DummyValue, "_subnet", translate("Subnet"))
|
||||
|
||||
o = s:option(DummyValue, "_gateway", translate("Gateway"))
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err = docker:read_status()
|
||||
s.err = s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "_new")
|
||||
o.inputtitle= translate("New")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.notitle=true
|
||||
o.inputstyle = "add"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
|
||||
end
|
||||
|
||||
o = s:option(Button, "_remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
local network_selected = {}
|
||||
local network_name_selected = {}
|
||||
local network_driver_selected = {}
|
||||
|
||||
for k in pairs(network_list) do
|
||||
if network_list[k]._selected == 1 then
|
||||
network_selected[#network_selected + 1] = network_list[k]._id
|
||||
network_name_selected[#network_name_selected + 1] = network_list[k]._name
|
||||
network_driver_selected[#network_driver_selected + 1] = network_list[k]._driver
|
||||
end
|
||||
end
|
||||
|
||||
if next(network_selected) ~= nil then
|
||||
local success = true
|
||||
docker:clear_status()
|
||||
|
||||
for ii, net in ipairs(network_selected) do
|
||||
docker:append_status("Networks: " .. "remove" .. " " .. net .. "...")
|
||||
local res = dk.networks["remove"](dk, {id = net})
|
||||
|
||||
if res and res.code >= 300 then
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
if network_driver_selected[ii] == "macvlan" then
|
||||
docker.remove_macvlan_interface(network_name_selected[ii])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,921 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
|
||||
local dk = docker.new()
|
||||
|
||||
local cmd_line = table.concat(arg, '/')
|
||||
local create_body = {}
|
||||
|
||||
local images = dk.images:list().body
|
||||
local networks = dk.networks:list().body
|
||||
local containers = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
}).body
|
||||
|
||||
local is_quot_complete = function(str)
|
||||
local num = 0, w
|
||||
require "math"
|
||||
|
||||
if not str then
|
||||
return true
|
||||
end
|
||||
|
||||
local num = 0, w
|
||||
for w in str:gmatch("\"") do
|
||||
num = num + 1
|
||||
end
|
||||
|
||||
if math.fmod(num, 2) ~= 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
num = 0
|
||||
for w in str:gmatch("\'") do
|
||||
num = num + 1
|
||||
end
|
||||
|
||||
if math.fmod(num, 2) ~= 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function contains(list, x)
|
||||
for _, v in pairs(list) do
|
||||
if v == x then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local resolve_cli = function(cmd_line)
|
||||
local config = {
|
||||
advance = 1
|
||||
}
|
||||
|
||||
local key_no_val = {
|
||||
't',
|
||||
'd',
|
||||
'i',
|
||||
'tty',
|
||||
'rm',
|
||||
'read_only',
|
||||
'interactive',
|
||||
'init',
|
||||
'help',
|
||||
'detach',
|
||||
'privileged',
|
||||
'P',
|
||||
'publish_all',
|
||||
}
|
||||
|
||||
local key_with_val = {
|
||||
'sysctl',
|
||||
'add_host',
|
||||
'a',
|
||||
'attach',
|
||||
'blkio_weight_device',
|
||||
'cap_add',
|
||||
'cap_drop',
|
||||
'device',
|
||||
'device_cgroup_rule',
|
||||
'device_read_bps',
|
||||
'device_read_iops',
|
||||
'device_write_bps',
|
||||
'device_write_iops',
|
||||
'dns',
|
||||
'dns_option',
|
||||
'dns_search',
|
||||
'e',
|
||||
'env',
|
||||
'env_file',
|
||||
'expose',
|
||||
'group_add',
|
||||
'l',
|
||||
'label',
|
||||
'label_file',
|
||||
'link',
|
||||
'link_local_ip',
|
||||
'log_driver',
|
||||
'log_opt',
|
||||
'network_alias',
|
||||
'p',
|
||||
'publish',
|
||||
'security_opt',
|
||||
'storage_opt',
|
||||
'tmpfs',
|
||||
'v',
|
||||
'volume',
|
||||
'volumes_from',
|
||||
'blkio_weight',
|
||||
'cgroup_parent',
|
||||
'cidfile',
|
||||
'cpu_period',
|
||||
'cpu_quota',
|
||||
'cpu_rt_period',
|
||||
'cpu_rt_runtime',
|
||||
'c',
|
||||
'cpu_shares',
|
||||
'cpus',
|
||||
'cpuset_cpus',
|
||||
'cpuset_mems',
|
||||
'detach_keys',
|
||||
'disable_content_trust',
|
||||
'domainname',
|
||||
'entrypoint',
|
||||
'gpus',
|
||||
'health_cmd',
|
||||
'health_interval',
|
||||
'health_retries',
|
||||
'health_start_period',
|
||||
'health_timeout',
|
||||
'h',
|
||||
'hostname',
|
||||
'ip',
|
||||
'ip6',
|
||||
'ipc',
|
||||
'isolation',
|
||||
'kernel_memory',
|
||||
'log_driver',
|
||||
'mac_address',
|
||||
'm',
|
||||
'memory',
|
||||
'memory_reservation',
|
||||
'memory_swap',
|
||||
'memory_swappiness',
|
||||
'mount',
|
||||
'name',
|
||||
'network',
|
||||
'no_healthcheck',
|
||||
'oom_kill_disable',
|
||||
'oom_score_adj',
|
||||
'pid',
|
||||
'pids_limit',
|
||||
'restart',
|
||||
'runtime',
|
||||
'shm_size',
|
||||
'sig_proxy',
|
||||
'stop_signal',
|
||||
'stop_timeout',
|
||||
'ulimit',
|
||||
'u',
|
||||
'user',
|
||||
'userns',
|
||||
'uts',
|
||||
'volume_driver',
|
||||
'w',
|
||||
'workdir'
|
||||
}
|
||||
|
||||
local key_abb = {
|
||||
net='network',
|
||||
a='attach',
|
||||
c='cpu-shares',
|
||||
d='detach',
|
||||
e='env',
|
||||
h='hostname',
|
||||
i='interactive',
|
||||
l='label',
|
||||
m='memory',
|
||||
p='publish',
|
||||
P='publish_all',
|
||||
t='tty',
|
||||
u='user',
|
||||
v='volume',
|
||||
w='workdir'
|
||||
}
|
||||
|
||||
local key_with_list = {
|
||||
'sysctl',
|
||||
'add_host',
|
||||
'a',
|
||||
'attach',
|
||||
'blkio_weight_device',
|
||||
'cap_add',
|
||||
'cap_drop',
|
||||
'device',
|
||||
'device_cgroup_rule',
|
||||
'device_read_bps',
|
||||
'device_read_iops',
|
||||
'device_write_bps',
|
||||
'device_write_iops',
|
||||
'dns',
|
||||
'dns_optiondns_search',
|
||||
'e',
|
||||
'env',
|
||||
'env_file',
|
||||
'expose',
|
||||
'group_add',
|
||||
'l',
|
||||
'label',
|
||||
'label_file',
|
||||
'link',
|
||||
'link_local_ip',
|
||||
'log_driver',
|
||||
'log_opt',
|
||||
'network_alias',
|
||||
'p',
|
||||
'publish',
|
||||
'security_opt',
|
||||
'storage_opt',
|
||||
'tmpfs',
|
||||
'v',
|
||||
'volume',
|
||||
'volumes_from',
|
||||
}
|
||||
|
||||
local key = nil
|
||||
local _key = nil
|
||||
local val = nil
|
||||
local is_cmd = false
|
||||
|
||||
cmd_line = cmd_line:match("^DOCKERCLI%s+(.+)")
|
||||
for w in cmd_line:gmatch("[^%s]+") do
|
||||
if w =='\\' then
|
||||
elseif not key and not _key and not is_cmd then
|
||||
--key=val
|
||||
key, val = w:match("^%-%-([%lP%-]-)=(.+)")
|
||||
if not key then
|
||||
--key val
|
||||
key = w:match("^%-%-([%lP%-]+)")
|
||||
if not key then
|
||||
-- -v val
|
||||
key = w:match("^%-([%lP%-]+)")
|
||||
if key then
|
||||
-- for -dit
|
||||
if key:match("i") or key:match("t") or key:match("d") then
|
||||
if key:match("i") then
|
||||
config[key_abb["i"]] = true
|
||||
key:gsub("i", "")
|
||||
end
|
||||
if key:match("t") then
|
||||
config[key_abb["t"]] = true
|
||||
key:gsub("t", "")
|
||||
end
|
||||
if key:match("d") then
|
||||
config[key_abb["d"]] = true
|
||||
key:gsub("d", "")
|
||||
end
|
||||
if key:match("P") then
|
||||
config[key_abb["P"]] = true
|
||||
key:gsub("P", "")
|
||||
end
|
||||
if key == "" then
|
||||
key = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if key then
|
||||
key = key:gsub("-","_")
|
||||
key = key_abb[key] or key
|
||||
if contains(key_no_val, key) then
|
||||
config[key] = true
|
||||
val = nil
|
||||
key = nil
|
||||
elseif contains(key_with_val, key) then
|
||||
-- if key == "cap_add" then config.privileged = true end
|
||||
else
|
||||
key = nil
|
||||
val = nil
|
||||
end
|
||||
else
|
||||
config.image = w
|
||||
key = nil
|
||||
val = nil
|
||||
is_cmd = true
|
||||
end
|
||||
elseif (key or _key) and not is_cmd then
|
||||
if key == "mount" then
|
||||
-- we need resolve mount options here
|
||||
-- type=bind,source=/source,target=/app
|
||||
local _type = w:match("^type=([^,]+),") or "bind"
|
||||
local source = (_type ~= "tmpfs") and (w:match("source=([^,]+),") or w:match("src=([^,]+),")) or ""
|
||||
local target = w:match(",target=([^,]+)") or w:match(",dst=([^,]+)") or w:match(",destination=([^,]+)") or ""
|
||||
local ro = w:match(",readonly") and "ro" or nil
|
||||
|
||||
if source and target then
|
||||
if _type ~= "tmpfs" then
|
||||
local bind_propagation = (_type == "bind") and w:match(",bind%-propagation=([^,]+)") or nil
|
||||
val = source..":"..target .. ((ro or bind_propagation) and (":" .. (ro and ro or "") .. (((ro and bind_propagation) and "," or "") .. (bind_propagation and bind_propagation or ""))or ""))
|
||||
else
|
||||
local tmpfs_mode = w:match(",tmpfs%-mode=([^,]+)") or nil
|
||||
local tmpfs_size = w:match(",tmpfs%-size=([^,]+)") or nil
|
||||
key = "tmpfs"
|
||||
val = target .. ((tmpfs_mode or tmpfs_size) and (":" .. (tmpfs_mode and ("mode=" .. tmpfs_mode) or "") .. ((tmpfs_mode and tmpfs_size) and "," or "") .. (tmpfs_size and ("size=".. tmpfs_size) or "")) or "")
|
||||
if not config[key] then
|
||||
config[key] = {}
|
||||
end
|
||||
table.insert( config[key], val )
|
||||
key = nil
|
||||
val = nil
|
||||
end
|
||||
end
|
||||
else
|
||||
val = w
|
||||
end
|
||||
elseif is_cmd then
|
||||
config["command"] = (config["command"] and (config["command"] .. " " )or "") .. w
|
||||
end
|
||||
if (key or _key) and val then
|
||||
key = _key or key
|
||||
if contains(key_with_list, key) then
|
||||
if not config[key] then
|
||||
config[key] = {}
|
||||
end
|
||||
if _key then
|
||||
config[key][#config[key]] = config[key][#config[key]] .. " " .. w
|
||||
else
|
||||
table.insert( config[key], val )
|
||||
end
|
||||
if is_quot_complete(config[key][#config[key]]) then
|
||||
config[key][#config[key]] = config[key][#config[key]]:gsub("[\"\']", "")
|
||||
_key = nil
|
||||
else
|
||||
_key = key
|
||||
end
|
||||
else
|
||||
config[key] = (config[key] and (config[key] .. " ") or "") .. val
|
||||
if is_quot_complete(config[key]) then
|
||||
config[key] = config[key]:gsub("[\"\']", "")
|
||||
_key = nil
|
||||
else
|
||||
_key = key
|
||||
end
|
||||
end
|
||||
key = nil
|
||||
val = nil
|
||||
end
|
||||
end
|
||||
|
||||
return config
|
||||
end
|
||||
|
||||
local default_config = {}
|
||||
|
||||
if cmd_line and cmd_line:match("^DOCKERCLI.+") then
|
||||
default_config = resolve_cli(cmd_line)
|
||||
elseif cmd_line and cmd_line:match("^duplicate/[^/]+$") then
|
||||
local container_id = cmd_line:match("^duplicate/(.+)")
|
||||
create_body = dk:containers_duplicate_config({id = container_id}) or {}
|
||||
|
||||
if not create_body.HostConfig then
|
||||
create_body.HostConfig = {}
|
||||
end
|
||||
|
||||
if next(create_body) ~= nil then
|
||||
default_config.name = nil
|
||||
default_config.image = create_body.Image
|
||||
default_config.hostname = create_body.Hostname
|
||||
default_config.tty = create_body.Tty and true or false
|
||||
default_config.interactive = create_body.OpenStdin and true or false
|
||||
default_config.privileged = create_body.HostConfig.Privileged and true or false
|
||||
default_config.restart = create_body.HostConfig.RestartPolicy and create_body.HostConfig.RestartPolicy.name or nil
|
||||
default_config.network = create_body.NetworkingConfig and create_body.NetworkingConfig.EndpointsConfig and next(create_body.NetworkingConfig.EndpointsConfig) or nil
|
||||
default_config.ip = default_config.network and default_config.network ~= "bridge" and default_config.network ~= "host" and default_config.network ~= "null" and create_body.NetworkingConfig.EndpointsConfig[default_config.network].IPAMConfig and create_body.NetworkingConfig.EndpointsConfig[default_config.network].IPAMConfig.IPv4Address or nil
|
||||
default_config.link = create_body.HostConfig.Links
|
||||
default_config.env = create_body.Env
|
||||
default_config.dns = create_body.HostConfig.Dns
|
||||
default_config.volume = create_body.HostConfig.Binds
|
||||
default_config.cap_add = create_body.HostConfig.CapAdd
|
||||
default_config.publish_all = create_body.HostConfig.PublishAllPorts
|
||||
|
||||
if create_body.HostConfig.Sysctls and type(create_body.HostConfig.Sysctls) == "table" then
|
||||
default_config.sysctl = {}
|
||||
for k, v in pairs(create_body.HostConfig.Sysctls) do
|
||||
table.insert( default_config.sysctl, k.."="..v )
|
||||
end
|
||||
end
|
||||
|
||||
if create_body.HostConfig.LogConfig and create_body.HostConfig.LogConfig.Config and type(create_body.HostConfig.LogConfig.Config) == "table" then
|
||||
default_config.log_opt = {}
|
||||
for k, v in pairs(create_body.HostConfig.LogConfig.Config) do
|
||||
table.insert( default_config.log_opt, k.."="..v )
|
||||
end
|
||||
end
|
||||
|
||||
if create_body.HostConfig.PortBindings and type(create_body.HostConfig.PortBindings) == "table" then
|
||||
default_config.publish = {}
|
||||
for k, v in pairs(create_body.HostConfig.PortBindings) do
|
||||
local host_ip = ""
|
||||
if v[1].HostIp and v[1].HostIp ~= "" then
|
||||
host_ip = v[1].HostIp .. ":"
|
||||
end
|
||||
local host_port = v[1].HostPort
|
||||
local container_port = k:match("^(%d+)/.+")
|
||||
local protocol = k:match("^%d+/(.+)")
|
||||
table.insert(default_config.publish, host_ip .. host_port .. ":" .. container_port .. "/" .. protocol)
|
||||
end
|
||||
end
|
||||
|
||||
default_config.user = create_body.User or nil
|
||||
default_config.command = create_body.Cmd and type(create_body.Cmd) == "table" and table.concat(create_body.Cmd, " ") or nil
|
||||
default_config.advance = 1
|
||||
default_config.cpus = create_body.HostConfig.NanoCPUs
|
||||
default_config.cpu_shares = create_body.HostConfig.CpuShares
|
||||
default_config.memory = create_body.HostConfig.Memory
|
||||
default_config.blkio_weight = create_body.HostConfig.BlkioWeight
|
||||
|
||||
if create_body.HostConfig.Devices and type(create_body.HostConfig.Devices) == "table" then
|
||||
default_config.device = {}
|
||||
for _, v in ipairs(create_body.HostConfig.Devices) do
|
||||
table.insert( default_config.device, v.PathOnHost..":"..v.PathInContainer..(v.CgroupPermissions ~= "" and (":" .. v.CgroupPermissions) or "") )
|
||||
end
|
||||
end
|
||||
|
||||
if create_body.HostConfig.Tmpfs and type(create_body.HostConfig.Tmpfs) == "table" then
|
||||
default_config.tmpfs = {}
|
||||
for k, v in pairs(create_body.HostConfig.Tmpfs) do
|
||||
table.insert( default_config.tmpfs, k .. (v~="" and ":" or "")..v )
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
m = SimpleForm("docker", translate("Docker - Containers"))
|
||||
m.redirect = luci.dispatcher.build_url("admin", "docker", "containers")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection, translate("Create new docker container"))
|
||||
s.addremove = true
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(DummyValue,"cmd_line", translate("Resolve CLI"))
|
||||
o.rawhtml = true
|
||||
o.template = "dockerman/newcontainer_resolve"
|
||||
|
||||
o = s:option(Value, "name", translate("Container Name"))
|
||||
o.rmempty = true
|
||||
o.default = default_config.name or nil
|
||||
|
||||
o = s:option(Flag, "interactive", translate("Interactive (-i)"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = default_config.interactive and 1 or 0
|
||||
|
||||
o = s:option(Flag, "tty", translate("TTY (-t)"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = default_config.tty and 1 or 0
|
||||
|
||||
o = s:option(Value, "image", translate("Docker Image"))
|
||||
o.rmempty = true
|
||||
o.default = default_config.image or nil
|
||||
for _, v in ipairs (images) do
|
||||
if v.RepoTags then
|
||||
o:value(v.RepoTags[1], v.RepoTags[1])
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Flag, "_force_pull", translate("Always pull image first"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Flag, "privileged", translate("Privileged"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = default_config.privileged and 1 or 0
|
||||
|
||||
o = s:option(ListValue, "restart", translate("Restart Policy"))
|
||||
o.rmempty = true
|
||||
o:value("no", "No")
|
||||
o:value("unless-stopped", "Unless stopped")
|
||||
o:value("always", "Always")
|
||||
o:value("on-failure", "On failure")
|
||||
o.default = default_config.restart or "unless-stopped"
|
||||
|
||||
local d_network = s:option(ListValue, "network", translate("Networks"))
|
||||
d_network.rmempty = true
|
||||
d_network.default = default_config.network or "bridge"
|
||||
|
||||
local d_ip = s:option(Value, "ip", translate("IPv4 Address"))
|
||||
d_ip.datatype="ip4addr"
|
||||
d_ip:depends("network", "nil")
|
||||
d_ip.default = default_config.ip or nil
|
||||
|
||||
o = s:option(DynamicList, "link", translate("Links with other containers"))
|
||||
o.placeholder = "container_name:alias"
|
||||
o.rmempty = true
|
||||
o:depends("network", "bridge")
|
||||
o.default = default_config.link or nil
|
||||
|
||||
o = s:option(DynamicList, "dns", translate("Set custom DNS servers"))
|
||||
o.placeholder = "8.8.8.8"
|
||||
o.rmempty = true
|
||||
o.default = default_config.dns or nil
|
||||
|
||||
o = s:option(Value, "user",
|
||||
translate("User(-u)"),
|
||||
translate("The user that commands are run as inside the container.(format: name|uid[:group|gid])"))
|
||||
o.placeholder = "1000:1000"
|
||||
o.rmempty = true
|
||||
o.default = default_config.user or nil
|
||||
|
||||
o = s:option(DynamicList, "env",
|
||||
translate("Environmental Variable(-e)"),
|
||||
translate("Set environment variables to inside the container"))
|
||||
o.placeholder = "TZ=Asia/Shanghai"
|
||||
o.rmempty = true
|
||||
o.default = default_config.env or nil
|
||||
|
||||
o = s:option(DynamicList, "volume",
|
||||
translate("Bind Mount(-v)"),
|
||||
translate("Bind mount a volume"))
|
||||
o.placeholder = "/media:/media:slave"
|
||||
o.rmempty = true
|
||||
o.default = default_config.volume or nil
|
||||
|
||||
local d_publish = s:option(DynamicList, "publish",
|
||||
translate("Exposed Ports(-p)"),
|
||||
translate("Publish container's port(s) to the host"))
|
||||
d_publish.placeholder = "[192.168.1.0:]2200:22/tcp"
|
||||
d_publish.rmempty = true
|
||||
d_publish.default = default_config.publish or nil
|
||||
|
||||
o = s:option(Value, "command", translate("Run command"))
|
||||
o.placeholder = "/bin/sh init.sh"
|
||||
o.rmempty = true
|
||||
o.default = default_config.command or nil
|
||||
|
||||
o = s:option(Flag, "advance", translate("Advance"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = default_config.advance or 0
|
||||
|
||||
o = s:option(Value, "hostname",
|
||||
translate("Host Name"),
|
||||
translate("The hostname to use for the container"))
|
||||
o.rmempty = true
|
||||
o.default = default_config.hostname or nil
|
||||
o:depends("advance", 1)
|
||||
|
||||
o = s:option(Flag, "publish_all",
|
||||
translate("Exposed All Ports(-P)"),
|
||||
translate("Allocates an ephemeral host port for all of a container's exposed ports"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = default_config.publish_all and 1 or 0
|
||||
o:depends("advance", 1)
|
||||
|
||||
o = s:option(DynamicList, "device",
|
||||
translate("Device(--device)"),
|
||||
translate("Add host device to the container"))
|
||||
o.placeholder = "/dev/sda:/dev/xvdc:rwm"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.device or nil
|
||||
|
||||
o = s:option(DynamicList, "tmpfs",
|
||||
translate("Tmpfs(--tmpfs)"),
|
||||
translate("Mount tmpfs directory"))
|
||||
o.placeholder = "/run:rw,noexec,nosuid,size=65536k"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.tmpfs or nil
|
||||
|
||||
o = s:option(DynamicList, "sysctl",
|
||||
translate("Sysctl(--sysctl)"),
|
||||
translate("Sysctls (kernel parameters) options"))
|
||||
o.placeholder = "net.ipv4.ip_forward=1"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.sysctl or nil
|
||||
|
||||
o = s:option(DynamicList, "cap_add",
|
||||
translate("CAP-ADD(--cap-add)"),
|
||||
translate("A list of kernel capabilities to add to the container"))
|
||||
o.placeholder = "NET_ADMIN"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.cap_add or nil
|
||||
|
||||
o = s:option(Value, "cpus",
|
||||
translate("CPUs"),
|
||||
translate("Number of CPUs. Number is a fractional number. 0.000 means no limit"))
|
||||
o.placeholder = "1.5"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.datatype="ufloat"
|
||||
o.default = default_config.cpus or nil
|
||||
|
||||
o = s:option(Value, "cpu_shares",
|
||||
translate("CPU Shares Weight"),
|
||||
translate("CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024"))
|
||||
o.placeholder = "1024"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.datatype="uinteger"
|
||||
o.default = default_config.cpu_shares or nil
|
||||
|
||||
o = s:option(Value, "memory",
|
||||
translate("Memory"),
|
||||
translate("Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M"))
|
||||
o.placeholder = "128m"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.memory or nil
|
||||
|
||||
o = s:option(Value, "blkio_weight",
|
||||
translate("Block IO Weight"),
|
||||
translate("Block IO weight (relative weight) accepts a weight value between 10 and 1000"))
|
||||
o.placeholder = "500"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.datatype="uinteger"
|
||||
o.default = default_config.blkio_weight or nil
|
||||
|
||||
o = s:option(DynamicList, "log_opt",
|
||||
translate("Log driver options"),
|
||||
translate("The logging configuration for this container"))
|
||||
o.placeholder = "max-size=1m"
|
||||
o.rmempty = true
|
||||
o:depends("advance", 1)
|
||||
o.default = default_config.log_opt or nil
|
||||
|
||||
for _, v in ipairs (networks) do
|
||||
if v.Name then
|
||||
local parent = v.Options and v.Options.parent or nil
|
||||
local ip = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
|
||||
ipv6 = v.IPAM and v.IPAM.Config and v.IPAM.Config[2] and v.IPAM.Config[2].Subnet or nil
|
||||
local network_name = v.Name .. " | " .. v.Driver .. (parent and (" | " .. parent) or "") .. (ip and (" | " .. ip) or "").. (ipv6 and (" | " .. ipv6) or "")
|
||||
d_network:value(v.Name, network_name)
|
||||
|
||||
if v.Name ~= "none" and v.Name ~= "bridge" and v.Name ~= "host" then
|
||||
d_ip:depends("network", v.Name)
|
||||
end
|
||||
|
||||
if v.Driver == "bridge" then
|
||||
d_publish:depends("network", v.Name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
m.handle = function(self, state, data)
|
||||
if state ~= FORM_VALID then
|
||||
return
|
||||
end
|
||||
|
||||
local tmp
|
||||
local name = data.name or ("luci_" .. os.date("%Y%m%d%H%M%S"))
|
||||
local hostname = data.hostname
|
||||
local tty = type(data.tty) == "number" and (data.tty == 1 and true or false) or default_config.tty or false
|
||||
local publish_all = type(data.publish_all) == "number" and (data.publish_all == 1 and true or false) or default_config.publish_all or false
|
||||
local interactive = type(data.interactive) == "number" and (data.interactive == 1 and true or false) or default_config.interactive or false
|
||||
local image = data.image
|
||||
local user = data.user
|
||||
|
||||
if image and not image:match(".-:.+") then
|
||||
image = image .. ":latest"
|
||||
end
|
||||
|
||||
local privileged = type(data.privileged) == "number" and (data.privileged == 1 and true or false) or default_config.privileged or false
|
||||
local restart = data.restart
|
||||
local env = data.env
|
||||
local dns = data.dns
|
||||
local cap_add = data.cap_add
|
||||
local sysctl = {}
|
||||
|
||||
tmp = data.sysctl
|
||||
if type(tmp) == "table" then
|
||||
for i, v in ipairs(tmp) do
|
||||
local k,v1 = v:match("(.-)=(.+)")
|
||||
if k and v1 then
|
||||
sysctl[k]=v1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local log_opt = {}
|
||||
tmp = data.log_opt
|
||||
if type(tmp) == "table" then
|
||||
for i, v in ipairs(tmp) do
|
||||
local k,v1 = v:match("(.-)=(.+)")
|
||||
if k and v1 then
|
||||
log_opt[k]=v1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local network = data.network
|
||||
local ip = (network ~= "bridge" and network ~= "host" and network ~= "none") and data.ip or nil
|
||||
local volume = data.volume
|
||||
local memory = data.memory or 0
|
||||
local cpu_shares = data.cpu_shares or 0
|
||||
local cpus = data.cpus or 0
|
||||
local blkio_weight = data.blkio_weight or nil
|
||||
|
||||
local portbindings = {}
|
||||
local exposedports = {}
|
||||
|
||||
local tmpfs = {}
|
||||
tmp = data.tmpfs
|
||||
if type(tmp) == "table" then
|
||||
for i, v in ipairs(tmp)do
|
||||
local k= v:match("([^:]+)")
|
||||
local v1 = v:match(".-:([^:]+)") or ""
|
||||
if k then
|
||||
tmpfs[k]=v1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local device = {}
|
||||
tmp = data.device
|
||||
if type(tmp) == "table" then
|
||||
for i, v in ipairs(tmp) do
|
||||
local t = {}
|
||||
local _,_, h, c, p = v:find("(.-):(.-):(.+)")
|
||||
if h and c then
|
||||
t['PathOnHost'] = h
|
||||
t['PathInContainer'] = c
|
||||
t['CgroupPermissions'] = p or "rwm"
|
||||
else
|
||||
local _,_, h, c = v:find("(.-):(.+)")
|
||||
if h and c then
|
||||
t['PathOnHost'] = h
|
||||
t['PathInContainer'] = c
|
||||
t['CgroupPermissions'] = "rwm"
|
||||
else
|
||||
t['PathOnHost'] = v
|
||||
t['PathInContainer'] = v
|
||||
t['CgroupPermissions'] = "rwm"
|
||||
end
|
||||
end
|
||||
|
||||
if next(t) ~= nil then
|
||||
table.insert( device, t )
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
tmp = data.publish or {}
|
||||
for i, v in ipairs(tmp) do
|
||||
local ip, host_port, container_port = v:match("^([^:]+):(%d+):([^%s]+)$")
|
||||
if not container_port then
|
||||
host_port, container_port = v:match("^(%d+):([^%s]+)$")
|
||||
end
|
||||
|
||||
if container_port then
|
||||
if not container_port:match("^%d+/(%w+)$") then
|
||||
container_port = container_port .. "/tcp"
|
||||
end
|
||||
local binding = { HostPort = host_port }
|
||||
if ip then
|
||||
binding["HostIp"] = ip
|
||||
end
|
||||
|
||||
portbindings[container_port] = { binding }
|
||||
exposedports[container_port] = {HostPort = host_port}
|
||||
end
|
||||
end
|
||||
|
||||
local link = data.link
|
||||
tmp = data.command
|
||||
local command = {}
|
||||
if tmp ~= nil then
|
||||
for v in string.gmatch(tmp, "[^%s]+") do
|
||||
command[#command+1] = v
|
||||
end
|
||||
end
|
||||
|
||||
if memory ~= 0 then
|
||||
_,_,n,unit = memory:find("([%d%.]+)([%l%u]+)")
|
||||
if n then
|
||||
unit = unit and unit:sub(1,1):upper() or "B"
|
||||
if unit == "M" then
|
||||
memory = tonumber(n) * 1024 * 1024
|
||||
elseif unit == "G" then
|
||||
memory = tonumber(n) * 1024 * 1024 * 1024
|
||||
elseif unit == "K" then
|
||||
memory = tonumber(n) * 1024
|
||||
else
|
||||
memory = tonumber(n)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
create_body.Hostname = network ~= "host" and (hostname or name) or nil
|
||||
create_body.Tty = tty and true or false
|
||||
create_body.OpenStdin = interactive and true or false
|
||||
create_body.User = user
|
||||
create_body.Cmd = command
|
||||
create_body.Env = env
|
||||
create_body.Image = image
|
||||
create_body.ExposedPorts = exposedports
|
||||
create_body.HostConfig = create_body.HostConfig or {}
|
||||
create_body.HostConfig.Dns = dns
|
||||
create_body.HostConfig.Binds = volume
|
||||
create_body.HostConfig.RestartPolicy = { Name = restart, MaximumRetryCount = 0 }
|
||||
create_body.HostConfig.Privileged = privileged and true or false
|
||||
create_body.HostConfig.PortBindings = portbindings
|
||||
create_body.HostConfig.Memory = tonumber(memory)
|
||||
if create_body.HostConfig.Memory and create_body.HostConfig.Memory > 0 then
|
||||
create_body.HostConfig.MemorySwap = -1
|
||||
end
|
||||
create_body.HostConfig.CpuShares = tonumber(cpu_shares)
|
||||
create_body.HostConfig.NanoCPUs = tonumber(cpus) * 10 ^ 9
|
||||
create_body.HostConfig.BlkioWeight = tonumber(blkio_weight)
|
||||
create_body.HostConfig.PublishAllPorts = publish_all
|
||||
|
||||
if create_body.HostConfig.NetworkMode ~= network then
|
||||
create_body.NetworkingConfig = nil
|
||||
end
|
||||
|
||||
create_body.HostConfig.NetworkMode = network
|
||||
|
||||
if ip then
|
||||
if create_body.NetworkingConfig and create_body.NetworkingConfig.EndpointsConfig and type(create_body.NetworkingConfig.EndpointsConfig) == "table" then
|
||||
for k, v in pairs (create_body.NetworkingConfig.EndpointsConfig) do
|
||||
if k == network and v.IPAMConfig and v.IPAMConfig.IPv4Address then
|
||||
v.IPAMConfig.IPv4Address = ip
|
||||
else
|
||||
create_body.NetworkingConfig.EndpointsConfig = { [network] = { IPAMConfig = { IPv4Address = ip } } }
|
||||
end
|
||||
break
|
||||
end
|
||||
else
|
||||
create_body.NetworkingConfig = { EndpointsConfig = { [network] = { IPAMConfig = { IPv4Address = ip } } } }
|
||||
end
|
||||
elseif not create_body.NetworkingConfig then
|
||||
create_body.NetworkingConfig = nil
|
||||
end
|
||||
|
||||
create_body["HostConfig"]["Tmpfs"] = tmpfs
|
||||
create_body["HostConfig"]["Devices"] = device
|
||||
create_body["HostConfig"]["Sysctls"] = sysctl
|
||||
create_body["HostConfig"]["CapAdd"] = cap_add
|
||||
create_body["HostConfig"]["LogConfig"] = next(log_opt) ~= nil and { Config = log_opt } or nil
|
||||
|
||||
if network == "bridge" then
|
||||
create_body["HostConfig"]["Links"] = link
|
||||
end
|
||||
|
||||
local pull_image = function(image)
|
||||
local json_stringify = luci.jsonc and luci.jsonc.stringify
|
||||
docker:append_status("Images: " .. "pulling" .. " " .. image .. "...\n")
|
||||
local res = dk.images:create({query = {fromImage=image}}, docker.pull_image_show_status_cb)
|
||||
if res and res.code == 200 and (res.body[#res.body] and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. image or res.body[#res.body].status == "Status: Image is up to date for ".. image)) then
|
||||
docker:append_status("done\n")
|
||||
else
|
||||
res.code = (res.code == 200) and 500 or res.code
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)).. "\n")
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
|
||||
end
|
||||
end
|
||||
|
||||
docker:clear_status()
|
||||
local exist_image = false
|
||||
|
||||
if image then
|
||||
for _, v in ipairs (images) do
|
||||
if v.RepoTags and v.RepoTags[1] == image then
|
||||
exist_image = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not exist_image then
|
||||
pull_image(image)
|
||||
elseif data._force_pull == 1 then
|
||||
pull_image(image)
|
||||
end
|
||||
end
|
||||
|
||||
create_body = docker.clear_empty_tables(create_body)
|
||||
|
||||
docker:append_status("Container: " .. "create" .. " " .. name .. "...")
|
||||
local res = dk.containers:create({name = name, body = create_body})
|
||||
if res and res.code == 201 then
|
||||
docker:clear_status()
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message))
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,246 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
|
||||
local dk = docker.new()
|
||||
|
||||
m = SimpleForm("docker", translate("Docker - Network"))
|
||||
m.redirect = luci.dispatcher.build_url("admin", "docker", "networks")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection, translate("Create new docker network"))
|
||||
s.addremove = true
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Value, "name",
|
||||
translate("Network Name"),
|
||||
translate("Name of the network that can be selected during container creation"))
|
||||
o.rmempty = true
|
||||
|
||||
o = s:option(ListValue, "driver", translate("Driver"))
|
||||
o.rmempty = true
|
||||
o:value("bridge", translate("Bridge device"))
|
||||
o:value("macvlan", translate("MAC VLAN"))
|
||||
o:value("ipvlan", translate("IP VLAN"))
|
||||
o:value("overlay", translate("Overlay network"))
|
||||
|
||||
o = s:option(Value, "parent", translate("Base device"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "macvlan")
|
||||
local interfaces = luci.sys and luci.sys.net and luci.sys.net.devices() or {}
|
||||
for _, v in ipairs(interfaces) do
|
||||
o:value(v, v)
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "macvlan_mode", translate("Mode"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "macvlan")
|
||||
o.default="bridge"
|
||||
o:value("bridge", translate("Bridge (Support direct communication between MAC VLANs)"))
|
||||
o:value("private", translate("Private (Prevent communication between MAC VLANs)"))
|
||||
o:value("vepa", translate("VEPA (Virtual Ethernet Port Aggregator)"))
|
||||
o:value("passthru", translate("Pass-through (Mirror physical device to single MAC VLAN)"))
|
||||
|
||||
o = s:option(ListValue, "ipvlan_mode", translate("Ipvlan Mode"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "ipvlan")
|
||||
o.default="l3"
|
||||
o:value("l2", translate("L2 bridge"))
|
||||
o:value("l3", translate("L3 bridge"))
|
||||
|
||||
o = s:option(Flag, "ingress",
|
||||
translate("Ingress"),
|
||||
translate("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(DynamicList, "options", translate("Options"))
|
||||
o.rmempty = true
|
||||
o.placeholder="com.docker.network.driver.mtu=1500"
|
||||
|
||||
o = s:option(Flag, "internal", translate("Internal"), translate("Restrict external access to the network"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "overlay")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
|
||||
if nixio.fs.access("/etc/config/network") and nixio.fs.access("/etc/config/firewall")then
|
||||
o = s:option(Flag, "op_macvlan", translate("Create macvlan interface"), translate("Auto create macvlan interface in Openwrt"))
|
||||
o:depends("driver", "macvlan")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 1
|
||||
end
|
||||
|
||||
o = s:option(Value, "subnet", translate("Subnet"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.0.0/16"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(Value, "gateway", translate("Gateway"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.1.1"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(Value, "ip_range", translate("IP range"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.1.0/24"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(DynamicList, "aux_address", translate("Exclude IPs"))
|
||||
o.rmempty = true
|
||||
o.placeholder="my-route=10.1.1.1"
|
||||
|
||||
o = s:option(Flag, "ipv6", translate("Enable IPv6"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Value, "subnet6", translate("IPv6 Subnet"))
|
||||
o.rmempty = true
|
||||
o.placeholder="fe80::/10"
|
||||
o.datatype="ip6addr"
|
||||
o:depends("ipv6", 1)
|
||||
|
||||
o = s:option(Value, "gateway6", translate("IPv6 Gateway"))
|
||||
o.rmempty = true
|
||||
o.placeholder="fe80::1"
|
||||
o.datatype="ip6addr"
|
||||
o:depends("ipv6", 1)
|
||||
|
||||
m.handle = function(self, state, data)
|
||||
if state == FORM_VALID then
|
||||
local name = data.name
|
||||
local driver = data.driver
|
||||
|
||||
local internal = data.internal == 1 and true or false
|
||||
|
||||
local subnet = data.subnet
|
||||
local gateway = data.gateway
|
||||
local ip_range = data.ip_range
|
||||
|
||||
local aux_address = {}
|
||||
local tmp = data.aux_address or {}
|
||||
for i,v in ipairs(tmp) do
|
||||
_,_,k1,v1 = v:find("(.-)=(.+)")
|
||||
aux_address[k1] = v1
|
||||
end
|
||||
|
||||
local options = {}
|
||||
tmp = data.options or {}
|
||||
for i,v in ipairs(tmp) do
|
||||
_,_,k1,v1 = v:find("(.-)=(.+)")
|
||||
options[k1] = v1
|
||||
end
|
||||
|
||||
local ipv6 = data.ipv6 == 1 and true or false
|
||||
|
||||
local create_body = {
|
||||
Name = name,
|
||||
Driver = driver,
|
||||
EnableIPv6 = ipv6,
|
||||
IPAM = {
|
||||
Driver= "default"
|
||||
},
|
||||
Internal = internal
|
||||
}
|
||||
|
||||
if subnet or gateway or ip_range then
|
||||
create_body["IPAM"]["Config"] = {
|
||||
{
|
||||
Subnet = subnet,
|
||||
Gateway = gateway,
|
||||
IPRange = ip_range,
|
||||
AuxAddress = aux_address,
|
||||
AuxiliaryAddresses = aux_address
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
if driver == "macvlan" then
|
||||
create_body["Options"] = {
|
||||
macvlan_mode = data.macvlan_mode,
|
||||
parent = data.parent
|
||||
}
|
||||
elseif driver == "ipvlan" then
|
||||
create_body["Options"] = {
|
||||
ipvlan_mode = data.ipvlan_mode
|
||||
}
|
||||
elseif driver == "overlay" then
|
||||
create_body["Ingress"] = data.ingerss == 1 and true or false
|
||||
end
|
||||
|
||||
if ipv6 and data.subnet6 and data.subnet6 then
|
||||
if type(create_body["IPAM"]["Config"]) ~= "table" then
|
||||
create_body["IPAM"]["Config"] = {}
|
||||
end
|
||||
local index = #create_body["IPAM"]["Config"]
|
||||
create_body["IPAM"]["Config"][index+1] = {
|
||||
Subnet = data.subnet6,
|
||||
Gateway = data.gateway6
|
||||
}
|
||||
end
|
||||
|
||||
if next(options) ~= nil then
|
||||
create_body["Options"] = create_body["Options"] or {}
|
||||
for k, v in pairs(options) do
|
||||
create_body["Options"][k] = v
|
||||
end
|
||||
end
|
||||
|
||||
create_body = docker.clear_empty_tables(create_body)
|
||||
docker:write_status("Network: " .. "create" .. " " .. create_body.Name .. "...")
|
||||
|
||||
local res = dk.networks:create({
|
||||
body = create_body
|
||||
})
|
||||
|
||||
if res and res.code == 201 then
|
||||
docker:write_status("Network: " .. "create macvlan interface...")
|
||||
res = dk.networks:inspect({
|
||||
name = create_body.Name
|
||||
})
|
||||
|
||||
if driver == "macvlan" and
|
||||
data.op_macvlan ~= 0 and
|
||||
res.code == 200 and
|
||||
res.body and
|
||||
res.body.IPAM and
|
||||
res.body.IPAM.Config and
|
||||
res.body.IPAM.Config[1] and
|
||||
res.body.IPAM.Config[1].Gateway and
|
||||
res.body.IPAM.Config[1].Subnet then
|
||||
|
||||
docker.create_macvlan_interface(data.name,
|
||||
data.parent,
|
||||
res.body.IPAM.Config[1].Gateway,
|
||||
res.body.IPAM.Config[1].Subnet)
|
||||
end
|
||||
|
||||
docker:clear_status()
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,92 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
|
||||
function byte_format(byte)
|
||||
local suff = {"B", "KB", "MB", "GB", "TB"}
|
||||
for i=1, 5 do
|
||||
if byte > 1024 and i < 5 then
|
||||
byte = byte / 1024
|
||||
else
|
||||
return string.format("%.2f %s", byte, suff[i])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
m = SimpleForm("dockerd",
|
||||
translate("Docker - Overview"),
|
||||
translate("An overview with the relevant data is displayed here with which the LuCI docker client is connected."))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
local docker_info_table = {}
|
||||
docker_info_table['3ServerVersion'] = {_key=translate("Docker Version"),_value='-'}
|
||||
docker_info_table['4ApiVersion'] = {_key=translate("Api Version"),_value='-'}
|
||||
docker_info_table['5NCPU'] = {_key=translate("CPUs"),_value='-'}
|
||||
docker_info_table['6MemTotal'] = {_key=translate("Total Memory"),_value='-'}
|
||||
docker_info_table['7DockerRootDir'] = {_key=translate("Docker Root Dir"),_value='-'}
|
||||
docker_info_table['8IndexServerAddress'] = {_key=translate("Index Server Address"),_value='-'}
|
||||
docker_info_table['9RegistryMirrors'] = {_key=translate("Registry Mirrors"),_value='-'}
|
||||
|
||||
s = m:section(Table, docker_info_table)
|
||||
s:option(DummyValue, "_key", translate("Info"))
|
||||
s:option(DummyValue, "_value")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/overview"
|
||||
|
||||
s.containers_running = '-'
|
||||
s.images_used = '-'
|
||||
s.containers_total = '-'
|
||||
s.images_total = '-'
|
||||
s.networks_total = '-'
|
||||
s.volumes_total = '-'
|
||||
|
||||
if docker.new():_ping().code == 200 then
|
||||
local dk = docker.new()
|
||||
local containers_list = dk.containers:list({query = {all=true}}).body
|
||||
local images_list = dk.images:list().body
|
||||
local vol = dk.volumes:list()
|
||||
local volumes_list = vol and vol.body and vol.body.Volumes or {}
|
||||
local networks_list = dk.networks:list().body or {}
|
||||
local docker_info = dk:info()
|
||||
|
||||
docker_info_table['3ServerVersion']._value = docker_info.body.ServerVersion
|
||||
docker_info_table['4ApiVersion']._value = docker_info.headers["Api-Version"]
|
||||
docker_info_table['5NCPU']._value = tostring(docker_info.body.NCPU)
|
||||
docker_info_table['6MemTotal']._value = byte_format(docker_info.body.MemTotal)
|
||||
if docker_info.body.DockerRootDir then
|
||||
local statvfs = nixio.fs.statvfs(docker_info.body.DockerRootDir)
|
||||
local size = statvfs and (statvfs.bavail * statvfs.bsize) or 0
|
||||
docker_info_table['7DockerRootDir']._value = docker_info.body.DockerRootDir .. " (" .. tostring(byte_format(size)) .. " " .. translate("Available") .. ")"
|
||||
end
|
||||
|
||||
docker_info_table['8IndexServerAddress']._value = docker_info.body.IndexServerAddress
|
||||
for i, v in ipairs(docker_info.body.RegistryConfig.Mirrors or {}) do
|
||||
docker_info_table['9RegistryMirrors']._value = docker_info_table['9RegistryMirrors']._value == "-" and v or (docker_info_table['9RegistryMirrors']._value .. ", " .. v)
|
||||
end
|
||||
|
||||
s.images_used = 0
|
||||
for i, v in ipairs(images_list) do
|
||||
for ci,cv in ipairs(containers_list) do
|
||||
if v.Id == cv.ImageID then
|
||||
s.images_used = s.images_used + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
s.containers_running = tostring(docker_info.body.ContainersRunning)
|
||||
s.images_used = tostring(s.images_used)
|
||||
s.containers_total = tostring(docker_info.body.Containers)
|
||||
s.images_total = tostring(#images_list)
|
||||
s.networks_total = tostring(#networks_list)
|
||||
s.volumes_total = tostring(#volumes_list)
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,142 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
local dk = docker.new()
|
||||
|
||||
local m, s, o
|
||||
|
||||
local res, containers, volumes
|
||||
|
||||
function get_volumes()
|
||||
local data = {}
|
||||
for i, v in ipairs(volumes) do
|
||||
local index = v.Name
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["_nameraw"] = v.Name
|
||||
data[index]["_name"] = v.Name:sub(1,12)
|
||||
|
||||
for ci,cv in ipairs(containers) do
|
||||
if cv.Mounts and type(cv.Mounts) ~= "table" then
|
||||
break
|
||||
end
|
||||
for vi, vv in ipairs(cv.Mounts) do
|
||||
if v.Name == vv.Name then
|
||||
data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
|
||||
'<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2)..'</a>'
|
||||
end
|
||||
end
|
||||
end
|
||||
data[index]["_driver"] = v.Driver
|
||||
data[index]["_mountpoint"] = nil
|
||||
|
||||
for v1 in v.Mountpoint:gmatch('[^/]+') do
|
||||
if v1 == index then
|
||||
data[index]["_mountpoint"] = data[index]["_mountpoint"] .."/" .. v1:sub(1,12) .. "..."
|
||||
else
|
||||
data[index]["_mountpoint"] = (data[index]["_mountpoint"] and data[index]["_mountpoint"] or "").."/".. v1
|
||||
end
|
||||
end
|
||||
data[index]["_created"] = v.CreatedAt
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
res = dk.volumes:list()
|
||||
if res.code <300 then
|
||||
volumes = res.body.Volumes
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
})
|
||||
if res.code <300 then
|
||||
containers = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local volume_list = get_volumes()
|
||||
|
||||
m = SimpleForm("docker", translate("Docker - Volumes"))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
s = m:section(Table, volume_list, translate("Volumes overview"))
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write = function(self, section, value)
|
||||
volume_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Name"))
|
||||
|
||||
o = s:option(DummyValue, "_driver", translate("Driver"))
|
||||
|
||||
o = s:option(DummyValue, "_containers", translate("Containers"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_mountpoint", translate("Mount Point"))
|
||||
|
||||
o = s:option(DummyValue, "_created", translate("Created"))
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
local volume_selected = {}
|
||||
|
||||
for k in pairs(volume_list) do
|
||||
if volume_list[k]._selected == 1 then
|
||||
volume_selected[#volume_selected+1] = k
|
||||
end
|
||||
end
|
||||
|
||||
if next(volume_selected) ~= nil then
|
||||
local success = true
|
||||
docker:clear_status()
|
||||
for _,vol in ipairs(volume_selected) do
|
||||
docker:append_status("Volumes: " .. "remove" .. " " .. vol .. "...")
|
||||
local msg = dk.volumes["remove"](dk, {id = vol})
|
||||
if msg.code ~= 204 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/volumes"))
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,479 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.docker"
|
||||
local fs = require "nixio.fs"
|
||||
local uci = (require "luci.model.uci").cursor()
|
||||
|
||||
local _docker = {}
|
||||
local status_path = uci:get("dockerd", "globals", "status_path") or "/tmp/.docker_status"
|
||||
|
||||
--pull image and return iamge id
|
||||
local update_image = function(self, image_name)
|
||||
local json_stringify = luci.jsonc and luci.jsonc.stringify
|
||||
_docker:append_status("Images: " .. "pulling" .. " " .. image_name .. "...\n")
|
||||
local res = self.images:create({query = {fromImage=image_name}}, _docker.pull_image_show_status_cb)
|
||||
|
||||
if res and res.code == 200 and (#res.body > 0 and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. image_name)) then
|
||||
_docker:append_status("done\n")
|
||||
else
|
||||
res.body.message = res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)
|
||||
end
|
||||
|
||||
new_image_id = self.images:inspect({name = image_name}).body.Id
|
||||
return new_image_id, res
|
||||
end
|
||||
|
||||
local table_equal = function(t1, t2)
|
||||
if not t1 then
|
||||
return true
|
||||
end
|
||||
|
||||
if not t2 then
|
||||
return false
|
||||
end
|
||||
|
||||
if #t1 ~= #t2 then
|
||||
return false
|
||||
end
|
||||
|
||||
for i, v in ipairs(t1) do
|
||||
if t1[i] ~= t2[i] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local table_subtract = function(t1, t2)
|
||||
if not t1 or next(t1) == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
if not t2 or next(t2) == nil then
|
||||
return t1
|
||||
end
|
||||
|
||||
local res = {}
|
||||
for _, v1 in ipairs(t1) do
|
||||
local found = false
|
||||
for _, v2 in ipairs(t2) do
|
||||
if v1 == v2 then
|
||||
found= true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
table.insert(res, v1)
|
||||
end
|
||||
end
|
||||
|
||||
return next(res) == nil and nil or res
|
||||
end
|
||||
|
||||
local map_subtract = function(t1, t2)
|
||||
if not t1 or next(t1) == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
if not t2 or next(t2) == nil then
|
||||
return t1
|
||||
end
|
||||
|
||||
local res = {}
|
||||
for k1, v1 in pairs(t1) do
|
||||
local found = false
|
||||
for k2, v2 in ipairs(t2) do
|
||||
if k1 == k2 and luci.util.serialize_data(v1) == luci.util.serialize_data(v2) then
|
||||
found= true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not found then
|
||||
res[k1] = v1
|
||||
end
|
||||
end
|
||||
|
||||
return next(res) ~= nil and res or nil
|
||||
end
|
||||
|
||||
_docker.clear_empty_tables = function ( t )
|
||||
local k, v
|
||||
|
||||
if next(t) == nil then
|
||||
t = nil
|
||||
else
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == 'table' then
|
||||
t[k] = _docker.clear_empty_tables(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return t
|
||||
end
|
||||
|
||||
local get_config = function(container_config, image_config)
|
||||
local config = container_config.Config
|
||||
local old_host_config = container_config.HostConfig
|
||||
local old_network_setting = container_config.NetworkSettings.Networks or {}
|
||||
|
||||
if config.WorkingDir == image_config.WorkingDir then
|
||||
config.WorkingDir = ""
|
||||
end
|
||||
|
||||
if config.User == image_config.User then
|
||||
config.User = ""
|
||||
end
|
||||
|
||||
if table_equal(config.Cmd, image_config.Cmd) then
|
||||
config.Cmd = nil
|
||||
end
|
||||
|
||||
if table_equal(config.Entrypoint, image_config.Entrypoint) then
|
||||
config.Entrypoint = nil
|
||||
end
|
||||
|
||||
if table_equal(config.ExposedPorts, image_config.ExposedPorts) then
|
||||
config.ExposedPorts = nil
|
||||
end
|
||||
|
||||
config.Env = table_subtract(config.Env, image_config.Env)
|
||||
config.Labels = table_subtract(config.Labels, image_config.Labels)
|
||||
config.Volumes = map_subtract(config.Volumes, image_config.Volumes)
|
||||
|
||||
if old_host_config.PortBindings and next(old_host_config.PortBindings) ~= nil then
|
||||
config.ExposedPorts = {}
|
||||
for p, v in pairs(old_host_config.PortBindings) do
|
||||
config.ExposedPorts[p] = { HostPort=v[1] and v[1].HostPort }
|
||||
end
|
||||
end
|
||||
|
||||
local network_setting = {}
|
||||
local multi_network = false
|
||||
local extra_network = {}
|
||||
|
||||
for k, v in pairs(old_network_setting) do
|
||||
if multi_network then
|
||||
extra_network[k] = v
|
||||
else
|
||||
network_setting[k] = v
|
||||
end
|
||||
multi_network = true
|
||||
end
|
||||
|
||||
local host_config = old_host_config
|
||||
host_config.Mounts = {}
|
||||
for i, v in ipairs(container_config.Mounts) do
|
||||
if v.Type == "volume" then
|
||||
table.insert(host_config.Mounts, {
|
||||
Type = v.Type,
|
||||
Target = v.Destination,
|
||||
Source = v.Source:match("([^/]+)\/_data"),
|
||||
BindOptions = (v.Type == "bind") and {Propagation = v.Propagation} or nil,
|
||||
ReadOnly = not v.RW
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local create_body = config
|
||||
create_body["HostConfig"] = host_config
|
||||
create_body["NetworkingConfig"] = {EndpointsConfig = network_setting}
|
||||
create_body = _docker.clear_empty_tables(create_body) or {}
|
||||
extra_network = _docker.clear_empty_tables(extra_network) or {}
|
||||
|
||||
return create_body, extra_network
|
||||
end
|
||||
|
||||
local upgrade = function(self, request)
|
||||
_docker:clear_status()
|
||||
|
||||
local container_info = self.containers:inspect({id = request.id})
|
||||
|
||||
if container_info.code > 300 and type(container_info.body) == "table" then
|
||||
return container_info
|
||||
end
|
||||
|
||||
local image_name = container_info.body.Config.Image
|
||||
if not image_name:match(".-:.+") then
|
||||
image_name = image_name .. ":latest"
|
||||
end
|
||||
|
||||
local old_image_id = container_info.body.Image
|
||||
local container_name = container_info.body.Name:sub(2)
|
||||
|
||||
local image_id, res = update_image(self, image_name)
|
||||
if res and res.code ~= 200 then
|
||||
return res
|
||||
end
|
||||
|
||||
if image_id == old_image_id then
|
||||
return {code = 305, body = {message = "Already up to date"}}
|
||||
end
|
||||
|
||||
_docker:append_status("Container: " .. "Stop" .. " " .. container_name .. "...")
|
||||
res = self.containers:stop({name = container_name})
|
||||
if res and res.code < 305 then
|
||||
_docker:append_status("done\n")
|
||||
else
|
||||
return res
|
||||
end
|
||||
|
||||
_docker:append_status("Container: rename" .. " " .. container_name .. " to ".. container_name .. "_old ...")
|
||||
res = self.containers:rename({name = container_name, query = { name = container_name .. "_old" }})
|
||||
if res and res.code < 300 then
|
||||
_docker:append_status("done\n")
|
||||
else
|
||||
return res
|
||||
end
|
||||
|
||||
local image_config = self.images:inspect({id = old_image_id}).body.Config
|
||||
local create_body, extra_network = get_config(container_info.body, image_config)
|
||||
|
||||
-- create new container
|
||||
_docker:append_status("Container: Create" .. " " .. container_name .. "...")
|
||||
create_body = _docker.clear_empty_tables(create_body)
|
||||
res = self.containers:create({name = container_name, body = create_body})
|
||||
if res and res.code > 300 then
|
||||
return res
|
||||
end
|
||||
_docker:append_status("done\n")
|
||||
|
||||
-- extra networks need to network connect action
|
||||
for k, v in pairs(extra_network) do
|
||||
_docker:append_status("Networks: Connect" .. " " .. container_name .. "...")
|
||||
res = self.networks:connect({id = k, body = {Container = container_name, EndpointConfig = v}})
|
||||
if res.code > 300 then
|
||||
return res
|
||||
end
|
||||
_docker:append_status("done\n")
|
||||
end
|
||||
|
||||
_docker:clear_status()
|
||||
return res
|
||||
end
|
||||
|
||||
local duplicate_config = function (self, request)
|
||||
local container_info = self.containers:inspect({id = request.id})
|
||||
if container_info.code > 300 and type(container_info.body) == "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local old_image_id = container_info.body.Image
|
||||
local image_config = self.images:inspect({id = old_image_id}).body.Config
|
||||
|
||||
return get_config(container_info.body, image_config)
|
||||
end
|
||||
|
||||
_docker.new = function()
|
||||
local host = nil
|
||||
local port = nil
|
||||
local socket_path = nil
|
||||
local debug_path = nil
|
||||
|
||||
local remote = uci:get_bool("dockerd", "globals", "remote_endpoint")
|
||||
if remote then
|
||||
host = uci:get("dockerd", "globals", "remote_host") or nil
|
||||
port = uci:get("dockerd", "globals", "remote_port") or nil
|
||||
else
|
||||
socket_path = uci:get("dockerd", "globals", "socket_path") or "/var/run/docker.sock"
|
||||
end
|
||||
|
||||
local debug = uci:get_bool("dockerd", "globals", "debug")
|
||||
if debug then
|
||||
debug_path = uci:get("dockerd", "globals", "debug_path") or "/tmp/.docker_debug"
|
||||
end
|
||||
|
||||
_docker.options = {
|
||||
host = host,
|
||||
port = port,
|
||||
socket_path = socket_path,
|
||||
debug = debug,
|
||||
debug_path = debug_path
|
||||
}
|
||||
|
||||
local _new = docker.new(_docker.options)
|
||||
_new.containers_upgrade = upgrade
|
||||
_new.containers_duplicate_config = duplicate_config
|
||||
|
||||
return _new
|
||||
end
|
||||
|
||||
_docker.append_status=function(self,val)
|
||||
if not val then
|
||||
return
|
||||
end
|
||||
local file_docker_action_status=io.open(status_path, "a+")
|
||||
file_docker_action_status:write(val)
|
||||
file_docker_action_status:close()
|
||||
end
|
||||
|
||||
_docker.write_status=function(self,val)
|
||||
if not val then
|
||||
return
|
||||
end
|
||||
local file_docker_action_status=io.open(status_path, "w+")
|
||||
file_docker_action_status:write(val)
|
||||
file_docker_action_status:close()
|
||||
end
|
||||
|
||||
_docker.read_status=function(self)
|
||||
return fs.readfile(status_path)
|
||||
end
|
||||
|
||||
_docker.clear_status=function(self)
|
||||
fs.remove(status_path)
|
||||
end
|
||||
|
||||
local status_cb = function(res, source, handler)
|
||||
res.body = res.body or {}
|
||||
while true do
|
||||
local chunk = source()
|
||||
if chunk then
|
||||
--standard output to res.body
|
||||
table.insert(res.body, chunk)
|
||||
handler(chunk)
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--{"status":"Pulling from library\/debian","id":"latest"}
|
||||
--{"status":"Pulling fs layer","progressDetail":[],"id":"50e431f79093"}
|
||||
--{"status":"Downloading","progressDetail":{"total":50381971,"current":2029978},"id":"50e431f79093","progress":"[==> ] 2.03MB\/50.38MB"}
|
||||
--{"status":"Download complete","progressDetail":[],"id":"50e431f79093"}
|
||||
--{"status":"Extracting","progressDetail":{"total":50381971,"current":17301504},"id":"50e431f79093","progress":"[=================> ] 17.3MB\/50.38MB"}
|
||||
--{"status":"Pull complete","progressDetail":[],"id":"50e431f79093"}
|
||||
--{"status":"Digest: sha256:a63d0b2ecbd723da612abf0a8bdb594ee78f18f691d7dc652ac305a490c9b71a"}
|
||||
--{"status":"Status: Downloaded newer image for debian:latest"}
|
||||
_docker.pull_image_show_status_cb = function(res, source)
|
||||
return status_cb(res, source, function(chunk)
|
||||
local json_parse = luci.jsonc.parse
|
||||
local step = json_parse(chunk)
|
||||
if type(step) == "table" then
|
||||
local buf = _docker:read_status()
|
||||
local num = 0
|
||||
local str = '\t' .. (step.id and (step.id .. ": ") or "") .. (step.status and step.status or "") .. (step.progress and (" " .. step.progress) or "").."\n"
|
||||
if step.id then
|
||||
buf, num = buf:gsub("\t"..step.id .. ": .-\n", str)
|
||||
end
|
||||
if num == 0 then
|
||||
buf = buf .. str
|
||||
end
|
||||
_docker:write_status(buf)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--{"status":"Downloading from https://downloads.openwrt.org/releases/19.07.0/targets/x86/64/openwrt-19.07.0-x86-64-generic-rootfs.tar.gz"}
|
||||
--{"status":"Importing","progressDetail":{"current":1572391,"total":3821714},"progress":"[====================\u003e ] 1.572MB/3.822MB"}
|
||||
--{"status":"sha256:d5304b58e2d8cc0a2fd640c05cec1bd4d1229a604ac0dd2909f13b2b47a29285"}
|
||||
_docker.import_image_show_status_cb = function(res, source)
|
||||
return status_cb(res, source, function(chunk)
|
||||
local json_parse = luci.jsonc.parse
|
||||
local step = json_parse(chunk)
|
||||
if type(step) == "table" then
|
||||
local buf = _docker:read_status()
|
||||
local num = 0
|
||||
local str = '\t' .. (step.status and step.status or "") .. (step.progress and (" " .. step.progress) or "").."\n"
|
||||
if step.status then
|
||||
buf, num = buf:gsub("\t"..step.status .. " .-\n", str)
|
||||
end
|
||||
if num == 0 then
|
||||
buf = buf .. str
|
||||
end
|
||||
_docker:write_status(buf)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
_docker.create_macvlan_interface = function(name, device, gateway, subnet)
|
||||
if not fs.access("/etc/config/network") or not fs.access("/etc/config/firewall") then
|
||||
return
|
||||
end
|
||||
|
||||
if uci:get("dockerd", "globals", "remote_endpoint") == "true" then
|
||||
return
|
||||
end
|
||||
|
||||
local ip = require "luci.ip"
|
||||
local if_name = "docker_"..name
|
||||
local dev_name = "macvlan_"..name
|
||||
local net_mask = tostring(ip.new(subnet):mask())
|
||||
local lan_interfaces
|
||||
|
||||
-- add macvlan device
|
||||
uci:delete("network", dev_name)
|
||||
uci:set("network", dev_name, "device")
|
||||
uci:set("network", dev_name, "name", dev_name)
|
||||
uci:set("network", dev_name, "ifname", device)
|
||||
uci:set("network", dev_name, "type", "macvlan")
|
||||
uci:set("network", dev_name, "mode", "bridge")
|
||||
|
||||
-- add macvlan interface
|
||||
uci:delete("network", if_name)
|
||||
uci:set("network", if_name, "interface")
|
||||
uci:set("network", if_name, "proto", "static")
|
||||
uci:set("network", if_name, "ifname", dev_name)
|
||||
uci:set("network", if_name, "ipaddr", gateway)
|
||||
uci:set("network", if_name, "netmask", net_mask)
|
||||
uci:foreach("firewall", "zone", function(s)
|
||||
if s.name == "lan" then
|
||||
local interfaces
|
||||
if type(s.network) == "table" then
|
||||
interfaces = table.concat(s.network, " ")
|
||||
uci:delete("firewall", s[".name"], "network")
|
||||
else
|
||||
interfaces = s.network and s.network or ""
|
||||
end
|
||||
interfaces = interfaces .. " " .. if_name
|
||||
interfaces = interfaces:gsub("%s+", " ")
|
||||
uci:set("firewall", s[".name"], "network", interfaces)
|
||||
end
|
||||
end)
|
||||
|
||||
uci:commit("firewall")
|
||||
uci:commit("network")
|
||||
|
||||
os.execute("ifup " .. if_name)
|
||||
end
|
||||
|
||||
_docker.remove_macvlan_interface = function(name)
|
||||
if not fs.access("/etc/config/network") or not fs.access("/etc/config/firewall") then
|
||||
return
|
||||
end
|
||||
|
||||
if uci:get("dockerd", "globals", "remote_endpoint") == "true" then
|
||||
return
|
||||
end
|
||||
|
||||
local if_name = "docker_"..name
|
||||
local dev_name = "macvlan_"..name
|
||||
uci:foreach("firewall", "zone", function(s)
|
||||
if s.name == "lan" then
|
||||
local interfaces
|
||||
if type(s.network) == "table" then
|
||||
interfaces = table.concat(s.network, " ")
|
||||
else
|
||||
interfaces = s.network and s.network or ""
|
||||
end
|
||||
interfaces = interfaces and interfaces:gsub(if_name, "")
|
||||
interfaces = interfaces and interfaces:gsub("%s+", " ")
|
||||
uci:set("firewall", s[".name"], "network", interfaces)
|
||||
end
|
||||
end)
|
||||
|
||||
uci:delete("network", dev_name)
|
||||
uci:delete("network", if_name)
|
||||
uci:commit("network")
|
||||
uci:commit("firewall")
|
||||
|
||||
os.execute("ip link del " .. if_name)
|
||||
end
|
||||
|
||||
return _docker
|
||||
@@ -1,147 +0,0 @@
|
||||
<style>
|
||||
#docker_apply_overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: none;
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
#docker_apply_overlay .alert-message {
|
||||
position: relative;
|
||||
top: 10%;
|
||||
width: 60%;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#docker_apply_overlay .alert-message > h4,
|
||||
#docker_apply_overlay .alert-message > p,
|
||||
#docker_apply_overlay .alert-message > div {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
#docker_apply_overlay .alert-message > img {
|
||||
margin-right: 1em;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
|
||||
body.apply-overlay-active {
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body.apply-overlay-active #docker_apply_overlay {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
var xhr = new XHR(),
|
||||
uci_apply_rollback = <%=math.max(luci.config and luci.config.apply and luci.config.apply.rollback or 90, 30)%>,
|
||||
uci_apply_holdoff = <%=math.max(luci.config and luci.config.apply and luci.config.apply.holdoff or 4, 1)%>,
|
||||
uci_apply_timeout = <%=math.max(luci.config and luci.config.apply and luci.config.apply.timeout or 5, 1)%>,
|
||||
uci_apply_display = <%=math.max(luci.config and luci.config.apply and luci.config.apply.display or 1.5, 1)%>,
|
||||
was_xhr_poll_running = false;
|
||||
|
||||
function docker_status_message(type, content) {
|
||||
document.getElementById('docker_apply_overlay') || document.body.insertAdjacentHTML("beforeend",'<div id="docker_apply_overlay"><div class="alert-message"></div></div>')
|
||||
var overlay = document.getElementById('docker_apply_overlay')
|
||||
message = overlay.querySelector('.alert-message');
|
||||
|
||||
if (message && type) {
|
||||
if (!message.classList.contains(type)) {
|
||||
message.classList.remove('notice');
|
||||
message.classList.remove('warning');
|
||||
message.classList.add(type);
|
||||
}
|
||||
|
||||
if (content)
|
||||
message.innerHTML = content;
|
||||
|
||||
document.body.classList.add('apply-overlay-active');
|
||||
document.body.scrollTop = document.documentElement.scrollTop = 0;
|
||||
if (!was_xhr_poll_running) {
|
||||
was_xhr_poll_running = XHR.running();
|
||||
XHR.halt();
|
||||
}
|
||||
}
|
||||
else {
|
||||
document.body.classList.remove('apply-overlay-active');
|
||||
if (was_xhr_poll_running)
|
||||
XHR.run();
|
||||
}
|
||||
}
|
||||
|
||||
var loading_msg="Loading.."
|
||||
function uci_confirm_docker() {
|
||||
var tt;
|
||||
docker_status_message('notice');
|
||||
var call = function(r, resjson, duration) {
|
||||
if (r && r.status === 200 ) {
|
||||
var indicator = document.querySelector('.uci_change_indicator');
|
||||
if (indicator)
|
||||
indicator.style.display = 'none';
|
||||
docker_status_message('notice', '<%:Docker actions done.%>');
|
||||
document.body.classList.remove('apply-overlay-active');
|
||||
window.clearTimeout(tt);
|
||||
return;
|
||||
}
|
||||
loading_msg = resjson?resjson.info:loading_msg
|
||||
// var delay = isNaN(duration) ? 0 : Math.max(1000 - duration, 0);
|
||||
var delay =1000
|
||||
window.setTimeout(function() {
|
||||
xhr.get('<%=url("admin/docker/confirm")%>', null, call, uci_apply_timeout * 1000);
|
||||
},delay);
|
||||
};
|
||||
|
||||
var tick = function() {
|
||||
var now = Date.now();
|
||||
|
||||
docker_status_message(
|
||||
'notice',
|
||||
'<img src="<%=resource%>/icons/loading.svg" alt="" style="vertical-align:middle" /> <span style="white-space:pre-line; word-break:break-all; font-family: \'Courier New\', Courier, monospace;">' + loading_msg + '</span>'
|
||||
);
|
||||
|
||||
tt = window.setTimeout(tick, 200);
|
||||
ts = now;
|
||||
};
|
||||
|
||||
tick();
|
||||
/* wait a few seconds for the settings to become effective */
|
||||
window.setTimeout(call, Math.max(uci_apply_holdoff * 1000 , 1));
|
||||
}
|
||||
// document.getElementsByTagName("form")[0].addEventListener("submit", (e)=>{
|
||||
// uci_confirm_docker()
|
||||
// })
|
||||
|
||||
function fnSubmitForm(el){
|
||||
if (el.id != "cbid.table.1._new") {
|
||||
uci_confirm_docker()
|
||||
}
|
||||
}
|
||||
|
||||
<% if self.err then -%>
|
||||
docker_status_message('warning', '<span style="white-space:pre-line; word-break:break-all; font-family: \'Courier New\', Courier, monospace;">'+`<%=self.err%>`+'</span>');
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
<%- end %>
|
||||
|
||||
window.onload= function (){
|
||||
var buttons = document.querySelectorAll('input[type="submit"]');
|
||||
[].slice.call(buttons).forEach(function (el) {
|
||||
el.onclick = fnSubmitForm.bind(this, el);
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,7 +0,0 @@
|
||||
<div style="display: inline-block;">
|
||||
<% if self:cfgvalue(section) ~= false then %>
|
||||
<input class="btn cbi-button cbi-button-<%=self.inputstyle or "button" %>" type="submit"" <% if self.disable then %>disabled <% end %><%= attr("name", cbid) .. attr("id", cbid) .. attr("value", self.inputtitle or self.title)%> />
|
||||
<% else %>
|
||||
-
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<div style="display: inline-block;">
|
||||
<!-- <%- if self.title then -%>
|
||||
<label class="cbi-value-title"<%= attr("for", cbid) %>>
|
||||
<%- if self.titleref then -%><a title="<%=self.titledesc or translate('Go to relevant configuration page')%>" class="cbi-title-ref" href="<%=self.titleref%>"><%- end -%>
|
||||
<%-=self.title-%>
|
||||
<%- if self.titleref then -%></a><%- end -%>
|
||||
</label>
|
||||
<%- end -%> -->
|
||||
<%- if self.password then -%>
|
||||
<input type="password" style="position:absolute; left:-100000px" aria-hidden="true"<%=
|
||||
attr("name", "password." .. cbid)
|
||||
%> />
|
||||
<%- end -%>
|
||||
<input data-update="change"<%=
|
||||
attr("id", cbid) ..
|
||||
attr("name", cbid) ..
|
||||
attr("type", self.password and "password" or "text") ..
|
||||
attr("class", self.password and "cbi-input-password" or "cbi-input-text") ..
|
||||
attr("value", self:cfgvalue(section) or self.default) ..
|
||||
ifattr(self.password, "autocomplete", "new-password") ..
|
||||
ifattr(self.size, "size") ..
|
||||
ifattr(self.placeholder, "placeholder") ..
|
||||
ifattr(self.readonly, "readonly") ..
|
||||
ifattr(self.maxlength, "maxlength") ..
|
||||
ifattr(self.datatype, "data-type", self.datatype) ..
|
||||
ifattr(self.datatype, "data-optional", self.optional or self.rmempty) ..
|
||||
ifattr(self.combobox_manual, "data-manual", self.combobox_manual) ..
|
||||
ifattr(#self.keylist > 0, "data-choices", { self.keylist, self.vallist })
|
||||
%> />
|
||||
<%- if self.password then -%>
|
||||
<div class="btn cbi-button cbi-button-neutral" title="<%:Reveal/hide password%>" onclick="var e = this.previousElementSibling; e.type = (e.type === 'password') ? 'text' : 'password'">∗</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<% if self:cfgvalue(self.section) then section = self.section %>
|
||||
<div class="cbi-section" id="cbi-<%=self.config%>-<%=section%>">
|
||||
<%+cbi/tabmenu%>
|
||||
<div class="cbi-section-node<% if self.tabs then %> cbi-section-node-tabbed<% end %>" id="cbi-<%=self.config%>-<%=section%>">
|
||||
<%+cbi/ucisection%>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- /nsection -->
|
||||
@@ -1,10 +0,0 @@
|
||||
<%+cbi/valueheader%>
|
||||
<input type="hidden" value="1"<%=
|
||||
attr("name", "cbi.cbe." .. self.config .. "." .. section .. "." .. self.option)
|
||||
%> />
|
||||
<input class="cbi-input-checkbox" data-update="click change" type="checkbox" <% if self.disable == 1 then %>disabled <% end %><%=
|
||||
attr("id", cbid) .. attr("name", cbid) .. attr("value", self.enabled or 1) ..
|
||||
ifattr((self:cfgvalue(section) or self.default) == self.enabled, "checked", "checked")
|
||||
%> />
|
||||
<label<%= attr("for", cbid)%>></label>
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -1,28 +0,0 @@
|
||||
<br />
|
||||
<ul class="cbi-tabmenu">
|
||||
<li id="cbi-tab-container_info"><a id="a-cbi-tab-container_info" href=""><%:Info%></a></li>
|
||||
<li id="cbi-tab-container_resources"><a id="a-cbi-tab-container_resources" href=""><%:Resources%></a></li>
|
||||
<li id="cbi-tab-container_stats"><a id="a-cbi-tab-container_stats" href=""><%:Stats%></a></li>
|
||||
<li id="cbi-tab-container_file"><a id="a-cbi-tab-container_file" href=""><%:File%></a></li>
|
||||
<li id="cbi-tab-container_console"><a id="a-cbi-tab-container_console" href=""><%:Console%></a></li>
|
||||
<li id="cbi-tab-container_inspect"><a id="a-cbi-tab-container_inspect" href=""><%:Inspect%></a></li>
|
||||
<li id="cbi-tab-container_logs"><a id="a-cbi-tab-container_logs" href=""><%:Logs%></a></li>
|
||||
</ul>
|
||||
|
||||
<script>
|
||||
let re = /\/admin\/docker\/container\//
|
||||
let p = window.location.href
|
||||
let path = p.split(re)
|
||||
let container_id = path[1].split('/')[0] || path[1]
|
||||
let action = path[1].split('/')[1] || "info"
|
||||
let actions=["info","resources","stats","file","console","logs","inspect"]
|
||||
actions.forEach(function(item) {
|
||||
document.getElementById("a-cbi-tab-container_" + item).href= path[0]+"/admin/docker/container/"+container_id+'/'+item
|
||||
if (action === item) {
|
||||
document.getElementById("cbi-tab-container_" + item).className="cbi-tab"
|
||||
}
|
||||
else {
|
||||
document.getElementById("cbi-tab-container_" + item).className="cbi-tab-disabled"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="cbi-map">
|
||||
<iframe id="terminal" style="width: 100%; min-height: 500px; border: none; border-radius: 3px;"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById("terminal").src = "http://" + window.location.hostname + ":7682";
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<div id="upload-container" class="cbi-value cbi-value-last">
|
||||
<label class="cbi-value-title" for="archive"><%:Upload%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input type="file" name="upload_archive" accept="application/x-tar" id="upload_archive" />
|
||||
</div>
|
||||
<br />
|
||||
<label class="cbi-value-title" for="path"><%:Path%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input type="text" class="cbi-input-text" name="path" value="/tmp/" id="path" />
|
||||
</div>
|
||||
<br />
|
||||
<div class="cbi-value-field">
|
||||
<input type="button"" class="btn cbi-button cbi-button-action important" id="upload" name="upload" value="<%:Upload%>" />
|
||||
<input type="button"" class="btn cbi-button cbi-button-action important" id="download" name="download" value="<%:Download%>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let btnUpload = document.getElementById('upload')
|
||||
btnUpload.onclick = function (e) {
|
||||
let uploadArchive = document.getElementById('upload_archive')
|
||||
let uploadPath = document.getElementById('path').value
|
||||
if (!uploadArchive.value || !uploadPath) {
|
||||
docker_status_message('warning', "<%:Please input the PATH and select the file !%>")
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
let fileName = uploadArchive.files[0].name
|
||||
let formData = new FormData()
|
||||
formData.append('upload-filename', fileName)
|
||||
formData.append('upload-path', uploadPath)
|
||||
formData.append('upload-archive', uploadArchive.files[0])
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.open("POST", '<%=luci.dispatcher.build_url("admin/docker/container_put_archive")%>/<%=self.container%>', true)
|
||||
xhr.onload = function() {
|
||||
if (xhr.status == 200) {
|
||||
uploadArchive.value = ''
|
||||
docker_status_message('notice', "<%:Upload Success%>")
|
||||
}
|
||||
else {
|
||||
docker_status_message('warning', "<%:Upload Error%>:" + xhr.statusText)
|
||||
}
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
}
|
||||
xhr.send(formData)
|
||||
}
|
||||
|
||||
let btnDownload = document.getElementById('download')
|
||||
btnDownload.onclick = function (e) {
|
||||
let downloadPath = document.getElementById('path').value
|
||||
if (!downloadPath) {
|
||||
docker_status_message('warning', "<%:Please input the PATH !%>")
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
window.open('<%=luci.dispatcher.build_url("admin/docker/container_get_archive")%>?id=<%=self.container%>&path=' + encodeURIComponent(downloadPath))
|
||||
}
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script>
|
||||
let last_bw_tx
|
||||
let last_bw_rx
|
||||
let interval = 3
|
||||
|
||||
function progressbar(v, m, pc, np, f) {
|
||||
m = m || 100
|
||||
|
||||
return String.format(
|
||||
'<div style="width:100%%; max-width:500px; position:relative; border:1px solid #999999">' +
|
||||
'<div style="background-color:#CCCCCC; width:%d%%; height:15px">' +
|
||||
'<div style="position:absolute; left:0; top:0; text-align:center; width:100%%; color:#000000">' +
|
||||
'<small>%s '+(f?f:'/')+' %s ' + (np ? "" : '(%d%%)') + '</small>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>', pc, v, m, pc, f
|
||||
);
|
||||
}
|
||||
|
||||
function niceBytes(bytes, decimals) {
|
||||
if (bytes == 0) return '0 Bytes';
|
||||
var k = 1000,
|
||||
dm = decimals + 1 || 3,
|
||||
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
|
||||
i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
XHR.poll(interval, '<%=luci.dispatcher.build_url("admin/docker/container_stats")%>/<%=self.container_id%>', { status: 1 },
|
||||
function (x, info) {
|
||||
var e;
|
||||
|
||||
if (e = document.getElementById('cbi-table-cpu-value'))
|
||||
e.innerHTML = progressbar(
|
||||
(info.cpu_percent), 100, (info.cpu_percent ? info.cpu_percent : 0));
|
||||
if (e = document.getElementById('cbi-table-memory-value'))
|
||||
e.innerHTML = progressbar(
|
||||
niceBytes(info.memory.mem_useage),
|
||||
niceBytes(info.memory.mem_limit),
|
||||
((100 / (info.memory.mem_limit ? info.memory.mem_limit : 100)) * (info.memory.mem_useage ? info.memory.mem_useage : 0))
|
||||
);
|
||||
|
||||
for (var eth in info.bw_rxtx) {
|
||||
if (!document.getElementById("cbi-table-speed_" + eth + "-value")) {
|
||||
let tab = document.getElementById("cbi-table-cpu").parentNode
|
||||
let div = document.getElementById('cbi-table-cpu').cloneNode(true);
|
||||
div.id = "cbi-table-speed_" + eth;
|
||||
div.children[0].innerHTML = "<%:Upload/Download%>: " + eth
|
||||
div.children[1].id = "cbi-table-speed_" + eth + "-value"
|
||||
tab.appendChild(div)
|
||||
}
|
||||
if (!document.getElementById("cbi-table-network_" + eth + "-value")) {
|
||||
let tab = document.getElementById("cbi-table-cpu").parentNode
|
||||
let div = document.getElementById('cbi-table-cpu').cloneNode(true);
|
||||
div.id = "cbi-table-network_" + eth;
|
||||
div.children[0].innerHTML = "<%:TX/RX%>: " + eth
|
||||
div.children[1].id = "cbi-table-network_" + eth + "-value"
|
||||
tab.appendChild(div)
|
||||
}
|
||||
e = document.getElementById("cbi-table-network_" + eth + "-value")
|
||||
e.innerHTML = progressbar(
|
||||
'↑'+niceBytes(info.bw_rxtx[eth].bw_tx),
|
||||
'↓'+niceBytes(info.bw_rxtx[eth].bw_rx),
|
||||
null,
|
||||
true, " "
|
||||
);
|
||||
e = document.getElementById("cbi-table-speed_" + eth + "-value")
|
||||
if (! last_bw_tx) last_bw_tx = info.bw_rxtx[eth].bw_tx
|
||||
if (! last_bw_rx) last_bw_rx = info.bw_rxtx[eth].bw_rx
|
||||
e.innerHTML = progressbar(
|
||||
'↑'+niceBytes((info.bw_rxtx[eth].bw_tx - last_bw_tx)/interval)+'/s',
|
||||
'↓'+niceBytes((info.bw_rxtx[eth].bw_rx - last_bw_rx)/interval)+'/s',
|
||||
null,
|
||||
true, " "
|
||||
);
|
||||
last_bw_tx = info.bw_rxtx[eth].bw_tx
|
||||
last_bw_rx = info.bw_rxtx[eth].bw_rx
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
@@ -1,104 +0,0 @@
|
||||
<input type="text" class="cbi-input-text" name="isrc" placeholder="http://host/image.tar" id="isrc" />
|
||||
<input type="text" class="cbi-input-text" name="itag" placeholder="repository:tag" id="itag" />
|
||||
<div style="display: inline-block;">
|
||||
<input type="button"" class="btn cbi-button cbi-button-add" id="btnimport" name="import" value="<%:Import%>" />
|
||||
<input type="file" id="file_import" style="visibility:hidden; position: absolute;top: 0px; left: 0px;" />
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let btnImport = document.getElementById('btnimport')
|
||||
let valISrc = document.getElementById('isrc')
|
||||
let valITag = document.getElementById('itag')
|
||||
btnImport.onclick = function (e) {
|
||||
if (valISrc.value == "") {
|
||||
document.getElementById("file_import").click()
|
||||
return
|
||||
}
|
||||
else {
|
||||
let formData = new FormData()
|
||||
formData.append('src', valISrc.value)
|
||||
formData.append('tag', valITag.value)
|
||||
let xhr = new XMLHttpRequest()
|
||||
uci_confirm_docker()
|
||||
xhr.open("POST", "<%=luci.dispatcher.build_url('admin/docker/images_import')%>", true)
|
||||
xhr.onload = function () {
|
||||
location.reload()
|
||||
}
|
||||
xhr.send(formData)
|
||||
}
|
||||
}
|
||||
|
||||
let fileimport = document.getElementById('file_import')
|
||||
fileimport.onchange = function (e) {
|
||||
let fileimport = document.getElementById('file_import')
|
||||
if (!fileimport.value) {
|
||||
return
|
||||
}
|
||||
let valITag = document.getElementById('itag')
|
||||
let fileName = fileimport.files[0].name
|
||||
let formData = new FormData()
|
||||
formData.append('upload-filename', fileName)
|
||||
formData.append('tag', valITag.value)
|
||||
formData.append('upload-archive', fileimport.files[0])
|
||||
let xhr = new XMLHttpRequest()
|
||||
uci_confirm_docker()
|
||||
xhr.open("POST", "<%=luci.dispatcher.build_url('admin/docker/images_import')%>", true)
|
||||
xhr.onload = function () {
|
||||
fileimport.value = ''
|
||||
location.reload()
|
||||
}
|
||||
xhr.send(formData)
|
||||
}
|
||||
|
||||
let new_tag = function (image_id) {
|
||||
let new_tag = prompt("<%:New tag%>\n<%:Image%>" + "ID: " + image_id + "\n<%:Please input new tag%>:", "")
|
||||
if (new_tag) {
|
||||
(new XHR()).post("<%=luci.dispatcher.build_url('admin/docker/images_tag')%>",
|
||||
{
|
||||
id: image_id,
|
||||
tag: new_tag
|
||||
},
|
||||
function (r) {
|
||||
if (r.status == 201) {
|
||||
location.reload()
|
||||
}
|
||||
else {
|
||||
docker_status_message('warning', 'Image: untagging ' + tag + '...fail code:' + r.status + r.statusText);
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let un_tag = function (tag) {
|
||||
if (tag.match("<none>"))
|
||||
return
|
||||
if (confirm("<%:Remove tag%>: " + tag + " ?")) {
|
||||
(new XHR()).post("<%=luci.dispatcher.build_url('admin/docker/images_untag')%>",
|
||||
{
|
||||
tag: tag
|
||||
},
|
||||
function (r) {
|
||||
if (r.status == 200) {
|
||||
location.reload()
|
||||
}
|
||||
else {
|
||||
docker_status_message('warning', 'Image: untagging ' + tag + '...fail code:' + r.status + r.statusText);
|
||||
document.getElementById('docker_apply_overlay').addEventListener(
|
||||
"click",
|
||||
(e)=>{
|
||||
docker_status_message()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
<div style="display: inline-block;">
|
||||
<input type="button"" class="btn cbi-button cbi-button-add" id="btnload" name="load" value="<%:Load%>" />
|
||||
<input type="file" id="file_load" style="visibility:hidden; position: absolute;top: 0px; left: 0px;" accept="application/x-tar" />
|
||||
</div>
|
||||
<script>
|
||||
let btnLoad = document.getElementById('btnload')
|
||||
btnLoad.onclick = function (e) {
|
||||
document.getElementById("file_load").click()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
let fileLoad = document.getElementById('file_load')
|
||||
fileLoad.onchange = function(e){
|
||||
let fileLoad = document.getElementById('file_load')
|
||||
if (!fileLoad.value) {
|
||||
return
|
||||
}
|
||||
let fileName = fileLoad.files[0].name
|
||||
let formData = new FormData()
|
||||
formData.append('upload-filename', fileName)
|
||||
formData.append('upload-archive', fileLoad.files[0])
|
||||
let xhr = new XMLHttpRequest()
|
||||
uci_confirm_docker()
|
||||
xhr.open("POST", '<%=luci.dispatcher.build_url("admin/docker/images_load")%>', true)
|
||||
xhr.onload = function() {
|
||||
location.reload()
|
||||
}
|
||||
xhr.send(formData)
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<% if self.title == "Events" then %>
|
||||
<%+header%>
|
||||
<h2 name="content"><%:Docker%></h2>
|
||||
<div class="cbi-section">
|
||||
<h3><%:Events%></h3>
|
||||
<% end %>
|
||||
<div id="content_syslog">
|
||||
<textarea readonly="readonly" wrap="off" rows="<%=self.syslog:cmatch('\n')+2%>" id="syslog"><%=self.syslog:pcdata()%></textarea>
|
||||
</div>
|
||||
<% if self.title == "Events" then %>
|
||||
</div>
|
||||
<%+footer%>
|
||||
<% end %>
|
||||
@@ -1,102 +0,0 @@
|
||||
<style>
|
||||
#dialog_reslov {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: none;
|
||||
z-index: 20000;
|
||||
}
|
||||
|
||||
#dialog_reslov .dialog_box {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255);
|
||||
top: 10%;
|
||||
width: 50%;
|
||||
margin: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
height:auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#dialog_reslov .dialog_line {
|
||||
margin-top: .5em;
|
||||
margin-bottom: .5em;
|
||||
margin-left: 2em;
|
||||
margin-right: 2em;
|
||||
}
|
||||
|
||||
#dialog_reslov .dialog_box>h4,
|
||||
#dialog_reslov .dialog_box>p,
|
||||
#dialog_reslov .dialog_box>div {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
#dialog_reslov .dialog_box>img {
|
||||
margin-right: 1em;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
|
||||
body.dialog-reslov-active {
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body.dialog-reslov-active #dialog_reslov {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function close_reslov_dialog() {
|
||||
document.body.classList.remove('dialog-reslov-active')
|
||||
document.documentElement.style.overflowY = 'scroll'
|
||||
}
|
||||
|
||||
function reslov_container() {
|
||||
let s = document.getElementById('cmd-line-status')
|
||||
|
||||
if (!s)
|
||||
return
|
||||
|
||||
let cmd_line = document.getElementById("dialog_reslov_text").value;
|
||||
if (cmd_line == null || cmd_line == "") {
|
||||
return
|
||||
}
|
||||
|
||||
cmd_line = cmd_line.replace(/(^\s*)/g,"")
|
||||
if (!cmd_line.match(/^docker\s+(run|create)/)) {
|
||||
s.innerHTML = "<font color='red'><%:Command line Error%></font>"
|
||||
return
|
||||
}
|
||||
|
||||
let reg_space = /\s+/g
|
||||
let reg_muti_line= /\\\s*\n/g
|
||||
// reg_rem =/(?<!\\)`#.+(?<!\\)`/g // the command has `# `
|
||||
let reg_rem =/`#.+`/g// the command has `# `
|
||||
cmd_line = cmd_line.replace(/^docker\s+(run|create)/,"DOCKERCLI").replace(reg_rem, " ").replace(reg_muti_line, " ").replace(reg_space, " ")
|
||||
console.log(cmd_line)
|
||||
window.location.href = '<%=luci.dispatcher.build_url("admin/docker/newcontainer")%>/' + encodeURI(cmd_line)
|
||||
}
|
||||
|
||||
function clear_text(){
|
||||
let s = document.getElementById('cmd-line-status')
|
||||
s.innerHTML = ""
|
||||
}
|
||||
|
||||
function show_reslov_dialog() {
|
||||
document.getElementById('dialog_reslov') || document.body.insertAdjacentHTML("beforeend", '<div id="dialog_reslov"><div class="dialog_box"><div class="dialog_line"></div><div class="dialog_line"><span><%:Plese input <docker create/run> command line:%></span><br /><span id="cmd-line-status"></span></div><div class="dialog_line"><textarea class="cbi-input-textarea" id="dialog_reslov_text" style="width: 100%; height:100%;" rows="15" onkeyup="clear_text()"></textarea></div><div class="dialog_line" style="text-align: right;"><input type="button" class="btn cbi-button cbi-button-apply" type="submit" value="<%:Submit%>" onclick="reslov_container()" /> <input type="button" class="btn cbi-button cbi-button-reset" type="reset" value="<%:Cancel%>" onclick="close_reslov_dialog()" /></div><div class="dialog_line"></div></div></div>')
|
||||
document.body.classList.add('dialog-reslov-active')
|
||||
let s = document.getElementById('cmd-line-status')
|
||||
s.innerHTML = ""
|
||||
document.documentElement.style.overflowY = 'hidden'
|
||||
}
|
||||
</script>
|
||||
<%+cbi/valueheader%>
|
||||
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Command line%>" onclick="show_reslov_dialog()" />
|
||||
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -1,197 +0,0 @@
|
||||
<style>
|
||||
/*!
|
||||
Pure v1.0.1
|
||||
Copyright 2013 Yahoo!
|
||||
Licensed under the BSD License.
|
||||
https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
*/
|
||||
.pure-g {
|
||||
letter-spacing: -.31em;
|
||||
text-rendering: optimizespeed;
|
||||
font-family: FreeSans, Arimo, "Droid Sans", Helvetica, Arial, sans-serif;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-box-orient: horizontal;
|
||||
-webkit-box-direction: normal;
|
||||
-webkit-flex-flow: row wrap;
|
||||
-ms-flex-flow: row wrap;
|
||||
flex-flow: row wrap;
|
||||
-webkit-align-content: flex-start;
|
||||
-ms-flex-line-pack: start;
|
||||
align-content: flex-start
|
||||
}
|
||||
|
||||
.pure-u {
|
||||
display: inline-block;
|
||||
zoom: 1;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
vertical-align: top;
|
||||
text-rendering: auto
|
||||
}
|
||||
|
||||
.pure-g [class*=pure-u] {
|
||||
font-family: sans-serif
|
||||
}
|
||||
|
||||
.pure-u-1-4,
|
||||
.pure-u-2-5,
|
||||
.pure-u-3-5 {
|
||||
display: inline-block;
|
||||
zoom: 1;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
vertical-align: top;
|
||||
text-rendering: auto
|
||||
}
|
||||
|
||||
.pure-u-1-4 {
|
||||
width: 25%
|
||||
}
|
||||
|
||||
.pure-u-2-5 {
|
||||
width: 40%
|
||||
}
|
||||
|
||||
.pure-u-3-5 {
|
||||
width: 60%
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 1rem -0.5rem 1rem -0.5rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
margin: 0.5rem 0.5rem;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
min-width: inherit;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, .05);
|
||||
border-radius: .375rem;
|
||||
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15);
|
||||
}
|
||||
|
||||
.img-con {
|
||||
margin: 1rem;
|
||||
min-width: 4rem;
|
||||
max-width: 4rem;
|
||||
min-height: 4rem;
|
||||
max-height: 4rem;
|
||||
}
|
||||
|
||||
.block h4 {
|
||||
font-size: .8125rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem;
|
||||
color: #8898aa !important;
|
||||
line-height: 1.8em;
|
||||
}
|
||||
|
||||
.cbi-section-table-cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.pure-u-1-4 {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.cbi-button-add {
|
||||
position: fixed;
|
||||
padding: 0.3rem 0.5rem;
|
||||
z-index: 1000;
|
||||
width: 50px !important;
|
||||
height: 50px;
|
||||
bottom: 90px;
|
||||
right: 5px;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
background-color: #fb6340 !important;
|
||||
border-color: #fb6340 !important;
|
||||
box-shadow: 0 0 1rem 0 rgba(136, 152, 170, .75);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="pure-g status">
|
||||
<div class="pure-u-1-4">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-2-5">
|
||||
<div class="img-con">
|
||||
<img src="<%=resource%>/dockerman/containers.svg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-3-5">
|
||||
<h4 style="text-align: right; font-size: 1rem"><%:Containers%></h4>
|
||||
<h4 style="text-align: right;">
|
||||
<%- if self.containers_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/containers")%>'><%- end -%>
|
||||
<span style="font-size: 2rem; color: #2dce89;"><%=self.containers_running%></span>
|
||||
<span style="font-size: 1rem; color: #8898aa !important;">/<%=self.containers_total%></span>
|
||||
<%- if self.containers_total ~= "-" then -%></a><%- end -%>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-2-5">
|
||||
<div class="img-con">
|
||||
<img src="<%=resource%>/dockerman/images.svg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-3-5">
|
||||
<h4 style="text-align: right; font-size: 1rem"><%:Images%></h4>
|
||||
<h4 style="text-align: right;">
|
||||
<%- if self.images_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/images")%>'><%- end -%>
|
||||
<span style="font-size: 2rem; color: #2dce89;"><%=self.images_used%></span>
|
||||
<span style="font-size: 1rem; color: #8898aa !important;">/<%=self.images_total%></span>
|
||||
<%- if self.images_total ~= "-" then -%></a><%- end -%>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-2-5">
|
||||
<div class="img-con">
|
||||
<img src="<%=resource%>/dockerman/networks.svg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-3-5">
|
||||
<h4 style="text-align: right; font-size: 1rem"><%:Networks%></h4>
|
||||
<h4 style="text-align: right;">
|
||||
<%- if self.networks_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/networks")%>'><%- end -%>
|
||||
<span style="font-size: 2rem; color: #2dce89;"><%=self.networks_total%></span>
|
||||
<!-- <span style="font-size: 1rem; color: #8898aa !important;">/20</span> -->
|
||||
<%- if self.networks_total ~= "-" then -%></a><%- end -%>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-2-5">
|
||||
<div class="img-con">
|
||||
<img src="<%=resource%>/dockerman/volumes.svg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-3-5">
|
||||
<h4 style="text-align: right; font-size: 1rem"><%:Volumes%></h4>
|
||||
<h4 style="text-align: right;">
|
||||
<%- if self.volumes_total ~= "-" then -%><a href='<%=luci.dispatcher.build_url("admin/docker/volumes")%>'><%- end -%>
|
||||
<span style="font-size: 2rem; color: #2dce89;"><%=self.volumes_total%></span>
|
||||
<!-- <span style="font-size: 1rem; color: #8898aa !important;">/20</span> -->
|
||||
<%- if self.volumes_total ~= "-" then -%></a><%- end -%>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+1685
-380
File diff suppressed because it is too large.
Load diff
+1668
-359
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1668
-357
File diff suppressed because it is too large.
Load diff
+1727
-403
File diff suppressed because it is too large.
Load diff
+1668
-358
File diff suppressed because it is too large.
Load diff
+1704
-411
File diff suppressed because it is too large.
Load diff
+1663
-353
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1741
-413
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1702
-405
File diff suppressed because it is too large.
Load diff
+1669
-359
File diff suppressed because it is too large.
Load diff
+1698
-405
File diff suppressed because it is too large.
Load diff
+1732
-415
File diff suppressed because it is too large.
Load diff
+1666
-356
File diff suppressed because it is too large.
Load diff
+1662
-352
File diff suppressed because it is too large.
Load diff
+1670
-360
File diff suppressed because it is too large.
Load diff
+1702
-406
File diff suppressed because it is too large.
Load diff
+1669
-359
File diff suppressed because it is too large.
Load diff
+1692
-386
File diff suppressed because it is too large.
Load diff
+1735
-427
File diff suppressed because it is too large.
Load diff
+1705
-407
File diff suppressed because it is too large.
Load diff
+1662
-352
File diff suppressed because it is too large.
Load diff
+1665
-355
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1699
-404
File diff suppressed because it is too large.
Load diff
+1739
-419
File diff suppressed because it is too large.
Load diff
+1698
-403
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1698
-404
File diff suppressed because it is too large.
Load diff
+1731
-420
File diff suppressed because it is too large.
Load diff
+1665
-356
File diff suppressed because it is too large.
Load diff
+1704
-406
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+1702
-406
File diff suppressed because it is too large.
Load diff
+1704
-408
File diff suppressed because it is too large.
Load diff
+1698
-402
File diff suppressed because it is too large.
Load diff
+1662
-352
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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,26 @@
|
||||
"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" ],
|
||||
"network.interface": [ "dump" ],
|
||||
"rc": [ "init" ]
|
||||
},
|
||||
"uci": [ "dockerd" ]
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"docker": [ "*" ],
|
||||
"docker.*": [ "*" ],
|
||||
"rc": [ "init" ]
|
||||
},
|
||||
"uci": [ "dockerd" ]
|
||||
}
|
||||
}
|
||||
|
||||
+507
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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