17 Commits

Author SHA1 Message Date
Paul Donald c9e0285618 luci-app-dockerman: return true for net validate
When all other conditions pass, the function shall return true.

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
Signed-off-by: sbwml <admin@cooluc.com>
2026-04-19 17:13:06 +08:00
sbwml a081876f33 luci-app-dockerman: optimize container command display
Signed-off-by: sbwml <admin@cooluc.com>
2026-04-19 17:07:36 +08:00
sbwml f60e6d8659 luci-app-dockerman: chore(containers): disable the container prune button
Signed-off-by: sbwml <admin@cooluc.com>
2026-03-06 07:09:44 +08:00
sbwml 63012460bc luci-app-dockerman: feat(containers): Make container port mappings clickable
Signed-off-by: sbwml <admin@cooluc.com>
2026-03-06 06:59:32 +08:00
sbwml da5e1d46e8 luci-app-dockerman: add missing zh-cn translations
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 15:57:19 +08:00
sbwml 70c4701456 luci-app-dockerman: fix correct storage unit display to GiB/MiB
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 15:45:12 +08:00
sbwml e37ca2bd46 luci-app-dockerman: add missing zh-cn translations
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 11:43:28 +08:00
sbwml 94c6ef5266 luci-app-dockerman: remove emoji i18n
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 11:42:17 +08:00
sbwml ff6723c59a luci-app-dockerman: fix(networks): Use relative path for network detail links
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 04:22:19 +08:00
sbwml 24edcc374f luci-app-dockerman: fix menu order
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 03:53:59 +08:00
sbwml 9392d0e1e7 luci-app-dockerman: add http/https proxy support
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 03:28:28 +08:00
sbwml 53e46a6469 luci-app-dockerman: add buildkit & experimental
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 03:21:14 +08:00
sbwml 789e328d11 luci-app-dockerman: add missing zh-cn translations
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 03:09:07 +08:00
sbwml 63ba560ec7 luci-app-dockerman: optimize overview page and clean up info table
rendering

Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 02:57:27 +08:00
sbwml df41465d5f luci-app-dockerman: Improve robustness of the overview page
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-22 01:35:49 +08:00
sbwml 8f32cdf595 luci-app-dockerman: modify menu layout
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-21 22:46:34 +08:00
sbwml fb4454678a luci-app-dockerman: add from openwrt luci
Signed-off-by: sbwml <admin@cooluc.com>
2026-02-21 22:33:23 +08:00
89 changed files with 89599 additions and 20556 deletions
+6 -7
View File
@@ -2,19 +2,18 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Support for docker
LUCI_DEPENDS:=@(aarch64||arm||x86_64) \
+luci-compat \
+luci-lib-docker \
+luci-base \
+docker \
+ttyd \
+dockerd \
+ttyd
LUCI_PKGARCH:=all
+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:=1.0.0
include $(TOPDIR)/feeds/luci/luci.mk
include ../../luci.mk
# call BuildPackage - OpenWrt buildroot signature
+244
View File
@@ -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
@@ -0,0 +1,174 @@
'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.Flag, 'buildkit',
_('Enable BuildKit'),
_('BuildKit is an improved backend to replace the legacy builder.'));
o.rmempty = false;
o.optional = true;
o = s.taboption('globals', form.Flag, 'experimental',
_('Experimental Features'),
_('Enable Docker experimental features.'));
o.rmempty = false;
o.optional = true;
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.Value, 'http_proxy',
_('HTTP Proxy'),
_('Set the HTTP proxy for Docker (optional)'));
o.placeholder = _('Example: `http://proxy.example.com:3128`');
o.rmempty = true;
o = s.taboption('globals', form.Value, 'https_proxy',
_('HTTPS Proxy'),
_('Set the HTTPS proxy for Docker (optional)'));
o.placeholder = _('Example: `https://proxy.example.com:3128`');
o.rmempty = true;
o = s.taboption('globals', form.Value, 'no_proxy',
_('No Proxy (no_proxy)'),
_('Set addresses that bypass the proxy for Docker (optional, comma separated)'));
o.placeholder = _('Example: *.test.example.com,.example.org,127.0.0.0/8');
o.rmempty = true;
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,951 @@
'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');
return true;
};
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,372 @@
'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}`;
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}`;
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'].i18n]),
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'].i18n]),
(() => {
const icon = isRunning
? dm2.Types['container'].sub['pause'].i18n
: (isPaused
? dm2.Types['container'].sub['unpause'].i18n
: dm2.Types['container'].sub['start'].i18n);
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'].i18n]),
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'].i18n]),
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'].i18n]),
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'].i18n]),
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'].i18n]),
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'].i18n]),
];
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),
Command: cont.Command ? E('div', { 'style': 'word-break: break-all;' }, String(cont.Command)) : '',
Created: this.buildTimeString(cont?.Created) || '',
Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
? cont.Ports.map(p => {
const pub = p.PublicPort || '';
const priv = p.PrivatePort || '';
const type = p.Type || '';
const displayText = `${pub ? pub + ':' : ''}${priv}/${type}`;
if (!pub) {
return displayText;
}
const host = p.IP;
if (!host || host === '0.0.0.0' || host === '::') {
const onclick = `window.open((window.location.origin.match(/^(.+):\\d+$/) && window.location.origin.match(/^(.+):\\d+$/)[1] || window.location.origin) + ':${pub}', '_blank')`;
return `<a href="javascript:void(0);" onclick="${onclick}">${displayText}</a>`;
}
const urlHost = host.includes(':') ? `[${host}]` : host;
return `<a href="http://${urlHost}:${pub}" target="_blank">${displayText}</a>`;
}).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].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.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.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].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}`; // _('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}`; // _('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}`; // _('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}`;
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}` //_('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}`;
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}`;
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}`;
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'].i18n]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': dm2.ActionTypes['history'].i18n,
'click': ui.createHandlerFn(this, this.handleHistory, image),
}, [dm2.ActionTypes['history'].i18n]),
E('button', {
'class': 'cbi-button cbi-button-positive save',
'title': dm2.ActionTypes['save'].i18n,
'click': ui.createHandlerFn(this, this.handleGet, image),
}, [dm2.ActionTypes['save'].i18n]),
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'].i18n]),
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'].i18n]),
];
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'].i18n);
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
}, _('Cancel')),
' ',
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'].i18n)
])
]);
}, 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
}, [_('Cancel')]),
' ',
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'].i18n])
])
]);
}, 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}`;
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}`;
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}`;
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'].i18n]),
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'].i18n),
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'].i18n),
];
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': `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,293 @@
'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().catch(e => ({ body: { message: e.message }, error: e })),
dm2.docker_info().catch(e => ({ code: 500, body: { message: e.message }, error: e })),
// dm2.docker_df(), // takes > 20 seconds on large docker environments
dm2.container_list().then(r => r.body || []).catch(e => []),
dm2.image_list().then(r => r.body || []).catch(e => []),
dm2.network_list().then(r => r.body || []).catch(e => []),
dm2.volume_list().then(r => r.body || []).catch(e => ({ Volumes: [] })),
dm2.callMountPoints().catch(e => []),
]);
},
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) {
const mainContainer = E('div', { 'class': 'cbi-map' });
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.'),
]));
mainContainer.appendChild(E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('p', { 'class': 'spinning' }, _('Docker daemon is not running.')),
E('p', { 'style': 'font-family: monospace; color: #888; margin-left: 1.5em;'}, info_response?.body?.message)
])
]));
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 positive',
'click': () => this.handleAction('dockerd', 'start').then(() => {
L.ui.showModal(_('Starting daemon...'), [
E('p', { 'class': 'spinning' }, _('The page will be reloaded in 5 seconds.'))
]);
setTimeout(() => window.location.reload(), 5000);
})
}, _('Start', 'daemon start action')),
])
]));
}
return mainContainer;
}
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.2mB'.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.')
]));
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').then(() => {
L.ui.showModal(_('Restarting daemon...'), [
E('p', { 'class': 'spinning' }, _('The page will be reloaded in 10 seconds.'))
]);
setTimeout(() => window.location.reload(), 10000);
}) }, _('Restart', 'daemon restart action')),
E('button', { 'class': 'btn cbi-button-action cbi-button-remove', 'click': () => this.handleAction('dockerd', 'stop').then(() => {
L.ui.showModal(_('Stopping daemon...'), [
E('p', { 'class': 'spinning' }, _('The page will be reloaded in 5 seconds.'))
]);
setTimeout(() => window.location.reload(), 5000);
}) }, _('Stop', 'daemon stop action')),
])
]));
const info_data = {
[_('Docker Version')]: version_response.body.Version,
[_('Api Version')]: version_response.body.ApiVersion,
[_('CPUs')]: info_response.body.NCPU,
[_('Total Memory')]: '%1024.2mB'.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 ?? []).join(', ') || '-',
};
const info_body_table = [];
this.parseBody(info_data, info_body_table);
const m_info = new form.JSONMap({ ib: info_body_table });
m_info.readonly = true;
m_info.tabbed = false;
const s_info = m_info.section(form.TableSection, 'ib', _('Info'));
s_info.anonymous = true;
s_info.option(form.DummyValue, 'entry', _('Name'));
s_info.option(form.DummyValue, 'value', _('Value'));
const m_version = new form.JSONMap({ vb: version_body });
m_version.readonly = true;
m_version.tabbed = false;
const s_version = m_version.section(form.TableSection, 'vb', _('Version'));
s_version.anonymous = true;
s_version.option(form.DummyValue, 'entry', _('Name'));
s_version.option(form.DummyValue, 'value', _('Value'));
const 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 : ''),
]);
return Promise.all([m_info.render(), m_version.render()]).then(([info_fe, version_fe]) => {
mainContainer.appendChild(info_fe);
mainContainer.appendChild(statusContainer);
mainContainer.appendChild(version_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}`;
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}`;
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
}, [_('Cancel')]),
' ',
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'].i18n])
])
]);
};
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'].i18n]),
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'].i18n]),
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'].i18n]),
];
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);
},
});
-444
View File
@@ -1,444 +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"), 41).acl_depends = { "luci-app-dockerman" }
entry({"admin", "docker", "overview"},cbi("dockerman/overview"),_("Overview"), 1).leaf=true
entry({"admin", "docker", "containers"}, form("dockerman/containers"), _("Containers"), 2).leaf=true
entry({"admin", "docker", "images"}, form("dockerman/images"), _("Images"), 3).leaf=true
entry({"admin", "docker", "networks"}, form("dockerman/networks"), _("Networks"), 4).leaf=true
entry({"admin", "docker", "volumes"}, form("dockerman/volumes"), _("Volumes"), 5).leaf=true
entry({"admin", "docker", "events"}, call("action_events"), _("Events"), 6)
entry({"admin", "docker", "config"},cbi("dockerman/configuration"),_("Configuration"), 7).leaf=true
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_total_usage_current = tonumber(d["cpu_stats"]["cpu_usage"]["total_usage"])
local cpu_total_usage_previous = tonumber(d["precpu_stats"]["cpu_usage"]["total_usage"])
local system_cpu_usage_current = tonumber(d["cpu_stats"]["system_cpu_usage"])
local system_cpu_usage_previous = tonumber(d["precpu_stats"]["system_cpu_usage"])
if cpu_total_usage_current and cpu_total_usage_previous and system_cpu_usage_current and system_cpu_usage_previous then
local cpu_delta = cpu_total_usage_current - cpu_total_usage_previous
local system_delta = system_cpu_usage_current - system_cpu_usage_previous
if system_delta > 0.0 then
cpu_percent = string.format("%.2f", cpu_delta / system_delta * 100.0 * cpu_count)
end
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"]) or 0
local total_cache = tonumber(d["memory_stats"]["stats"]["total_cache"]) or 0
return usage - total_cache, 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(" ","&#160;")
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,102 +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(Flag, "buildkit",
translate("Enable BuildKit"),
translate("BuildKit is an improved backend to replace the legacy builder."))
o.rmempty = false
o:depends("remote_endpoint", 0)
o = s:option(Flag, "experimental",
translate("Experimental Features"),
translate("Enable Docker experimental features."))
o.rmempty = false
o:depends("remote_endpoint", 0)
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(Value, "http_proxy",
translate("HTTP Proxy"),
translate("Set the HTTP proxy for Docker (optional)"))
o.placeholder = translate("Example: http://proxy.example.com:3128")
o:depends("remote_endpoint", 0)
o = s:option(Value, "https_proxy",
translate("HTTPS Proxy"),
translate("Set the HTTPS proxy for Docker (optional)"))
o.placeholder = translate("Example: https://proxy.example.com:3128")
o:depends("remote_endpoint", 0)
o = s:option(Value, "no_proxy",
translate("No Proxy (no_proxy)"),
translate("Set addresses that bypass the proxy for Docker (optional, comma separated)"))
o.placeholder = translate("Example: *.test.example.com,.example.org,127.0.0.0/8")
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: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
-807
View File
@@ -1,807 +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
data = (data and (data .. "<br />") or "") .. out[1]["HostPort"] .. ":" .. inter
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(" ","&#160;")
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, "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.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
request_body = {
BlkioWeight = tonumber(data.blkioweight),
NanoCPUs = tonumber(data.cpus)*10^9,
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 uci = (require "luci.model.uci").cursor()
local ttyd_ssl = uci:get("ttyd", "@ttyd[0]", "ssl")
local ttyd_ssl_key = uci:get("ttyd", "@ttyd[0]", "ssl_key")
local ttyd_ssl_cert = uci:get("ttyd", "@ttyd[0]", "ssl_cert")
if ttyd_ssl == "1" and ttyd_ssl_cert and ttyd_ssl_key then
cmd_ttyd = string.format('%s -S -C %s -K %s', cmd_ttyd, ttyd_ssl_cert, ttyd_ssl_key)
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
-236
View File
@@ -1,236 +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
data[index]["_ports"] = (data[index]["_ports"] and (data[index]["_ports"] .. ", ") or "")
.. ((v2.PublicPort and v2.Type and v2.Type == "tcp") and ('<a href="javascript:void(0);" onclick="window.open((window.location.origin.match(/^(.+):\\d+$/) && window.location.origin.match(/^(.+):\\d+$/)[1] || window.location.origin) + \':\' + '.. v2.PublicPort ..', \'_blank\');">') or "")
.. (v2.PublicPort and (v2.PublicPort .. ":") or "") .. (v2.PrivatePort and (v2.PrivatePort .."/") or "") .. (v2.Type and v2.Type or "")
.. ((v2.PublicPort and v2.Type and v2.Type == "tcp")and "</a>" or "")
end
end
for ii,iv in ipairs(images) do
if iv.Id == v.ImageID then
data[index]["_image"] = iv.RepoTags and iv.RepoTags[1] or (iv.RepoDigests[1]:gsub("(.-)@.+", "%1") .. ":<none>")
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(" ","&#160;")
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
-280
View File
@@ -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>","&lt;none&gt;")
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("&lt;none&gt;")) 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(" ","&#160;")
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
-154
View File
@@ -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(" ","&#160;")
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
-902
View File
@@ -1,902 +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
table.insert( default_config.publish, v[1].HostPort..":"..k:match("^(%d+)/.+").."/"..k:match("^%d+/(.+)") )
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(" ","&#160;")
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 = "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
for v1 ,v2 in string.gmatch(v, "(%d+):([^%s]+)") do
local _,_,p= v2:find("^%d+/(%w+)")
if p == nil then
v2=v2..'/tcp'
end
portbindings[v2] = {{HostPort=v1}}
exposedports[v2] = {HostPort=v1}
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)
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
-246
View File
@@ -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(" ","&#160;")
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
-143
View File
@@ -1,143 +0,0 @@
--[[
LuCI - Lua Configuration Interface
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
]]--
local docker = require "luci.model.docker"
local uci = (require "luci.model.uci").cursor()
local m, s, o, lost_state
local dk = docker.new()
if dk:_ping().code ~= 200 then
lost_state = true
end
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 = Map("dockerd",
translate("Docker - Overview"),
translate("An overview with the relevant data is displayed here with which the LuCI docker client is connected."))
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='-'}
if nixio.fs.access("/usr/bin/dockerd") and not uci:get_bool("dockerd", "dockerman", "remote_endpoint") then
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(" ","&nbsp;")
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 = lost_state and translate("Start") or translate("Stop")
o.inputstyle = lost_state and "add" or "remove"
o.forcewrite = true
o.write = function(self, section)
docker:clear_status()
if lost_state then
docker:append_status("Docker daemon: starting")
luci.util.exec("/etc/init.d/dockerd start")
else
docker:append_status("Docker daemon: stopping")
luci.util.exec("/etc/init.d/dockerd stop")
end
docker:clear_status()
luci.http.redirect(luci.dispatcher.build_url("admin/docker/overview"))
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)
docker:clear_status()
docker:append_status("Docker daemon: restarting")
luci.util.exec("/etc/init.d/dockerd restart")
docker:clear_status()
luci.http.redirect(luci.dispatcher.build_url("admin/docker/overview"))
end
end
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 not lost_state then
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)
else
docker_info_table['3ServerVersion']._value = translate("Cannot connect to Docker daemon. Is the docker daemon running?")
end
return m
-142
View File
@@ -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(" ","&#160;")
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
-482
View File
@@ -1,482 +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 = {}
_docker.options = {}
--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
local status_path = uci:get("dockerd", "globals", "status_path") or "/tmp/.docker_status"
_docker.options = {
host = host,
port = port,
socket_path = socket_path,
debug = debug,
debug_path = debug_path,
status_path = status_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(self.options.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(self.options.status_path, "w+")
file_docker_action_status:write(val)
file_docker_action_status:close()
end
_docker.read_status=function(self)
return fs.readfile(self.options.status_path)
end
_docker.clear_status=function(self)
fs.remove(self.options.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
-147
View File
@@ -1,147 +0,0 @@
<style type="text/css">
#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 type="text/javascript">//<![CDATA[
var xhr = new XHR(),
uci_apply_rollback = <%=math.max(luci.config and luci.config.apply and luci.config.apply.rollback or 90, 90)%>,
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.gif" 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>
-33
View File
@@ -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 -->
-10
View File
@@ -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%>
-28
View File
@@ -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 type="text/javascript">
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 type="text/javascript">
document.getElementById("terminal").src = window.location.protocol + "//" + window.location.hostname + ":7682";
</script>
-73
View File
@@ -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 type="text/javascript">
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>
-81
View File
@@ -1,81 +0,0 @@
<script type="text/javascript">//<![CDATA[
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>
-104
View File
@@ -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 type="text/javascript">
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>
-30
View File
@@ -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 type="text/javascript">
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>
-13
View File
@@ -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 type="text/css">
#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 type="text/javascript">
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%>
-214
View File
@@ -1,214 +0,0 @@
<%
local fs = require "nixio.fs"
local uci = require 'luci.model.uci'.cursor()
local mode = 'normal'
if fs.access('/etc/config/argon') then
mode = uci:get_first('argon', 'global', 'mode')
end
-%>
<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%;
}
}
.shadow {
background-color: #e6e6e6;
border-radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
.shadow {
background-color: #2c323c;
border-radius: 0.375rem;
}
.block {
border: 2px solid #2c323c;
box-shadow: none;
}
}
<% if mode == 'dark' then %>
.shadow {
background-color: #2c323c;
border-radius: 0.375rem;
}
.block {
border: 2px solid #2c323c;
box-shadow: none;
}
<% end -%>
</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 class="shadow" 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 class="shadow" 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 class="shadow" 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 class="shadow" 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>
+1823 -407
View File
File diff suppressed because it is too large Load Diff
+1699 -375
View File
File diff suppressed because it is too large Load Diff
+1684 -361
View File
File diff suppressed because it is too large Load Diff
+1680 -360
View File
File diff suppressed because it is too large Load Diff
+2099 -564
View File
File diff suppressed because it is too large Load Diff
+1702 -364
View File
File diff suppressed because it is too large Load Diff
+1909 -433
View File
File diff suppressed because it is too large Load Diff
+1676 -359
View File
File diff suppressed because it is too large Load Diff
-946
View File
@@ -1,946 +0,0 @@
msgid ""
msgstr ""
"PO-Revision-Date: 2022-07-03 10:25+0000\n"
"Last-Translator: Hannu Nyman <hannu.nyman@iki.fi>\n"
"Language-Team: English <https://hosted.weblate.org/projects/openwrt/"
"luciapplicationsdockerman/en/>\n"
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.13.1-dev\n"
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:604
msgid "A list of kernel capabilities to add to the container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:183
msgid "Add"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:580
msgid "Add host device to the container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:556
msgid "Advance"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:571
msgid "Allocates an ephemeral host port for all of a container's exposed ports"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:483
msgid "Always pull image first"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:23
msgid ""
"An overview with the relevant data is displayed here with which the LuCI "
"docker client is connected."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:27
msgid "Api Version"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:84
msgid "Auto create macvlan interface in Openwrt"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:64
msgid "Available"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:39
msgid "Base device"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:538
msgid "Bind Mount(-v)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:539
msgid "Bind mount a volume"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:588
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:637
msgid "Block IO Weight"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:638
msgid ""
"Block IO weight (relative weight) accepts a weight value between 10 and 1000"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:589
msgid ""
"Block IO weight (relative weight) accepts a weight value between 10 and 1000."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:51
msgid "Bridge (Support direct communication between MAC VLANs)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:34
msgid "Bridge device"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:84
msgid ""
"By entering a valid image name with the corresponding version, the docker "
"image can be downloaded from the configured registry."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:603
msgid "CAP-ADD(--cap-add)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:573
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:620
msgid "CPU Shares Weight"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:770
msgid "CPU Useage"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:621
msgid ""
"CPU shares relative weight, if 0 is set, the system will ignore the value "
"and use the default of 1024"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:574
msgid ""
"CPU shares relative weight, if 0 is set, the system will ignore the value "
"and use the default of 1024."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:565
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:611
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:28
msgid "CPUs"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm:91
msgid "Cancel"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:65
msgid "Client connection"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:339
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:678
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:142
msgid "Command"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm:100
msgid "Command line"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm:72
msgid "Command line Error"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:16
msgid "Configuration"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:42
msgid "Configure the default bridge network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:397
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:698
msgid "Connect"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:395
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:429
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:465
msgid "Connect Network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:14
msgid "Connect to remote endpoint"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:7
msgid "Console"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:641
msgid "Container Inspect"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:662
msgid "Container Logs"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:125
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:458
msgid "Container Name"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:58
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:29
msgid "Container detail"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:37
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:109
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:143
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:87
#: applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm:133
msgid "Containers"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:107
msgid "Containers overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:84
msgid "Create macvlan interface"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:450
msgid "Create new docker container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:23
msgid "Create new docker network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:304
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:148
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:92
msgid "Created"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:363
msgid "DNS"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:56
msgid "Debug"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:41
msgid "Default bridge"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:355
msgid "Device"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:579
msgid "Device(--device)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:388
msgid "Disconnect"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:13
#: applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm:3
msgid "Docker"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:7
msgid "Docker - Configuration"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:199
msgid "Docker - Container (%s)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:94
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:439
msgid "Docker - Containers"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:72
msgid "Docker - Images"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:12
msgid "Docker - Network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:51
msgid "Docker - Networks"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:22
msgid "Docker - Overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:69
msgid "Docker - Volumes"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:474
msgid "Docker Image"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:36
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:30
msgid "Docker Root Dir"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:26
msgid "Docker Version"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/apply_widget.htm:91
msgid "Docker actions done."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:8
msgid "DockerMan is a simple docker manager client for LuCI"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:14
msgid "Download"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:79
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:32
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:85
msgid "Driver"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:263
msgid "Duplicate/Edit"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:110
msgid "Enable IPv6"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:343
msgid "Env"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:531
msgid "Environmental Variable(-e)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:59
msgid "Error"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:41
#: applications/luci-app-dockerman/luasrc/view/dockerman/logs.htm:5
msgid "Events"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:50
msgid "Example: https://hub-mirror.c.163.com"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:67
msgid "Example: tcp://0.0.0.0:2375"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:106
msgid "Exclude IPs"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:570
msgid "Exposed All Ports(-P)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:545
msgid "Exposed Ports(-p)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:60
msgid "Fatal"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:6
msgid "File"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:316
msgid "Finish Time"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:217
msgid "Force Remove"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:85
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:96
msgid "Gateway"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:10
msgid "Global settings"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm:4
msgid "Go to relevant configuration page"
msgstr ""
#: applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json:3
msgid "Grant UCI access for luci-app-dockerman"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:322
msgid "Healthy"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:563
msgid "Host Name"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:19
msgid "Host or IP Address for the connection to a remote docker instance"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:292
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:122
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:150
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:75
msgid "ID"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:36
msgid "IP VLAN"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:101
msgid "IP range"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:507
msgid "IPv4 Address"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:122
msgid "IPv6 Gateway"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:116
msgid "IPv6 Subnet"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:296
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:139
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm:54
msgid "Image"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:38
#: applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm:151
msgid "Images"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:130
msgid "Images overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm:4
msgid "Import"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:124
msgid "Import Image"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:31
msgid "Index Server Address"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:57
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:406
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:35
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:3
msgid "Info"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:64
msgid "Ingress"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:65
msgid ""
"Ingress network is the network which provides the routing-mesh in swarm mode"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:8
msgid "Inspect"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:462
msgid "Interactive (-i)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:76
msgid "Internal"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:56
msgid "Ipvlan Mode"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:49
msgid ""
"It replaces the daemon registry mirrors with a new set of registry mirrors"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:245
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:220
msgid "Kill"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:60
msgid "L2 bridge"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:61
msgid "L3 bridge"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:351
msgid "Links"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:512
msgid "Links with other containers"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:276
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_load.htm:2
msgid "Load"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:54
msgid "Log Level"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:646
msgid "Log driver options"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:9
msgid "Logs"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:35
msgid "MAC VLAN"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:581
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:629
msgid "Memory"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:774
msgid "Memory Useage"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:630
msgid ""
"Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit "
"can be one of b, k, m, or g. Minimum is 4M"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:582
msgid ""
"Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit "
"can be one of b, k, m, or g. Minimum is 4M."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:47
msgid "Mode"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:90
msgid "Mount Point"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:588
msgid "Mount tmpfs directory"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:335
msgid "Mount/Volume"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:287
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:411
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:83
msgid "Name"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:29
msgid "Name of the network that can be selected during container creation"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:386
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:520
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:132
msgid "Network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:77
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:28
msgid "Network Name"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:39
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:503
#: applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm:169
msgid "Networks"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:56
msgid "Networks overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:101
msgid "New"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:39
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm:54
msgid "New tag"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:612
msgid "Number of CPUs. Number is a fractional number. 0.000 means no limit"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:566
msgid "Number of CPUs. Number is a fractional number. 0.000 means no limit."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:73
msgid ""
"On this page all images are displayed that are available on the system and "
"with which a container can be created."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:200
msgid "On this page, the selected container can be managed."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:72
msgid "Options"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:37
msgid "Overlay network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:36
msgid "Overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:81
msgid "Parent Interface"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:54
msgid "Pass-through (Mirror physical device to single MAC VLAN)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:7
msgid "Path"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm:54
msgid "Please input new tag"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:62
msgid "Please input the PATH !"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:24
msgid "Please input the PATH and select the file !"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm:91
msgid "Plese input <docker create/run> command line:"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:347
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:135
msgid "Ports"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:52
msgid "Private (Prevent communication between MAC VLANs)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:489
msgid "Privileged"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:546
msgid "Publish container's port(s) to the host"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:100
msgid "Pull"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:83
msgid "Pull Image"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:48
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:32
msgid "Registry Mirrors"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:13
msgid "Remote Endpoint"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:18
msgid "Remote Host"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:27
msgid "Remote Port"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:272
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:229
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:208
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:111
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:108
msgid "Remove"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:43
#: applications/luci-app-dockerman/luasrc/view/dockerman/images_import.htm:82
msgid "Remove tag"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:140
msgid "RepoTags"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:454
msgid "Resolve CLI"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:4
msgid "Resources"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:227
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:202
msgid "Restart"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:326
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:419
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:495
msgid "Restart Policy"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:76
msgid "Restrict external access to the network"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/cbi/inlinevalue.htm:31
msgid "Reveal/hide password"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:551
msgid "Run command"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:226
msgid "Save"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:518
msgid "Set custom DNS servers"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:532
msgid "Set environment variables to inside the container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:55
msgid "Set the logging level"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:146
msgid "Size"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:66
msgid ""
"Specifies where the Docker daemon will listen for client connections "
"(default: unix:///var/run/docker.sock)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:218
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:193
msgid "Start"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:311
msgid "Start Time"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:780
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:781
#: applications/luci-app-dockerman/luasrc/view/dockerman/container.htm:5
msgid "Stats"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:300
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:128
msgid "Status"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:236
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:211
msgid "Stop"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/newcontainer_resolve.htm:91
msgid "Submit"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:83
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:91
msgid "Subnet"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:367
msgid "Sysctl"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:595
msgid "Sysctl(--sysctl)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:596
msgid "Sysctls (kernel parameters) options"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:783
msgid "TOP"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:468
msgid "TTY (-t)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm:56
msgid "TX/RX"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:564
msgid "The hostname to use for the container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:647
msgid "The logging configuration for this container"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:525
msgid ""
"The user that commands are run as inside the container.(format: name|uid[:"
"group|gid])"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/containers.lua:95
msgid ""
"This page displays all containers that have been created on the connected "
"docker host."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/networks.lua:52
msgid ""
"This page displays all docker networks that have been created on the "
"connected docker host."
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:359
msgid "Tmpfs"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:587
msgid "Tmpfs(--tmpfs)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/overview.lua:29
msgid "Total Memory"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:688
msgid "UID"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:289
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:328
msgid "Update"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:254
msgid "Upgrade"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:2
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:13
msgid "Upload"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:46
msgid "Upload Error"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_file.htm:43
msgid "Upload Success"
msgstr ""
#: applications/luci-app-dockerman/luasrc/view/dockerman/container_stats.htm:48
msgid "Upload/Download"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/container.lua:331
msgid "User"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newcontainer.lua:524
msgid "User(-u)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/newnetwork.lua:53
msgid "VEPA (Virtual Ethernet Port Aggregator)"
msgstr ""
#: applications/luci-app-dockerman/luasrc/controller/dockerman.lua:40
#: applications/luci-app-dockerman/luasrc/view/dockerman/overview.htm:187
msgid "Volumes"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/volumes.lua:73
msgid "Volumes overview"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/configuration.lua:58
msgid "Warning"
msgstr ""
#: applications/luci-app-dockerman/luasrc/model/cbi/dockerman/images.lua:125
msgid ""
"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."
msgstr ""
+2257
View File
File diff suppressed because it is too large Load Diff
+1933 -416
View File
File diff suppressed because it is too large Load Diff
+2257
View File
File diff suppressed because it is too large Load Diff
+1988 -477
View File
File diff suppressed because it is too large Load Diff
+1707 -369
View File
File diff suppressed because it is too large Load Diff
+1953 -431
View File
File diff suppressed because it is too large Load Diff
+2495
View File
File diff suppressed because it is too large Load Diff
+1692 -366
View File
File diff suppressed because it is too large Load Diff
+1676 -356
View File
File diff suppressed because it is too large Load Diff
+1699 -369
View File
File diff suppressed because it is too large Load Diff
+1901 -414
View File
File diff suppressed because it is too large Load Diff
+1720 -380
View File
File diff suppressed because it is too large Load Diff
+1822 -396
View File
File diff suppressed because it is too large Load Diff
+1841 -429
View File
File diff suppressed because it is too large Load Diff
+2010 -484
View File
File diff suppressed because it is too large Load Diff
+1669 -355
View File
File diff suppressed because it is too large Load Diff
+1687 -358
View File
File diff suppressed because it is too large Load Diff
+1706 -371
View File
File diff suppressed because it is too large Load Diff
+2469
View File
File diff suppressed because it is too large Load Diff
+1939 -431
View File
File diff suppressed because it is too large Load Diff
+1905 -418
View File
File diff suppressed because it is too large Load Diff
+1907 -421
View File
File diff suppressed because it is too large Load Diff
+1907 -421
View File
File diff suppressed because it is too large Load Diff
+2048 -501
View File
File diff suppressed because it is too large Load Diff
+1694 -369
View File
File diff suppressed because it is too large Load Diff
+1900 -414
View File
File diff suppressed because it is too large Load Diff
+2457
View File
File diff suppressed because it is too large Load Diff
+1669 -355
View File
File diff suppressed because it is too large Load Diff
+1902 -417
View File
File diff suppressed because it is too large Load Diff
+1955 -442
View File
File diff suppressed because it is too large Load Diff
+1992 -468
View File
File diff suppressed because it is too large Load Diff
+2254
View File
File diff suppressed because it is too large Load Diff
+1926 -429
View File
File diff suppressed because it is too large Load Diff
+2009 -523
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
{
"admin/docker": {
"title": "Docker",
"order": 41,
"action": {
"type": "firstchild",
"recurse": true
},
"depends": {
"acl": [ "luci-app-dockerman" ],
"fs": {
"/etc/init.d/dockerd": "executable",
"/usr/bin/dockerd": "executable"
},
"uci": { "dockerd": true }
}
},
"admin/docker/overview": {
"title": "Overview",
"order": 1,
"action": {
"type": "view",
"path": "dockerman/overview"
}
},
"admin/docker/configuration": {
"title": "Configuration",
"order": 2,
"action": {
"type": "view",
"path": "dockerman/configuration"
}
},
"admin/docker/container/archive/*": {
"action": {
"type": "alias",
"path": "admin/docker/containers"
}
},
"admin/docker/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/docker/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/docker/container/export/*": {
"action": {
"type": "function",
"module": "luci.controller.docker",
"function": "container_export"
},
"auth": {
"methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
"login": true
}
},
"admin/docker/container/*": {
"title_hide": "Container",
"action": {
"type": "view",
"path": "dockerman/container"
}
},
"admin/docker/containers": {
"title": "Containers",
"order": 3,
"action": {
"type": "view",
"path": "dockerman/containers"
}
},
"admin/docker/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/docker/container": {
"action": {
"type": "alias",
"path": "admin/docker/containers"
}
},
"admin/docker/container_new": {
"title_hide": "Container",
"action": {
"type": "view",
"path": "dockerman/container_new"
}
},
"admin/docker/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/docker/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/docker/images/get/*": {
"action": {
"type": "function",
"module": "luci.controller.docker",
"function": "image_get"
},
"auth": {
"methods": [ "cookie:sysauth_https", "cookie:sysauth_http" ],
"login": true
}
},
"admin/docker/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/docker/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/docker/images": {
"title": "Images",
"order": 4,
"action": {
"type": "view",
"path": "dockerman/images"
}
},
"admin/docker/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/docker/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/docker/image": {
"action": {
"type": "alias",
"path": "admin/docker/images"
}
},
"admin/docker/network/*": {
"title_hide": "Network",
"action": {
"type": "view",
"path": "dockerman/network"
}
},
"admin/docker/networks": {
"title": "Networks",
"order": 5,
"action": {
"type": "view",
"path": "dockerman/networks"
}
},
"admin/docker/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/docker/network_new": {
"title_hide": "Network",
"action": {
"type": "view",
"path": "dockerman/network_new"
}
},
"admin/docker/network": {
"action": {
"type": "alias",
"path": "admin/docker/networks"
}
},
"admin/docker/volumes": {
"title": "Volumes",
"order": 6,
"action": {
"type": "view",
"path": "dockerman/volumes"
}
},
"admin/docker/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/docker/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/docker/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
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env ucode
// Copyright 2025 Paul Donald / luci-lib-docker-js
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
'use strict';
import * as http from 'luci.http';
import * as fs from 'fs';
import * as socket from 'socket';
import { cursor } from 'uci';
import * as ds from 'luci.docker_socket';
// const cmdline = fs.readfile('/proc/self/cmdline');
// const args = split(cmdline, '\0');
const caller = trim(fs.readfile('/proc/self/comm'));
const BLOCKSIZE = 8192;
const POLL_TIMEOUT = 8000; // default; can be overridden per request
// const API_VER = '/v1.47';
const PROTOCOL = 'HTTP/1.1';
const CLIENT_VER = '1';
function merge(a, b) {
let c = {};
for (let k, v in a)
c[k] = v;
for (let k, v in b)
c[k] = v;
return c;
};
function chunked_body_reader(sock, initial_buffer) {
let state = 0, chunklen = 0, buffer = initial_buffer || '';
function poll_and_recv() {
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
if (!ready || !length(ready)) return null;
let data = sock.recv(BLOCKSIZE);
if (!data) return null;
buffer += data;
return true;
}
return () => {
while (true) {
if (state === 0) {
let m = match(buffer, /^([0-9a-fA-F]+)\r\n/);
if (!m || length(m) < 2) {
if (!poll_and_recv()) return null;
continue;
}
chunklen = int(m[1], 16);
buffer = substr(buffer, length(m[0]));
if (chunklen === 0) return null;
state = 1;
}
if (state === 1 && length(buffer) >= chunklen + 2) {
let chunk = substr(buffer, 0, chunklen);
buffer = substr(buffer, chunklen + 2);
state = 0;
return chunk;
} else {
if (!poll_and_recv()) return null;
continue;
}
}
};
};
function read_http_headers(response_headers, response) {
const lines = split(response, /\r?\n/);
for (let l in lines) {
let kv = match(l, /([^:]+):\s*(.*)/);
if (kv && length(kv) === 3)
response_headers[lc(kv[1])] = kv[2];
}
return response_headers;
};
function get_api_ver() {
const ctx = cursor();
const version = ctx.get('dockerd', 'globals', 'api_version') || '';
const version_str = version ? `/${version}` : '';
ctx.unload();
return version_str;
};
function coerce_values_to_string(obj) {
for (let k, v in obj) {
v = `${v}`;
obj[k]=v;
}
return obj;
};
function call_docker(method, path, options) {
options = options || {};
const headers = options.headers || {};
let payload = options.payload || null;
/* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g.
'127.0.0.1:2375'.
We use get_socket_dest_compat() which builds the SockAddress manually to
avoid this.
Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without
--tls* options.
A solution is a reverse proxy or ssh port forwarding to a remote host that
uses the unix socket, and you still connect to a 'local port', or socket:
ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client)
or (dropbear)
socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock
ssh -L 2375:localhost:12375 user@remote-host
*/
/* works on ucode 2025-12-01 */
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
/* works on ucode 2026-01-16 */
// const sock_dest = ds.get_socket_dest();
// const sock_addr = socket.sockaddr(sock_dest);
// const sock = socket.create(sock_addr.family, socket.SOCK_STREAM);
if (caller != 'rpcd') {
print('sock_dest:', sock_dest, '\n');
// print('sock_addr:', sock_addr, '\n');
}
if (!sock) {
return {
code: 500,
headers: {},
body: { message: "Failed to create socket" }
};
}
let conn_result = sock.connect(sock_dest);
let err_msg = `Failed to connect to docker host at ${sock_dest}`;
if (!conn_result) {
sock.close();
return {
code: 500,
headers: {},
body: { message: err_msg}
};
}
if (caller != 'rpcd')
print("query: ", options.query, '\n');
const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : '';
const url = path + query;
const req_headers = [
`${method} ${get_api_ver()}${url} ${PROTOCOL}`,
`Host: luci-host`,
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
`Connection: close`
];
if (payload) {
if (type(payload) === 'object') {
payload = sprintf('%J', payload);
headers['Content-Type'] = 'application/json';
}
headers['Content-Length'] = '' + length(payload);
}
for (let k, v in headers)
push(req_headers, `${k}: ${v}`);
push(req_headers, '', '');
if (caller != 'rpcd')
print(join('\r\n', req_headers), "\n");
sock.send(join('\r\n', req_headers));
if (payload) sock.send(payload);
const response_buff = sock.recv(BLOCKSIZE);
if (!response_buff || response_buff === '') {
sock.close();
return {
code: 500,
headers: {},
body: { message: "No response from Docker socket" }
};
}
const response_parts = split(response_buff, /\r?\n\r?\n/, 2);
const response_headers = read_http_headers({}, response_parts[0]);
let response_body;
let is_chunked = (response_headers['transfer-encoding'] === 'chunked');
let reader;
if (is_chunked) {
reader = chunked_body_reader(sock, response_parts[1]);
}
else if (response_headers['content-length']) {
let content_length = int(response_headers['content-length']);
let buf = response_parts[1];
reader = () => {
if (content_length <= 0) return null;
if (buf && length(buf)) {
let chunk = substr(buf, 0, content_length);
buf = substr(buf, length(chunk));
content_length -= length(chunk);
return chunk;
}
let data = sock.recv(min(BLOCKSIZE, content_length));
if (!data || data === '') return null;
content_length -= length(data);
return data;
};
}
else {
// Fallback for HTTP/1.0 or no content-length: read until close or timeout
reader = () => {
// Poll with 2 second timeout
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
if (!ready || !length(ready)) return null; // Timeout or error
let data = sock.recv(BLOCKSIZE);
if (!data || data === '') return null;
return data;
};
}
let chunks = [], chunk;
while ((chunk = reader())) {
push(chunks, chunk);
}
sock.close();
response_body = join('', chunks);
// Parse HTTP status code
let status_line = split(response_parts[0], /\r?\n/)[0];
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
let code = status_match ? int(status_match[1]) : 0;
// Docker events endpoint returns newline-delimited JSON, not a single JSON object
if (response_headers['content-type'] === 'application/json' && response_body) {
// Single JSON object
let data;
try { data = json(rtrim(response_body)); }
catch { data = null; }
// Check if this is newline-delimited JSON (multiple lines with JSON objects)
if (!data) {
// Parse each line as a separate JSON object
let lines = split(trim(response_body), /\n/);
let events = [];
for (let line in lines) {
line = trim(line);
if (line) {
try { push(events, json(line)); }
catch { /* skip invalid lines */ }
}
}
response_body = events;
} else {
response_body = data;
}
}
return {
code: code,
headers: response_headers,
body: response_body
};
};
function run_ttyd(request) {
const id = request.args.id || '';
const cmd = request.args.cmd || '/bin/sh';
const port = request.args.port || 7682;
const uid = request.args.uid || '';
if (!id) {
return { error: 'Container ID is required' };
}
let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`;
const sock_addr = ds.get_socket_dest();
/* Build the full command:
ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD
if the socket is /var/run/docker.sock, prefix unix://
Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls*
*/
const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr;
ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`;
if (uid && uid !== '') {
ttyd_cmd = `${ttyd_cmd} -u ${uid}`;
}
ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`;
// Try to kill any existing ttyd processes on this port
system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true');
// Start ttyd
system(ttyd_cmd);
return { status: 'ttyd started', command: ttyd_cmd };
}
// https://docs.docker.com/reference/api/engine/version/v1.47/
/* Note: methods here are included for structural reference. Some rpcd methods
are not suitable to be called from the GUI because they are streaming endpoints
or the operations in a busy dockerd cluster take a *long* time which causes
timeouts at the front end. Good examples of this are:
- /system/df
- push
- pull
- all /prune
We include them here because they can be useful from the command line.
*/
const core_methods = {
version: { call: () => call_docker('GET', '/version') },
info: { call: () => call_docker('GET', '/info') },
ping: { call: () => call_docker('GET', '/_ping') },
df: { call: () => call_docker('GET', '/system/df') },
events: { args: { query: { 'since': '', 'until': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) },
};
const exec_methods = {
start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) },
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) },
};
const container_methods = {
list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) },
create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) },
inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) },
top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) },
logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) },
changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) },
export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) },
stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) },
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) },
start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) },
stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) },
restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) },
kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) },
update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) },
rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) },
pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) },
unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) },
// attach
// attach websocket
// wait
remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) },
// archive info
info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
// archive get
get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
// archive extract
put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) },
exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.query }) },
// Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker
ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) },
};
const image_methods = {
list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request.args.query }) },
// build is long-running, and will likely cause time-out on the call. Function only here for reference.
build: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) },
build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) },
create: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) },
history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) },
push: { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) },
tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) },
remove: { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) },
search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) },
// create/commit
get: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) },
// get == export several
load: { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) },
};
const network_methods = {
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) },
inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) },
remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) },
create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) },
connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) },
disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) },
};
const volume_methods = {
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) },
create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) },
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) },
update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) },
remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) },
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.query }) },
};
const methods = {
'docker': core_methods,
'docker.container': container_methods,
'docker.exec': exec_methods,
'docker.image': image_methods,
'docker.network': network_methods,
'docker.volume': volume_methods,
};
// CLI test mode - check if script is run directly (not loaded by rpcd)
if (caller != 'rpcd') {
// Usage: ./docker_rpc.uc <object.method> <json-args>
// Example: ./docker_rpc.uc docker.network.list '{"query":{"filters":""}}'
// Example: ./docker_rpc.uc docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'
const scr_name = split(SCRIPT_NAME, '/')[-1] || 'docker_rpc.uc';
if (length(ARGV) < 1) {
print(`Usage: ${scr_name} <object.method> [json-args]\n`);
print("Available methods:\n");
for (let obj in methods) {
for (let name, info in methods[obj]) {
let sig = name;
if (info && info.args) {
try {
sig = `${sig} ${sprintf('\'%J\'', info.args)}`;
} catch {
sig = `${sig} <args>`;
}
}
print(` ${obj}.${sig}\n`);
}
}
print("\nExamples:\n");
print(` ${scr_name} docker.version\n`);
print(` ${scr_name} docker.network.list '{"query":{}}'\n`);
print(` ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`);
print(` ${scr_name} docker.container.list '{"query":{"all":true}}'\n`);
exit(1);
}
const method_path = split(ARGV[0], '.');
if (length(method_path) < 1) {
die(`Invalid method path: ${ARGV[0]}\n`);
}
// Build object path (e.g., "docker.network")
const obj_parts = slice(method_path, 0, -1);
const obj_name = join('.', obj_parts);
const method_name = method_path[length(method_path) - 1];
if (!methods[obj_name]) {
die(`Unknown object: ${obj_name}\n`);
}
if (!methods[obj_name][method_name]) {
die(`Unknown method: ${obj_name}.${method_name}\n`);
}
// Parse args if provided
let args = {};
if (length(ARGV) > 1) {
try {
args = json(ARGV[1]);
} catch (e) {
die(`Invalid JSON args: ${e}\n`);
}
}
// Call the method
const request = { args: args };
const result = methods[obj_name][method_name].call(request);
// Pretty print result
print(result, "\n");
exit(0);
};
return methods;
@@ -1,4 +0,0 @@
{
"config": "dockerd",
"init": "dockerd"
}
+393
View File
@@ -0,0 +1,393 @@
// Docker HTTP streaming endpoint
// Copyright 2025 Paul Donald <newtwen+github@gmail.com>
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
'use strict';
import { stdout } from 'fs';
import * as ds from 'luci.docker_socket';
import * as socket from 'socket';
import { cursor } from 'uci';
const BUFF_HEAD = 6; // 8000\r\n
const BUFF_TAIL = 2; // \r\n
const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776
// const API_VER = 'v1.47';
const PROTOCOL = 'HTTP/1.1';
const CLIENT_VER = '1';
let DockerController = {
// Handle file upload for chunked transfer
handle_file_upload: function(sock) {
let total_bytes = 0;
http.setfilehandler(function(meta, chunk, eof) {
if (meta.file && meta.name === 'upload-archive') {
if (chunk && length(chunk) > 0) {
let hex_size = sprintf('%x', length(chunk));
sock.send(hex_size + '\r\n');
sock.send(chunk);
sock.send('\r\n');
total_bytes += length(chunk);
}
if (eof) {
sock.send('0\r\n\r\n');
}
}
});
return total_bytes;
},
// Reusable header builder
build_headers: function(headers) {
let hdrs = [];
if (headers) {
for (let key in headers) {
if (headers[key] != null && headers[key] != '') {
push(hdrs, `${key}: ${headers[key]}`);
}
}
}
return length(hdrs) ? join('\r\n', hdrs) : '';
},
// Parse the initial HTTP response, split into parts and header lines, and store as properties
initial_response_parser: function(response_buff) {
let parts = split(response_buff, /\r?\n\r?\n/, 2);
let header_lines = split(parts[0], /\r?\n/);
let status_line = header_lines[0];
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
let code = status_match ? int(status_match[1]) : 500;
this.response_parts = parts;
this.header_lines = header_lines;
this.status_line = status_line;
this.status_match = status_match;
this.code = code;
},
// Stream the rest of the response in chunks from the socket
stream_response_chunks: function(sock, blocksize) {
let chunk;
while ((chunk = sock.recv(blocksize))) {
if (chunk && length(chunk)) {
this.debug('Streaming chunk:', substr(chunk, 0, 10));
stdout.write(chunk);
}
}
},
// Send a 200 OK response with headers and body
/* Write CGI response directly to stdout bypassing http.uc
The minimum to trigger a valid response via CGI is typically
Status: \r\n
The Docker response contains the \r\n after its headers, and the browser can
handle the chunked encoding fine, so we just forward its output verbatim.
Docker emits a x-docker-container-path-stat header with some meta-data for
the path, which we forward. uhttpd seems to coalesce headers, and inject its
own, so we occasionally have two Connection: headers.
*/
send_initial_200_response: function(headers, body) {
stdout.write('Status: 200 OK\r\n');
if (headers && type(headers) == 'array') {
stdout.write(join('', headers));
}
if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) {
stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n')));
}
},
// Debug output if &debug=... is present
debug: function(...args) {
let dbg = http.formvalue('debug');
let tostr = function(x) { return `${x}`; };
if (dbg != null && dbg != '') {
http.prepare_content('application/json');
http.write_json({msg: join(' ', map(args, tostr)) + '\n' });
}
},
// Generic error response helper
error_response: function(code, msg, detail) {
http.status(code ?? 500, msg ?? 'Internal Error');
http.prepare_content('application/json');
let out = { error: msg ?? 'Internal Error' };
if (detail)
out.detail = detail;
http.write_json(out);
},
get_api_ver: function() {
let ctx = cursor();
let version = ctx.get('dockerd', 'globals', 'api_version') || '';
ctx.unload();
return version ? `/${version}` : '';
},
join_args_array: function(args) {
return (type(args) == "array") ? join('/', args) : args;
},
require_param: function(name) {
let val = http.formvalue(name);
if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` });
return val;
},
// Reusable query string builder
build_query_str: function(query_params, skip_keys) {
let query_str = '';
if (query_params) {
let parts = [];
for (let key in query_params) {
if (skip_keys && (key in skip_keys))
continue;
let val = query_params[key];
if (val == null || val == '') continue;
if (type(val) === 'array') {
for (let v in val) {
push(parts, `${key}=${v}`);
}
} else {
push(parts, `${key}=${val}`);
}
}
if (length(parts))
query_str = '?' + join('&', parts);
}
return query_str;
},
get_archive: function(docker_path, id, docker_function, query_params, archive_name) {
this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name);
id = this.join_args_array(id);
let id_param = '';
if (id) id_param = `/${id}`;
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`;
let url = `${docker_path}${id_param}${docker_function}${query_str}`;
let req = [
`GET ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
`Connection: close`,
``,
``
];
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
let filename = length(id) >= 64 ? substr(id, 0, 12) : id;
if (!filename) filename = 'multi';
let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`];
this.send_initial_200_response(include_headers, response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) {
this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile);
const sock_dest = ds.get_socket_dest_compat();
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
this.debug('Socket created:', !!sock);
if (!sock) {
this.debug('Socket creation failed');
this.error_response(500, 'Failed to create socket');
return;
}
if (!sock.connect(sock_dest)) {
this.debug('Socket connect failed');
sock.close();
this.error_response(503, 'Failed to connect to Docker daemon');
return;
}
let skip_keys = {
token: true,
'X-Registry-Auth': true,
'upload-name': true,
'upload-archive': true,
'upload-path': true,
};
let remote = false;
if (query_params && type(query_params) === 'object' &&
query_params['remote'] != null && query_params['remote'] != '')
remote = true;
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`;
let hdr_str = this.build_headers(headers);
let url = `${docker_path}${docker_function}${query_str}`;
let req = [
`${method} ${this.get_api_ver()}${url} ${PROTOCOL}`,
`Host: openwrt-docker-ui`,
`User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`,
`Connection: close`,
];
if (hdr_str)
push(req, hdr_str);
if (haveFile) {
push(req, 'Content-Type: application/x-tar');
push(req, 'Transfer-Encoding: chunked');
}
push(req, '');
push(req, '');
this.debug('Sending request:', req);
sock.send(join('\r\n', req));
if (haveFile)
this.handle_file_upload(sock);
else
sock.send('\r\n\r\n');
let response_buff = sock.recv(BLOCKSIZE);
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
if (!response_buff || response_buff == '') {
this.debug('No response from Docker daemon');
sock.close();
this.error_response(500, 'No response from Docker daemon');
return;
}
this.initial_response_parser(response_buff);
if (this.code != 200) {
this.debug('Docker error status:', this.code, this.status_line);
sock.close();
this.error_response(this.code, 'Docker Error', this.status_line);
return;
}
this.send_initial_200_response('', response_buff);
this.stream_response_chunks(sock, BLOCKSIZE);
sock.close();
return;
},
// Handler methods
container_get_archive: function(id) {
this.require_param('path');
this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar');
},
container_export: function(id) {
this.get_archive('/containers', id, '/export', null, 'container_export.tar');
},
containers_prune: function() {
this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false);
},
container_put_archive: function(id) {
this.require_param('path');
this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true);
},
docker_events: function() {
this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false);
},
image_build: function(...args) {
let remote = http.formvalue('remote');
this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote);
},
image_build_prune: function() {
this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_create: function() {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false);
},
image_get: function(...args) {
this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar');
},
image_load: function() {
this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true);
},
images_prune: function() {
this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false);
},
image_push: function(...args) {
let headers = {
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
};
this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false);
},
networks_prune: function() {
this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false);
},
volumes_prune: function() {
this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false);
},
};
// Export all handlers with automatic error wrapping
let controller = DockerController;
let exports = {};
for (let k, v in controller) {
if (type(v) == 'function')
exports[k] = v;
}
return exports;
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2025 Paul Donald / luci-lib-docker-js
// Licensed to the public under the Apache License 2.0.
// Built against the docker v1.47 API
import { cursor } from 'uci';
import * as socket from 'socket';
/**
* Get the Docker socket path from uci config, more backwards compatible.
*/
export function get_socket_dest_compat() {
const ctx = cursor();
let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
ctx.unload();
sock_entry = lc(sock_entry);
/* start ucode 2025-12-01 compatibility */
let sock_split, addr = sock_entry, proto, proto_num, port = 0;
let family;
if (index(sock_entry, '://') != -1) {
let sock_split = split(lc(sock_entry), '://', 2);
addr = sock_split?.[1];
proto = sock_split?.[0];
}
if (index(addr, '/') != -1) {
// we got '/var/run/docker.sock' format
return socket.sockaddr(addr);
}
if (proto === 'tcp' || proto === 'udp' || proto === 'inet') {
family = socket.AF_INET;
if (proto === 'tcp')
proto_num = socket.IPPROTO_TCP;
else if (proto === 'udp')
proto_num = socket.IPPROTO_UDP;
}
else if (proto === 'tcp6' || proto === 'udp6' || proto === 'inet6') {
family = socket.AF_INET6;
if (proto === 'tcp6')
proto_num = socket.IPPROTO_TCP;
else if (proto === 'udp6')
proto_num = socket.IPPROTO_UDP;
}
else if (proto === 'unix')
family = socket.AF_UNIX;
else {
family = socket.AF_INET; // ipv4
proto_num = socket.IPPROTO_TCP; // tcp
}
let host = addr;
const l_bracket = index(host, '[');
const r_bracket = rindex(host, ']');
if (l_bracket != -1 && r_bracket != -1) {
host = substr(host, l_bracket + 1, r_bracket - 1);
family = socket.AF_INET6;
}
// find port based on addr, otherwise we find ':' in IPv6
const port_index = rindex(addr, ':');
if (port_index != -1) {
port = int(substr(addr, port_index + 1)) || 0;
host = substr(host, 0, port_index);
}
const sock = socket.addrinfo(host, port, {protocol: proto_num});
return socket.sockaddr(sock[0].addr);
// return {family: family, address: host, port: port};
};
/**
* Get the Docker socket path from uci config
*/
export function get_socket_dest() {
const ctx = cursor();
let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
sock_entry = lc(sock_entry);
let sock_addr = split(sock_entry, '://', 2)?.[1] ?? sock_entry;
ctx.unload();
return sock_addr;
};