Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9e0285618 | |||
| a081876f33 | |||
| f60e6d8659 | |||
| 63012460bc | |||
| da5e1d46e8 | |||
| 70c4701456 | |||
| e37ca2bd46 | |||
| 94c6ef5266 | |||
| ff6723c59a | |||
| 24edcc374f | |||
| 9392d0e1e7 | |||
| 53e46a6469 | |||
| 789e328d11 | |||
| 63ba560ec7 | |||
| df41465d5f | |||
| 8f32cdf595 | |||
| fb4454678a |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
});
|
||||
@@ -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(" "," ")
|
||||
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
|
||||
@@ -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(" "," ")
|
||||
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
|
||||
@@ -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(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table, container_list, translate("Containers overview"))
|
||||
s.addremove = false
|
||||
s.sectionhead = translate("Containers")
|
||||
s.sortable = false
|
||||
s.template = "cbi/tblsection"
|
||||
s.extedit = luci.dispatcher.build_url("admin", "docker", "container","%s")
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write=function(self, section, value)
|
||||
container_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
o.width="10%"
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Container Name"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_status", translate("Status"))
|
||||
o.width="15%"
|
||||
o.rawhtml=true
|
||||
|
||||
o = s:option(DummyValue, "_network", translate("Network"))
|
||||
o.width="15%"
|
||||
|
||||
o = s:option(DummyValue, "_ports", translate("Ports"))
|
||||
o.width="10%"
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_image", translate("Image"))
|
||||
o.width="10%"
|
||||
|
||||
o = s:option(DummyValue, "_command", translate("Command"))
|
||||
o.width="20%"
|
||||
|
||||
local start_stop_remove = function(m,cmd)
|
||||
local container_selected = {}
|
||||
|
||||
for k in pairs(container_list) do
|
||||
if container_list[k]._selected == 1 then
|
||||
container_selected[#container_selected + 1] = container_list[k]._name
|
||||
end
|
||||
end
|
||||
|
||||
if #container_selected > 0 then
|
||||
local success = true
|
||||
|
||||
docker:clear_status()
|
||||
for _, cont in ipairs(container_selected) do
|
||||
docker:append_status("Containers: " .. cmd .. " " .. cont .. "...")
|
||||
local res = dk.containers[cmd](dk, {id = cont})
|
||||
if res and res.code >= 300 then
|
||||
success = false
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/containers"))
|
||||
end
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "_new")
|
||||
o.inputtitle= translate("Add")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "add"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newcontainer"))
|
||||
end
|
||||
|
||||
o = s:option(Button, "_start")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Start")
|
||||
o.inputstyle = "apply"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"start")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_restart")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Restart")
|
||||
o.inputstyle = "reload"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"restart")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_stop")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Stop")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"stop")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_kill")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Kill")
|
||||
o.inputstyle = "reset"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"kill")
|
||||
end
|
||||
|
||||
o = s:option(Button, "_remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputtitle=translate("Remove")
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
start_stop_remove(m,"remove")
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,280 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
local dk = docker.new()
|
||||
|
||||
local containers, images, res
|
||||
local m, s, o
|
||||
|
||||
res = dk.images:list()
|
||||
if res.code < 300 then
|
||||
images = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
})
|
||||
if res.code < 300 then
|
||||
containers = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
function get_images()
|
||||
local data = {}
|
||||
|
||||
for i, v in ipairs(images) do
|
||||
local index = v.Created .. v.Id
|
||||
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["id"] = v.Id:sub(8)
|
||||
data[index]["_id"] = '<a href="javascript:new_tag(\''..v.Id:sub(8,20)..'\')" class="dockerman-link" title="'..translate("New tag")..'">' .. v.Id:sub(8,20) .. '</a>'
|
||||
|
||||
if v.RepoTags and next(v.RepoTags)~=nil then
|
||||
for i, v1 in ipairs(v.RepoTags) do
|
||||
data[index]["_tags"] =(data[index]["_tags"] and ( data[index]["_tags"] .. "<br />" )or "") .. ((v1:match("<none>") or (#v.RepoTags == 1)) and v1 or ('<a href="javascript:un_tag(\''..v1..'\')" class="dockerman_link" title="'..translate("Remove tag")..'" >' .. v1 .. '</a>'))
|
||||
|
||||
if not data[index]["tag"] then
|
||||
data[index]["tag"] = v1
|
||||
end
|
||||
end
|
||||
else
|
||||
data[index]["_tags"] = v.RepoDigests[1] and v.RepoDigests[1]:match("^(.-)@.+")
|
||||
data[index]["_tags"] = (data[index]["_tags"] and data[index]["_tags"] or "<none>" ).. ":<none>"
|
||||
end
|
||||
|
||||
data[index]["_tags"] = data[index]["_tags"]:gsub("<none>","<none>")
|
||||
for ci,cv in ipairs(containers) do
|
||||
if v.Id == cv.ImageID then
|
||||
data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
|
||||
'<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2).."</a>"
|
||||
end
|
||||
end
|
||||
|
||||
data[index]["_size"] = string.format("%.2f", tostring(v.Size/1024/1024)).."MB"
|
||||
data[index]["_created"] = os.date("%Y/%m/%d %H:%M:%S",v.Created)
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local image_list = get_images()
|
||||
|
||||
m = SimpleForm("docker",
|
||||
translate("Docker - Images"),
|
||||
translate("On this page all images are displayed that are available on the system and with which a container can be created."))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
local pull_value={
|
||||
_image_tag_name="",
|
||||
_registry="index.docker.io"
|
||||
}
|
||||
|
||||
s = m:section(SimpleSection,
|
||||
translate("Pull Image"),
|
||||
translate("By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry."))
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Value, "_image_tag_name")
|
||||
o.template = "dockerman/cbi/inlinevalue"
|
||||
o.placeholder="lisaac/luci:latest"
|
||||
o.write = function(self, section, value)
|
||||
local hastag = value:find(":")
|
||||
|
||||
if not hastag then
|
||||
value = value .. ":latest"
|
||||
end
|
||||
pull_value["_image_tag_name"] = value
|
||||
end
|
||||
|
||||
o = s:option(Button, "_pull")
|
||||
o.inputtitle= translate("Pull")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "add"
|
||||
o.write = function(self, section)
|
||||
local tag = pull_value["_image_tag_name"]
|
||||
local json_stringify = luci.jsonc and luci.jsonc.stringify
|
||||
|
||||
if tag and tag ~= "" then
|
||||
docker:write_status("Images: " .. "pulling" .. " " .. tag .. "...\n")
|
||||
local res = dk.images:create({query = {fromImage=tag}}, docker.pull_image_show_status_cb)
|
||||
|
||||
if res and res.code == 200 and (res.body[#res.body] and not res.body[#res.body].error and res.body[#res.body].status and (res.body[#res.body].status == "Status: Downloaded newer image for ".. tag)) then
|
||||
docker:clear_status()
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body[#res.body] and res.body[#res.body].error or (res.body.message or res.message)).. "\n")
|
||||
end
|
||||
else
|
||||
docker:append_status("code: 400 please input the name of image name!")
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection,
|
||||
translate("Import Image"),
|
||||
translate("When pressing the Import button, both a local image can be loaded onto the system and a valid image tar can be downloaded from remote."))
|
||||
|
||||
o = s:option(DummyValue, "_image_import")
|
||||
o.template = "dockerman/images_import"
|
||||
|
||||
s = m:section(Table, image_list, translate("Images overview"))
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write = function(self, section, value)
|
||||
image_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_tags", translate("RepoTags"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_containers", translate("Containers"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_size", translate("Size"))
|
||||
|
||||
o = s:option(DummyValue, "_created", translate("Created"))
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
o.rawhtml = true
|
||||
|
||||
local remove_action = function(force)
|
||||
local image_selected = {}
|
||||
|
||||
for k in pairs(image_list) do
|
||||
if image_list[k]._selected == 1 then
|
||||
image_selected[#image_selected+1] = (image_list[k]["_tags"]:match("<br />") or image_list[k]["_tags"]:match("<none>")) and image_list[k].id or image_list[k].tag
|
||||
end
|
||||
end
|
||||
|
||||
if next(image_selected) ~= nil then
|
||||
local success = true
|
||||
|
||||
docker:clear_status()
|
||||
for _, img in ipairs(image_selected) do
|
||||
local query
|
||||
docker:append_status("Images: " .. "remove" .. " " .. img .. "...")
|
||||
|
||||
if force then
|
||||
query = {force = true}
|
||||
end
|
||||
|
||||
local msg = dk.images:remove({
|
||||
id = img,
|
||||
query = query
|
||||
})
|
||||
if msg.code ~= 200 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/images"))
|
||||
end
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err = docker:read_status()
|
||||
s.err = s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
remove_action()
|
||||
end
|
||||
|
||||
o = s:option(Button, "forceremove")
|
||||
o.inputtitle= translate("Force Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
remove_action(true)
|
||||
end
|
||||
|
||||
o = s:option(Button, "save")
|
||||
o.inputtitle= translate("Save")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "edit"
|
||||
o.forcewrite = true
|
||||
o.write = function (self, section)
|
||||
local image_selected = {}
|
||||
|
||||
for k in pairs(image_list) do
|
||||
if image_list[k]._selected == 1 then
|
||||
image_selected[#image_selected + 1] = image_list[k].id
|
||||
end
|
||||
end
|
||||
|
||||
if next(image_selected) ~= nil then
|
||||
local names, first
|
||||
|
||||
for _, img in ipairs(image_selected) do
|
||||
names = names and (names .. "&names=".. img) or img
|
||||
end
|
||||
|
||||
local cb = function(res, chunk)
|
||||
if res.code == 200 then
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.header('Content-Disposition', 'inline; filename="images.tar"')
|
||||
luci.http.header('Content-Type', 'application\/x-tar')
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
else
|
||||
if not first then
|
||||
first = true
|
||||
luci.http.prepare_content("text/plain")
|
||||
end
|
||||
luci.ltn12.pump.all(chunk, luci.http.write)
|
||||
end
|
||||
end
|
||||
|
||||
docker:write_status("Images: " .. "save" .. " " .. table.concat(image_selected, "\n") .. "...")
|
||||
local msg = dk.images:get({query = {names = names}}, cb)
|
||||
|
||||
if msg.code ~= 200 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:clear_status()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Button, "load")
|
||||
o.inputtitle= translate("Load")
|
||||
o.template = "dockerman/images_load"
|
||||
o.inputstyle = "add"
|
||||
|
||||
return m
|
||||
@@ -1,154 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
local networks, dk, res
|
||||
|
||||
dk = docker.new()
|
||||
res = dk.networks:list()
|
||||
if res.code < 300 then
|
||||
networks = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local get_networks = function ()
|
||||
local data = {}
|
||||
|
||||
if type(networks) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i, v in ipairs(networks) do
|
||||
local index = v.Created .. v.Id
|
||||
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["_id"] = v.Id:sub(1,12)
|
||||
data[index]["_name"] = v.Name
|
||||
data[index]["_driver"] = v.Driver
|
||||
|
||||
if v.Driver == "bridge" then
|
||||
data[index]["_interface"] = v.Options["com.docker.network.bridge.name"]
|
||||
elseif v.Driver == "macvlan" then
|
||||
data[index]["_interface"] = v.Options.parent
|
||||
end
|
||||
|
||||
data[index]["_subnet"] = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Subnet or nil
|
||||
data[index]["_gateway"] = v.IPAM and v.IPAM.Config and v.IPAM.Config[1] and v.IPAM.Config[1].Gateway or nil
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
local network_list = get_networks()
|
||||
|
||||
m = SimpleForm("docker",
|
||||
translate("Docker - Networks"),
|
||||
translate("This page displays all docker networks that have been created on the connected docker host."))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
s = m:section(Table, network_list, translate("Networks overview"))
|
||||
s.nodescr=true
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.template = "dockerman/cbi/xfvalue"
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.render = function(self, section, scope)
|
||||
self.disable = 0
|
||||
if network_list[section]["_name"] == "bridge" or network_list[section]["_name"] == "none" or network_list[section]["_name"] == "host" then
|
||||
self.disable = 1
|
||||
end
|
||||
Flag.render(self, section, scope)
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
network_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_id", translate("ID"))
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Network Name"))
|
||||
|
||||
o = s:option(DummyValue, "_driver", translate("Driver"))
|
||||
|
||||
o = s:option(DummyValue, "_interface", translate("Parent Interface"))
|
||||
|
||||
o = s:option(DummyValue, "_subnet", translate("Subnet"))
|
||||
|
||||
o = s:option(DummyValue, "_gateway", translate("Gateway"))
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err = docker:read_status()
|
||||
s.err = s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "_new")
|
||||
o.inputtitle= translate("New")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.notitle=true
|
||||
o.inputstyle = "add"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
|
||||
end
|
||||
|
||||
o = s:option(Button, "_remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
local network_selected = {}
|
||||
local network_name_selected = {}
|
||||
local network_driver_selected = {}
|
||||
|
||||
for k in pairs(network_list) do
|
||||
if network_list[k]._selected == 1 then
|
||||
network_selected[#network_selected + 1] = network_list[k]._id
|
||||
network_name_selected[#network_name_selected + 1] = network_list[k]._name
|
||||
network_driver_selected[#network_driver_selected + 1] = network_list[k]._driver
|
||||
end
|
||||
end
|
||||
|
||||
if next(network_selected) ~= nil then
|
||||
local success = true
|
||||
docker:clear_status()
|
||||
|
||||
for ii, net in ipairs(network_selected) do
|
||||
docker:append_status("Networks: " .. "remove" .. " " .. net .. "...")
|
||||
local res = dk.networks["remove"](dk, {id = net})
|
||||
|
||||
if res and res.code >= 300 then
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
if network_driver_selected[ii] == "macvlan" then
|
||||
docker.remove_macvlan_interface(network_name_selected[ii])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,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(" "," ")
|
||||
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
|
||||
@@ -1,246 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
|
||||
local m, s, o
|
||||
|
||||
local dk = docker.new()
|
||||
|
||||
m = SimpleForm("docker", translate("Docker - Network"))
|
||||
m.redirect = luci.dispatcher.build_url("admin", "docker", "networks")
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(SimpleSection, translate("Create new docker network"))
|
||||
s.addremove = true
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Value, "name",
|
||||
translate("Network Name"),
|
||||
translate("Name of the network that can be selected during container creation"))
|
||||
o.rmempty = true
|
||||
|
||||
o = s:option(ListValue, "driver", translate("Driver"))
|
||||
o.rmempty = true
|
||||
o:value("bridge", translate("Bridge device"))
|
||||
o:value("macvlan", translate("MAC VLAN"))
|
||||
o:value("ipvlan", translate("IP VLAN"))
|
||||
o:value("overlay", translate("Overlay network"))
|
||||
|
||||
o = s:option(Value, "parent", translate("Base device"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "macvlan")
|
||||
local interfaces = luci.sys and luci.sys.net and luci.sys.net.devices() or {}
|
||||
for _, v in ipairs(interfaces) do
|
||||
o:value(v, v)
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "macvlan_mode", translate("Mode"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "macvlan")
|
||||
o.default="bridge"
|
||||
o:value("bridge", translate("Bridge (Support direct communication between MAC VLANs)"))
|
||||
o:value("private", translate("Private (Prevent communication between MAC VLANs)"))
|
||||
o:value("vepa", translate("VEPA (Virtual Ethernet Port Aggregator)"))
|
||||
o:value("passthru", translate("Pass-through (Mirror physical device to single MAC VLAN)"))
|
||||
|
||||
o = s:option(ListValue, "ipvlan_mode", translate("Ipvlan Mode"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "ipvlan")
|
||||
o.default="l3"
|
||||
o:value("l2", translate("L2 bridge"))
|
||||
o:value("l3", translate("L3 bridge"))
|
||||
|
||||
o = s:option(Flag, "ingress",
|
||||
translate("Ingress"),
|
||||
translate("Ingress network is the network which provides the routing-mesh in swarm mode"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o:depends("driver", "overlay")
|
||||
|
||||
o = s:option(DynamicList, "options", translate("Options"))
|
||||
o.rmempty = true
|
||||
o.placeholder="com.docker.network.driver.mtu=1500"
|
||||
|
||||
o = s:option(Flag, "internal", translate("Internal"), translate("Restrict external access to the network"))
|
||||
o.rmempty = true
|
||||
o:depends("driver", "overlay")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
|
||||
if nixio.fs.access("/etc/config/network") and nixio.fs.access("/etc/config/firewall")then
|
||||
o = s:option(Flag, "op_macvlan", translate("Create macvlan interface"), translate("Auto create macvlan interface in Openwrt"))
|
||||
o:depends("driver", "macvlan")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 1
|
||||
end
|
||||
|
||||
o = s:option(Value, "subnet", translate("Subnet"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.0.0/16"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(Value, "gateway", translate("Gateway"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.1.1"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(Value, "ip_range", translate("IP range"))
|
||||
o.rmempty = true
|
||||
o.placeholder="10.1.1.0/24"
|
||||
o.datatype="ip4addr"
|
||||
|
||||
o = s:option(DynamicList, "aux_address", translate("Exclude IPs"))
|
||||
o.rmempty = true
|
||||
o.placeholder="my-route=10.1.1.1"
|
||||
|
||||
o = s:option(Flag, "ipv6", translate("Enable IPv6"))
|
||||
o.rmempty = true
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Value, "subnet6", translate("IPv6 Subnet"))
|
||||
o.rmempty = true
|
||||
o.placeholder="fe80::/10"
|
||||
o.datatype="ip6addr"
|
||||
o:depends("ipv6", 1)
|
||||
|
||||
o = s:option(Value, "gateway6", translate("IPv6 Gateway"))
|
||||
o.rmempty = true
|
||||
o.placeholder="fe80::1"
|
||||
o.datatype="ip6addr"
|
||||
o:depends("ipv6", 1)
|
||||
|
||||
m.handle = function(self, state, data)
|
||||
if state == FORM_VALID then
|
||||
local name = data.name
|
||||
local driver = data.driver
|
||||
|
||||
local internal = data.internal == 1 and true or false
|
||||
|
||||
local subnet = data.subnet
|
||||
local gateway = data.gateway
|
||||
local ip_range = data.ip_range
|
||||
|
||||
local aux_address = {}
|
||||
local tmp = data.aux_address or {}
|
||||
for i,v in ipairs(tmp) do
|
||||
_,_,k1,v1 = v:find("(.-)=(.+)")
|
||||
aux_address[k1] = v1
|
||||
end
|
||||
|
||||
local options = {}
|
||||
tmp = data.options or {}
|
||||
for i,v in ipairs(tmp) do
|
||||
_,_,k1,v1 = v:find("(.-)=(.+)")
|
||||
options[k1] = v1
|
||||
end
|
||||
|
||||
local ipv6 = data.ipv6 == 1 and true or false
|
||||
|
||||
local create_body = {
|
||||
Name = name,
|
||||
Driver = driver,
|
||||
EnableIPv6 = ipv6,
|
||||
IPAM = {
|
||||
Driver= "default"
|
||||
},
|
||||
Internal = internal
|
||||
}
|
||||
|
||||
if subnet or gateway or ip_range then
|
||||
create_body["IPAM"]["Config"] = {
|
||||
{
|
||||
Subnet = subnet,
|
||||
Gateway = gateway,
|
||||
IPRange = ip_range,
|
||||
AuxAddress = aux_address,
|
||||
AuxiliaryAddresses = aux_address
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
if driver == "macvlan" then
|
||||
create_body["Options"] = {
|
||||
macvlan_mode = data.macvlan_mode,
|
||||
parent = data.parent
|
||||
}
|
||||
elseif driver == "ipvlan" then
|
||||
create_body["Options"] = {
|
||||
ipvlan_mode = data.ipvlan_mode
|
||||
}
|
||||
elseif driver == "overlay" then
|
||||
create_body["Ingress"] = data.ingerss == 1 and true or false
|
||||
end
|
||||
|
||||
if ipv6 and data.subnet6 and data.subnet6 then
|
||||
if type(create_body["IPAM"]["Config"]) ~= "table" then
|
||||
create_body["IPAM"]["Config"] = {}
|
||||
end
|
||||
local index = #create_body["IPAM"]["Config"]
|
||||
create_body["IPAM"]["Config"][index+1] = {
|
||||
Subnet = data.subnet6,
|
||||
Gateway = data.gateway6
|
||||
}
|
||||
end
|
||||
|
||||
if next(options) ~= nil then
|
||||
create_body["Options"] = create_body["Options"] or {}
|
||||
for k, v in pairs(options) do
|
||||
create_body["Options"][k] = v
|
||||
end
|
||||
end
|
||||
|
||||
create_body = docker.clear_empty_tables(create_body)
|
||||
docker:write_status("Network: " .. "create" .. " " .. create_body.Name .. "...")
|
||||
|
||||
local res = dk.networks:create({
|
||||
body = create_body
|
||||
})
|
||||
|
||||
if res and res.code == 201 then
|
||||
docker:write_status("Network: " .. "create macvlan interface...")
|
||||
res = dk.networks:inspect({
|
||||
name = create_body.Name
|
||||
})
|
||||
|
||||
if driver == "macvlan" and
|
||||
data.op_macvlan ~= 0 and
|
||||
res.code == 200 and
|
||||
res.body and
|
||||
res.body.IPAM and
|
||||
res.body.IPAM.Config and
|
||||
res.body.IPAM.Config[1] and
|
||||
res.body.IPAM.Config[1].Gateway and
|
||||
res.body.IPAM.Config[1].Subnet then
|
||||
|
||||
docker.create_macvlan_interface(data.name,
|
||||
data.parent,
|
||||
res.body.IPAM.Config[1].Gateway,
|
||||
res.body.IPAM.Config[1].Subnet)
|
||||
end
|
||||
|
||||
docker:clear_status()
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/networks"))
|
||||
else
|
||||
docker:append_status("code:" .. res.code.." ".. (res.body.message and res.body.message or res.message).. "\n")
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/newnetwork"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,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(" "," ")
|
||||
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
|
||||
@@ -1,142 +0,0 @@
|
||||
--[[
|
||||
LuCI - Lua Configuration Interface
|
||||
Copyright 2019 lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
]]--
|
||||
|
||||
local docker = require "luci.model.docker"
|
||||
local dk = docker.new()
|
||||
|
||||
local m, s, o
|
||||
|
||||
local res, containers, volumes
|
||||
|
||||
function get_volumes()
|
||||
local data = {}
|
||||
for i, v in ipairs(volumes) do
|
||||
local index = v.Name
|
||||
data[index]={}
|
||||
data[index]["_selected"] = 0
|
||||
data[index]["_nameraw"] = v.Name
|
||||
data[index]["_name"] = v.Name:sub(1,12)
|
||||
|
||||
for ci,cv in ipairs(containers) do
|
||||
if cv.Mounts and type(cv.Mounts) ~= "table" then
|
||||
break
|
||||
end
|
||||
for vi, vv in ipairs(cv.Mounts) do
|
||||
if v.Name == vv.Name then
|
||||
data[index]["_containers"] = (data[index]["_containers"] and (data[index]["_containers"] .. " | ") or "")..
|
||||
'<a href='..luci.dispatcher.build_url("admin/docker/container/"..cv.Id)..' class="dockerman_link" title="'..translate("Container detail")..'">'.. cv.Names[1]:sub(2)..'</a>'
|
||||
end
|
||||
end
|
||||
end
|
||||
data[index]["_driver"] = v.Driver
|
||||
data[index]["_mountpoint"] = nil
|
||||
|
||||
for v1 in v.Mountpoint:gmatch('[^/]+') do
|
||||
if v1 == index then
|
||||
data[index]["_mountpoint"] = data[index]["_mountpoint"] .."/" .. v1:sub(1,12) .. "..."
|
||||
else
|
||||
data[index]["_mountpoint"] = (data[index]["_mountpoint"] and data[index]["_mountpoint"] or "").."/".. v1
|
||||
end
|
||||
end
|
||||
data[index]["_created"] = v.CreatedAt
|
||||
end
|
||||
|
||||
return data
|
||||
end
|
||||
|
||||
res = dk.volumes:list()
|
||||
if res.code <300 then
|
||||
volumes = res.body.Volumes
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
res = dk.containers:list({
|
||||
query = {
|
||||
all=true
|
||||
}
|
||||
})
|
||||
if res.code <300 then
|
||||
containers = res.body
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
local volume_list = get_volumes()
|
||||
|
||||
m = SimpleForm("docker", translate("Docker - Volumes"))
|
||||
m.submit=false
|
||||
m.reset=false
|
||||
|
||||
s = m:section(Table, volume_list, translate("Volumes overview"))
|
||||
|
||||
o = s:option(Flag, "_selected","")
|
||||
o.disabled = 0
|
||||
o.enabled = 1
|
||||
o.default = 0
|
||||
o.write = function(self, section, value)
|
||||
volume_list[section]._selected = value
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_name", translate("Name"))
|
||||
|
||||
o = s:option(DummyValue, "_driver", translate("Driver"))
|
||||
|
||||
o = s:option(DummyValue, "_containers", translate("Containers"))
|
||||
o.rawhtml = true
|
||||
|
||||
o = s:option(DummyValue, "_mountpoint", translate("Mount Point"))
|
||||
|
||||
o = s:option(DummyValue, "_created", translate("Created"))
|
||||
|
||||
s = m:section(SimpleSection)
|
||||
s.template = "dockerman/apply_widget"
|
||||
s.err=docker:read_status()
|
||||
s.err=s.err and s.err:gsub("\n","<br />"):gsub(" "," ")
|
||||
if s.err then
|
||||
docker:clear_status()
|
||||
end
|
||||
|
||||
s = m:section(Table,{{}})
|
||||
s.notitle=true
|
||||
s.rowcolors=false
|
||||
s.template="cbi/nullsection"
|
||||
|
||||
o = s:option(Button, "remove")
|
||||
o.inputtitle= translate("Remove")
|
||||
o.template = "dockerman/cbi/inlinebutton"
|
||||
o.inputstyle = "remove"
|
||||
o.forcewrite = true
|
||||
o.write = function(self, section)
|
||||
local volume_selected = {}
|
||||
|
||||
for k in pairs(volume_list) do
|
||||
if volume_list[k]._selected == 1 then
|
||||
volume_selected[#volume_selected+1] = k
|
||||
end
|
||||
end
|
||||
|
||||
if next(volume_selected) ~= nil then
|
||||
local success = true
|
||||
docker:clear_status()
|
||||
for _,vol in ipairs(volume_selected) do
|
||||
docker:append_status("Volumes: " .. "remove" .. " " .. vol .. "...")
|
||||
local msg = dk.volumes["remove"](dk, {id = vol})
|
||||
if msg.code ~= 204 then
|
||||
docker:append_status("code:" .. msg.code.." ".. (msg.body.message and msg.body.message or msg.message).. "\n")
|
||||
success = false
|
||||
else
|
||||
docker:append_status("done\n")
|
||||
end
|
||||
end
|
||||
|
||||
if success then
|
||||
docker:clear_status()
|
||||
end
|
||||
luci.http.redirect(luci.dispatcher.build_url("admin/docker/volumes"))
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -1,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
|
||||
@@ -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>
|
||||
@@ -1,33 +0,0 @@
|
||||
<div style="display: inline-block;">
|
||||
<!-- <%- if self.title then -%>
|
||||
<label class="cbi-value-title"<%= attr("for", cbid) %>>
|
||||
<%- if self.titleref then -%><a title="<%=self.titledesc or translate('Go to relevant configuration page')%>" class="cbi-title-ref" href="<%=self.titleref%>"><%- end -%>
|
||||
<%-=self.title-%>
|
||||
<%- if self.titleref then -%></a><%- end -%>
|
||||
</label>
|
||||
<%- end -%> -->
|
||||
<%- if self.password then -%>
|
||||
<input type="password" style="position:absolute; left:-100000px" aria-hidden="true"<%=
|
||||
attr("name", "password." .. cbid)
|
||||
%> />
|
||||
<%- end -%>
|
||||
<input data-update="change"<%=
|
||||
attr("id", cbid) ..
|
||||
attr("name", cbid) ..
|
||||
attr("type", self.password and "password" or "text") ..
|
||||
attr("class", self.password and "cbi-input-password" or "cbi-input-text") ..
|
||||
attr("value", self:cfgvalue(section) or self.default) ..
|
||||
ifattr(self.password, "autocomplete", "new-password") ..
|
||||
ifattr(self.size, "size") ..
|
||||
ifattr(self.placeholder, "placeholder") ..
|
||||
ifattr(self.readonly, "readonly") ..
|
||||
ifattr(self.maxlength, "maxlength") ..
|
||||
ifattr(self.datatype, "data-type", self.datatype) ..
|
||||
ifattr(self.datatype, "data-optional", self.optional or self.rmempty) ..
|
||||
ifattr(self.combobox_manual, "data-manual", self.combobox_manual) ..
|
||||
ifattr(#self.keylist > 0, "data-choices", { self.keylist, self.vallist })
|
||||
%> />
|
||||
<%- if self.password then -%>
|
||||
<div class="btn cbi-button cbi-button-neutral" title="<%:Reveal/hide password%>" onclick="var e = this.previousElementSibling; e.type = (e.type === 'password') ? 'text' : 'password'">∗</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
<% if self:cfgvalue(self.section) then section = self.section %>
|
||||
<div class="cbi-section" id="cbi-<%=self.config%>-<%=section%>">
|
||||
<%+cbi/tabmenu%>
|
||||
<div class="cbi-section-node<% if self.tabs then %> cbi-section-node-tabbed<% end %>" id="cbi-<%=self.config%>-<%=section%>">
|
||||
<%+cbi/ucisection%>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- /nsection -->
|
||||
@@ -1,10 +0,0 @@
|
||||
<%+cbi/valueheader%>
|
||||
<input type="hidden" value="1"<%=
|
||||
attr("name", "cbi.cbe." .. self.config .. "." .. section .. "." .. self.option)
|
||||
%> />
|
||||
<input class="cbi-input-checkbox" data-update="click change" type="checkbox" <% if self.disable == 1 then %>disabled <% end %><%=
|
||||
attr("id", cbid) .. attr("name", cbid) .. attr("value", self.enabled or 1) ..
|
||||
ifattr((self:cfgvalue(section) or self.default) == self.enabled, "checked", "checked")
|
||||
%> />
|
||||
<label<%= attr("for", cbid)%>></label>
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -1,28 +0,0 @@
|
||||
<br />
|
||||
<ul class="cbi-tabmenu">
|
||||
<li id="cbi-tab-container_info"><a id="a-cbi-tab-container_info" href=""><%:Info%></a></li>
|
||||
<li id="cbi-tab-container_resources"><a id="a-cbi-tab-container_resources" href=""><%:Resources%></a></li>
|
||||
<li id="cbi-tab-container_stats"><a id="a-cbi-tab-container_stats" href=""><%:Stats%></a></li>
|
||||
<li id="cbi-tab-container_file"><a id="a-cbi-tab-container_file" href=""><%:File%></a></li>
|
||||
<li id="cbi-tab-container_console"><a id="a-cbi-tab-container_console" href=""><%:Console%></a></li>
|
||||
<li id="cbi-tab-container_inspect"><a id="a-cbi-tab-container_inspect" href=""><%:Inspect%></a></li>
|
||||
<li id="cbi-tab-container_logs"><a id="a-cbi-tab-container_logs" href=""><%:Logs%></a></li>
|
||||
</ul>
|
||||
|
||||
<script 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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%>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1699
-375
File diff suppressed because it is too large
Load Diff
+1684
-361
File diff suppressed because it is too large
Load Diff
+1680
-360
File diff suppressed because it is too large
Load Diff
+2099
-564
File diff suppressed because it is too large
Load Diff
+1702
-364
File diff suppressed because it is too large
Load Diff
+1909
-433
File diff suppressed because it is too large
Load Diff
+1676
-359
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1933
-416
File diff suppressed because it is too large
Load Diff
+2257
File diff suppressed because it is too large
Load Diff
+1988
-477
File diff suppressed because it is too large
Load Diff
+1707
-369
File diff suppressed because it is too large
Load Diff
+1953
-431
File diff suppressed because it is too large
Load Diff
+2495
File diff suppressed because it is too large
Load Diff
+1692
-366
File diff suppressed because it is too large
Load Diff
+1676
-356
File diff suppressed because it is too large
Load Diff
+1699
-369
File diff suppressed because it is too large
Load Diff
+1901
-414
File diff suppressed because it is too large
Load Diff
+1720
-380
File diff suppressed because it is too large
Load Diff
+1822
-396
File diff suppressed because it is too large
Load Diff
+1841
-429
File diff suppressed because it is too large
Load Diff
+2010
-484
File diff suppressed because it is too large
Load Diff
+1669
-355
File diff suppressed because it is too large
Load Diff
+1687
-358
File diff suppressed because it is too large
Load Diff
+1706
-371
File diff suppressed because it is too large
Load Diff
+2469
File diff suppressed because it is too large
Load Diff
+1937
-429
File diff suppressed because it is too large
Load Diff
+1905
-418
File diff suppressed because it is too large
Load Diff
+1907
-421
File diff suppressed because it is too large
Load Diff
+1907
-421
File diff suppressed because it is too large
Load Diff
+2048
-501
File diff suppressed because it is too large
Load Diff
+1694
-369
File diff suppressed because it is too large
Load Diff
+1900
-414
File diff suppressed because it is too large
Load Diff
+2457
File diff suppressed because it is too large
Load Diff
+1669
-355
File diff suppressed because it is too large
Load Diff
+1902
-417
File diff suppressed because it is too large
Load Diff
+1955
-442
File diff suppressed because it is too large
Load Diff
+1992
-468
File diff suppressed because it is too large
Load Diff
+2254
File diff suppressed because it is too large
Load Diff
+1926
-429
File diff suppressed because it is too large
Load Diff
+1999
-513
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" ]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+507
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env ucode
|
||||
|
||||
// Copyright 2025 Paul Donald / luci-lib-docker-js
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
// Built against the docker v1.47 API
|
||||
|
||||
|
||||
'use strict';
|
||||
|
||||
import * as http from 'luci.http';
|
||||
import * as fs from 'fs';
|
||||
import * as socket from 'socket';
|
||||
import { cursor } from 'uci';
|
||||
import * as ds from 'luci.docker_socket';
|
||||
|
||||
// const cmdline = fs.readfile('/proc/self/cmdline');
|
||||
// const args = split(cmdline, '\0');
|
||||
const caller = trim(fs.readfile('/proc/self/comm'));
|
||||
|
||||
const BLOCKSIZE = 8192;
|
||||
const POLL_TIMEOUT = 8000; // default; can be overridden per request
|
||||
// const API_VER = '/v1.47';
|
||||
const PROTOCOL = 'HTTP/1.1';
|
||||
const CLIENT_VER = '1';
|
||||
|
||||
function merge(a, b) {
|
||||
let c = {};
|
||||
for (let k, v in a)
|
||||
c[k] = v;
|
||||
for (let k, v in b)
|
||||
c[k] = v;
|
||||
return c;
|
||||
};
|
||||
|
||||
function chunked_body_reader(sock, initial_buffer) {
|
||||
let state = 0, chunklen = 0, buffer = initial_buffer || '';
|
||||
|
||||
function poll_and_recv() {
|
||||
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
|
||||
if (!ready || !length(ready)) return null;
|
||||
let data = sock.recv(BLOCKSIZE);
|
||||
if (!data) return null;
|
||||
buffer += data;
|
||||
return true;
|
||||
}
|
||||
|
||||
return () => {
|
||||
while (true) {
|
||||
if (state === 0) {
|
||||
let m = match(buffer, /^([0-9a-fA-F]+)\r\n/);
|
||||
if (!m || length(m) < 2) {
|
||||
if (!poll_and_recv()) return null;
|
||||
continue;
|
||||
}
|
||||
chunklen = int(m[1], 16);
|
||||
buffer = substr(buffer, length(m[0]));
|
||||
if (chunklen === 0) return null;
|
||||
state = 1;
|
||||
}
|
||||
if (state === 1 && length(buffer) >= chunklen + 2) {
|
||||
let chunk = substr(buffer, 0, chunklen);
|
||||
buffer = substr(buffer, chunklen + 2);
|
||||
state = 0;
|
||||
return chunk;
|
||||
} else {
|
||||
if (!poll_and_recv()) return null;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function read_http_headers(response_headers, response) {
|
||||
const lines = split(response, /\r?\n/);
|
||||
|
||||
for (let l in lines) {
|
||||
let kv = match(l, /([^:]+):\s*(.*)/);
|
||||
if (kv && length(kv) === 3)
|
||||
response_headers[lc(kv[1])] = kv[2];
|
||||
}
|
||||
|
||||
return response_headers;
|
||||
};
|
||||
|
||||
function get_api_ver() {
|
||||
|
||||
const ctx = cursor();
|
||||
const version = ctx.get('dockerd', 'globals', 'api_version') || '';
|
||||
const version_str = version ? `/${version}` : '';
|
||||
ctx.unload();
|
||||
|
||||
return version_str;
|
||||
};
|
||||
|
||||
function coerce_values_to_string(obj) {
|
||||
for (let k, v in obj) {
|
||||
v = `${v}`;
|
||||
obj[k]=v;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
function call_docker(method, path, options) {
|
||||
options = options || {};
|
||||
const headers = options.headers || {};
|
||||
let payload = options.payload || null;
|
||||
|
||||
/* requires ucode 2026-01-16 if get_socket_dest() provides ip:port e.g.
|
||||
'127.0.0.1:2375'.
|
||||
We use get_socket_dest_compat() which builds the SockAddress manually to
|
||||
avoid this.
|
||||
|
||||
Important: dockerd after v28 won't accept tcp://x.x.x.x:2375 without
|
||||
--tls* options.
|
||||
|
||||
A solution is a reverse proxy or ssh port forwarding to a remote host that
|
||||
uses the unix socket, and you still connect to a 'local port', or socket:
|
||||
ssh -L /tmp/docker.sock:localhost:2375 user@remote-host (openssh-client)
|
||||
or (dropbear)
|
||||
socat TCP-LISTEN:12375,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock
|
||||
ssh -L 2375:localhost:12375 user@remote-host
|
||||
*/
|
||||
|
||||
|
||||
/* works on ucode 2025-12-01 */
|
||||
const sock_dest = ds.get_socket_dest_compat();
|
||||
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
|
||||
|
||||
/* works on ucode 2026-01-16 */
|
||||
// const sock_dest = ds.get_socket_dest();
|
||||
// const sock_addr = socket.sockaddr(sock_dest);
|
||||
// const sock = socket.create(sock_addr.family, socket.SOCK_STREAM);
|
||||
|
||||
if (caller != 'rpcd') {
|
||||
print('sock_dest:', sock_dest, '\n');
|
||||
// print('sock_addr:', sock_addr, '\n');
|
||||
}
|
||||
if (!sock) {
|
||||
return {
|
||||
code: 500,
|
||||
headers: {},
|
||||
body: { message: "Failed to create socket" }
|
||||
};
|
||||
}
|
||||
|
||||
let conn_result = sock.connect(sock_dest);
|
||||
let err_msg = `Failed to connect to docker host at ${sock_dest}`;
|
||||
if (!conn_result) {
|
||||
sock.close();
|
||||
return {
|
||||
code: 500,
|
||||
headers: {},
|
||||
body: { message: err_msg}
|
||||
};
|
||||
}
|
||||
|
||||
if (caller != 'rpcd')
|
||||
print("query: ", options.query, '\n');
|
||||
|
||||
const query = options.query ? http.build_querystring(coerce_values_to_string(options.query)) : '';
|
||||
const url = path + query;
|
||||
|
||||
const req_headers = [
|
||||
`${method} ${get_api_ver()}${url} ${PROTOCOL}`,
|
||||
`Host: luci-host`,
|
||||
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
|
||||
`Connection: close`
|
||||
];
|
||||
|
||||
if (payload) {
|
||||
if (type(payload) === 'object') {
|
||||
payload = sprintf('%J', payload);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
headers['Content-Length'] = '' + length(payload);
|
||||
}
|
||||
|
||||
for (let k, v in headers)
|
||||
push(req_headers, `${k}: ${v}`);
|
||||
|
||||
push(req_headers, '', '');
|
||||
|
||||
if (caller != 'rpcd')
|
||||
print(join('\r\n', req_headers), "\n");
|
||||
|
||||
sock.send(join('\r\n', req_headers));
|
||||
if (payload) sock.send(payload);
|
||||
|
||||
const response_buff = sock.recv(BLOCKSIZE);
|
||||
if (!response_buff || response_buff === '') {
|
||||
sock.close();
|
||||
return {
|
||||
code: 500,
|
||||
headers: {},
|
||||
body: { message: "No response from Docker socket" }
|
||||
};
|
||||
}
|
||||
|
||||
const response_parts = split(response_buff, /\r?\n\r?\n/, 2);
|
||||
const response_headers = read_http_headers({}, response_parts[0]);
|
||||
let response_body;
|
||||
|
||||
let is_chunked = (response_headers['transfer-encoding'] === 'chunked');
|
||||
|
||||
let reader;
|
||||
if (is_chunked) {
|
||||
reader = chunked_body_reader(sock, response_parts[1]);
|
||||
}
|
||||
else if (response_headers['content-length']) {
|
||||
let content_length = int(response_headers['content-length']);
|
||||
let buf = response_parts[1];
|
||||
|
||||
reader = () => {
|
||||
if (content_length <= 0) return null;
|
||||
|
||||
if (buf && length(buf)) {
|
||||
let chunk = substr(buf, 0, content_length);
|
||||
buf = substr(buf, length(chunk));
|
||||
content_length -= length(chunk);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
let data = sock.recv(min(BLOCKSIZE, content_length));
|
||||
if (!data || data === '') return null;
|
||||
|
||||
content_length -= length(data);
|
||||
return data;
|
||||
};
|
||||
}
|
||||
else {
|
||||
// Fallback for HTTP/1.0 or no content-length: read until close or timeout
|
||||
reader = () => {
|
||||
// Poll with 2 second timeout
|
||||
let ready = socket.poll(POLL_TIMEOUT, [sock, socket.POLLIN]);
|
||||
if (!ready || !length(ready)) return null; // Timeout or error
|
||||
|
||||
let data = sock.recv(BLOCKSIZE);
|
||||
if (!data || data === '') return null;
|
||||
return data;
|
||||
};
|
||||
}
|
||||
|
||||
let chunks = [], chunk;
|
||||
while ((chunk = reader())) {
|
||||
push(chunks, chunk);
|
||||
}
|
||||
|
||||
sock.close();
|
||||
|
||||
response_body = join('', chunks);
|
||||
|
||||
// Parse HTTP status code
|
||||
let status_line = split(response_parts[0], /\r?\n/)[0];
|
||||
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
|
||||
let code = status_match ? int(status_match[1]) : 0;
|
||||
|
||||
// Docker events endpoint returns newline-delimited JSON, not a single JSON object
|
||||
if (response_headers['content-type'] === 'application/json' && response_body) {
|
||||
// Single JSON object
|
||||
let data;
|
||||
try { data = json(rtrim(response_body)); }
|
||||
catch { data = null; }
|
||||
|
||||
// Check if this is newline-delimited JSON (multiple lines with JSON objects)
|
||||
if (!data) {
|
||||
// Parse each line as a separate JSON object
|
||||
let lines = split(trim(response_body), /\n/);
|
||||
let events = [];
|
||||
for (let line in lines) {
|
||||
line = trim(line);
|
||||
if (line) {
|
||||
try { push(events, json(line)); }
|
||||
catch { /* skip invalid lines */ }
|
||||
}
|
||||
}
|
||||
response_body = events;
|
||||
} else {
|
||||
response_body = data;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: code,
|
||||
headers: response_headers,
|
||||
body: response_body
|
||||
};
|
||||
};
|
||||
|
||||
function run_ttyd(request) {
|
||||
|
||||
const id = request.args.id || '';
|
||||
const cmd = request.args.cmd || '/bin/sh';
|
||||
const port = request.args.port || 7682;
|
||||
const uid = request.args.uid || '';
|
||||
|
||||
if (!id) {
|
||||
return { error: 'Container ID is required' };
|
||||
}
|
||||
|
||||
let ttyd_cmd = `ttyd -q -d 2 --once --writable -p ${port} docker`;
|
||||
const sock_addr = ds.get_socket_dest();
|
||||
|
||||
/* Build the full command:
|
||||
ttyd --writable -d 2 --once -p PORT docker -H unix://SOCKET exec -it [-u UID] CONTAINER CMD
|
||||
|
||||
if the socket is /var/run/docker.sock, prefix unix://
|
||||
|
||||
Note: invocations of docker -H x.x.x.x:2375 [..] will fail after v27 without --tls*
|
||||
*/
|
||||
const sock_str = index(sock_addr, '/') != -1 && index(sock_addr, 'unix://') == -1 ? 'unix://' + sock_addr : sock_addr;
|
||||
ttyd_cmd = `${ttyd_cmd} -H "${sock_str}" exec -it`;
|
||||
if (uid && uid !== '') {
|
||||
ttyd_cmd = `${ttyd_cmd} -u ${uid}`;
|
||||
}
|
||||
|
||||
ttyd_cmd = `${ttyd_cmd} ${id} ${cmd} &`;
|
||||
|
||||
// Try to kill any existing ttyd processes on this port
|
||||
system(`pkill -f "ttyd.*-p ${port}"` + ' 2>/dev/null; true');
|
||||
|
||||
// Start ttyd
|
||||
system(ttyd_cmd);
|
||||
|
||||
return { status: 'ttyd started', command: ttyd_cmd };
|
||||
}
|
||||
|
||||
// https://docs.docker.com/reference/api/engine/version/v1.47/
|
||||
|
||||
/* Note: methods here are included for structural reference. Some rpcd methods
|
||||
are not suitable to be called from the GUI because they are streaming endpoints
|
||||
or the operations in a busy dockerd cluster take a *long* time which causes
|
||||
timeouts at the front end. Good examples of this are:
|
||||
- /system/df
|
||||
- push
|
||||
- pull
|
||||
- all /prune
|
||||
|
||||
We include them here because they can be useful from the command line.
|
||||
*/
|
||||
|
||||
const core_methods = {
|
||||
version: { call: () => call_docker('GET', '/version') },
|
||||
info: { call: () => call_docker('GET', '/info') },
|
||||
ping: { call: () => call_docker('GET', '/_ping') },
|
||||
df: { call: () => call_docker('GET', '/system/df') },
|
||||
events: { args: { query: { 'since': '', 'until': `${time()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.args?.query }) },
|
||||
};
|
||||
|
||||
const exec_methods = {
|
||||
start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request.args.id}/start`, { payload: request.args.body }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request.args.id}/resize`, { query: request.args.query }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request.args.id}/json`) },
|
||||
};
|
||||
|
||||
const container_methods = {
|
||||
list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request.args.query }) },
|
||||
create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request.args.query, payload: request.args.body }) },
|
||||
inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/json`, { query: request.args.query }) },
|
||||
top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/top`, { query: request.args.query }) },
|
||||
logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request.args.id}/logs`, { query: request.args.query }) },
|
||||
changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/changes`) },
|
||||
export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request.args.id}/export`) },
|
||||
stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/stats`, { query: request.args.query }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/resize`, { query: request.args.query }) },
|
||||
start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/start`, { query: request.args.query }) },
|
||||
stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/stop`, { query: request.args.query }) },
|
||||
restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/restart`, { query: request.args.query }) },
|
||||
kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/kill`, { query: request.args.query }) },
|
||||
update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/update`, { payload: request.args.body }) },
|
||||
rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request.args.id}/rename`, { query: request.args.query }) },
|
||||
pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/pause`) },
|
||||
unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request.args.id}/unpause`) },
|
||||
// attach
|
||||
// attach websocket
|
||||
// wait
|
||||
remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request.args.id}`, { query: request.args.query }) },
|
||||
// archive info
|
||||
info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
|
||||
// archive get
|
||||
get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request.args.id}/archive`, { query: request.args.query }) },
|
||||
// archive extract
|
||||
put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request.args.id}/archive`, { query: request.args.query, payload: request.args.body }) },
|
||||
exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request.args.id}/exec`, { payload: request.args.opts }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request.args.query }) },
|
||||
|
||||
// Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker
|
||||
ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) },
|
||||
};
|
||||
|
||||
const image_methods = {
|
||||
list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request.args.query }) },
|
||||
// build is long-running, and will likely cause time-out on the call. Function only here for reference.
|
||||
build: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build', { query: request.args.query, headers: request.args.headers }) },
|
||||
build_prune: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/build/prune', { query: request.args.query, headers: request.args.headers }) },
|
||||
create: { args: { query: { '': '' }, headers: {} }, call: (request) => call_docker('POST', '/images/create', { query: request.args.query, headers: request.args.headers }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/json`) },
|
||||
history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/history`) },
|
||||
push: { args: { name: '', query: { tag: '', platform: '' }, headers: {} }, call: (request) => call_docker('POST', `/images/${request.args.name}/push`, { query: request.args.query, headers: request.args.headers }) },
|
||||
tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request.args.id}/tag`, { query: request.args.query }) },
|
||||
remove: { args: { id: '', query: { 'force': false, 'noprune': false } }, call: (request) => call_docker('DELETE', `/images/${request.args.id}`, { query: request.args.query }) },
|
||||
search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request.args.query }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/images/prune', { query: request.args.query }) },
|
||||
// create/commit
|
||||
get: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request.args.id}/get`) },
|
||||
// get == export several
|
||||
load: { args: { query: { 'quiet': false } }, call: (request) => call_docker('POST', '/images/load', { query: request.args.query }) },
|
||||
};
|
||||
|
||||
const network_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request.args.query }) },
|
||||
inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request.args.id}`, { query: request.args.query }) },
|
||||
remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request.args.id}`) },
|
||||
create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request.args.body }) },
|
||||
connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/connect`, { payload: request.args.body }) },
|
||||
disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request.args.id}/disconnect`, { payload: request.args.body }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request.args.query }) },
|
||||
};
|
||||
|
||||
const volume_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request.args.query }) },
|
||||
create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request.args.opts }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request.args.id}`) },
|
||||
update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request.args.id}`, { query: request.args.query, payload: request.args.spec }) },
|
||||
remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request.args.id}`, { query: request.args.query }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request.args.query }) },
|
||||
};
|
||||
|
||||
const methods = {
|
||||
'docker': core_methods,
|
||||
'docker.container': container_methods,
|
||||
'docker.exec': exec_methods,
|
||||
'docker.image': image_methods,
|
||||
'docker.network': network_methods,
|
||||
'docker.volume': volume_methods,
|
||||
};
|
||||
|
||||
// CLI test mode - check if script is run directly (not loaded by rpcd)
|
||||
if (caller != 'rpcd') {
|
||||
// Usage: ./docker_rpc.uc <object.method> <json-args>
|
||||
// Example: ./docker_rpc.uc docker.network.list '{"query":{"filters":""}}'
|
||||
// Example: ./docker_rpc.uc docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'
|
||||
const scr_name = split(SCRIPT_NAME, '/')[-1] || 'docker_rpc.uc';
|
||||
|
||||
if (length(ARGV) < 1) {
|
||||
print(`Usage: ${scr_name} <object.method> [json-args]\n`);
|
||||
|
||||
print("Available methods:\n");
|
||||
for (let obj in methods) {
|
||||
for (let name, info in methods[obj]) {
|
||||
let sig = name;
|
||||
if (info && info.args) {
|
||||
try {
|
||||
sig = `${sig} ${sprintf('\'%J\'', info.args)}`;
|
||||
} catch {
|
||||
sig = `${sig} <args>`;
|
||||
}
|
||||
}
|
||||
print(` ${obj}.${sig}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
print("\nExamples:\n");
|
||||
print(` ${scr_name} docker.version\n`);
|
||||
print(` ${scr_name} docker.network.list '{"query":{}}'\n`);
|
||||
print(` ${scr_name} docker.image.create '{"query":{"fromImage":"alpine","tag":"latest"}}'\n`);
|
||||
print(` ${scr_name} docker.container.list '{"query":{"all":true}}'\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const method_path = split(ARGV[0], '.');
|
||||
if (length(method_path) < 1) {
|
||||
die(`Invalid method path: ${ARGV[0]}\n`);
|
||||
}
|
||||
|
||||
// Build object path (e.g., "docker.network")
|
||||
const obj_parts = slice(method_path, 0, -1);
|
||||
const obj_name = join('.', obj_parts);
|
||||
const method_name = method_path[length(method_path) - 1];
|
||||
|
||||
if (!methods[obj_name]) {
|
||||
die(`Unknown object: ${obj_name}\n`);
|
||||
}
|
||||
|
||||
if (!methods[obj_name][method_name]) {
|
||||
die(`Unknown method: ${obj_name}.${method_name}\n`);
|
||||
}
|
||||
|
||||
// Parse args if provided
|
||||
let args = {};
|
||||
if (length(ARGV) > 1) {
|
||||
try {
|
||||
args = json(ARGV[1]);
|
||||
} catch (e) {
|
||||
die(`Invalid JSON args: ${e}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Call the method
|
||||
const request = { args: args };
|
||||
const result = methods[obj_name][method_name].call(request);
|
||||
|
||||
// Pretty print result
|
||||
print(result, "\n");
|
||||
exit(0);
|
||||
};
|
||||
|
||||
return methods;
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"config": "dockerd",
|
||||
"init": "dockerd"
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Docker HTTP streaming endpoint
|
||||
// Copyright 2025 Paul Donald <newtwen+github@gmail.com>
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
// Built against the docker v1.47 API
|
||||
|
||||
'use strict';
|
||||
|
||||
import { stdout } from 'fs';
|
||||
import * as ds from 'luci.docker_socket';
|
||||
import * as socket from 'socket';
|
||||
import { cursor } from 'uci';
|
||||
|
||||
const BUFF_HEAD = 6; // 8000\r\n
|
||||
const BUFF_TAIL = 2; // \r\n
|
||||
const BLOCKSIZE = BUFF_HEAD + 0x8000 + BUFF_TAIL; //sync with Docker chunk size, 32776
|
||||
// const API_VER = 'v1.47';
|
||||
const PROTOCOL = 'HTTP/1.1';
|
||||
const CLIENT_VER = '1';
|
||||
|
||||
let DockerController = {
|
||||
|
||||
// Handle file upload for chunked transfer
|
||||
handle_file_upload: function(sock) {
|
||||
let total_bytes = 0;
|
||||
http.setfilehandler(function(meta, chunk, eof) {
|
||||
if (meta.file && meta.name === 'upload-archive') {
|
||||
if (chunk && length(chunk) > 0) {
|
||||
let hex_size = sprintf('%x', length(chunk));
|
||||
sock.send(hex_size + '\r\n');
|
||||
sock.send(chunk);
|
||||
sock.send('\r\n');
|
||||
total_bytes += length(chunk);
|
||||
}
|
||||
if (eof) {
|
||||
sock.send('0\r\n\r\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
return total_bytes;
|
||||
},
|
||||
|
||||
// Reusable header builder
|
||||
build_headers: function(headers) {
|
||||
let hdrs = [];
|
||||
if (headers) {
|
||||
for (let key in headers) {
|
||||
if (headers[key] != null && headers[key] != '') {
|
||||
push(hdrs, `${key}: ${headers[key]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return length(hdrs) ? join('\r\n', hdrs) : '';
|
||||
},
|
||||
|
||||
// Parse the initial HTTP response, split into parts and header lines, and store as properties
|
||||
initial_response_parser: function(response_buff) {
|
||||
let parts = split(response_buff, /\r?\n\r?\n/, 2);
|
||||
let header_lines = split(parts[0], /\r?\n/);
|
||||
let status_line = header_lines[0];
|
||||
let status_match = match(status_line, /HTTP\/\S+\s+(\d+)/);
|
||||
let code = status_match ? int(status_match[1]) : 500;
|
||||
this.response_parts = parts;
|
||||
this.header_lines = header_lines;
|
||||
this.status_line = status_line;
|
||||
this.status_match = status_match;
|
||||
this.code = code;
|
||||
},
|
||||
|
||||
// Stream the rest of the response in chunks from the socket
|
||||
stream_response_chunks: function(sock, blocksize) {
|
||||
let chunk;
|
||||
while ((chunk = sock.recv(blocksize))) {
|
||||
if (chunk && length(chunk)) {
|
||||
this.debug('Streaming chunk:', substr(chunk, 0, 10));
|
||||
stdout.write(chunk);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Send a 200 OK response with headers and body
|
||||
/* Write CGI response directly to stdout bypassing http.uc
|
||||
The minimum to trigger a valid response via CGI is typically
|
||||
Status: \r\n
|
||||
|
||||
The Docker response contains the \r\n after its headers, and the browser can
|
||||
handle the chunked encoding fine, so we just forward its output verbatim.
|
||||
|
||||
Docker emits a x-docker-container-path-stat header with some meta-data for
|
||||
the path, which we forward. uhttpd seems to coalesce headers, and inject its
|
||||
own, so we occasionally have two Connection: headers.
|
||||
*/
|
||||
send_initial_200_response: function(headers, body) {
|
||||
stdout.write('Status: 200 OK\r\n');
|
||||
if (headers && type(headers) == 'array') {
|
||||
stdout.write(join('', headers));
|
||||
}
|
||||
|
||||
if (body && index(body, 'HTTP/1.1 200 OK\r\n') === 0) {
|
||||
stdout.write(substr(body, length('HTTP/1.1 200 OK\r\n')));
|
||||
}
|
||||
},
|
||||
|
||||
// Debug output if &debug=... is present
|
||||
debug: function(...args) {
|
||||
let dbg = http.formvalue('debug');
|
||||
let tostr = function(x) { return `${x}`; };
|
||||
if (dbg != null && dbg != '') {
|
||||
http.prepare_content('application/json');
|
||||
http.write_json({msg: join(' ', map(args, tostr)) + '\n' });
|
||||
}
|
||||
},
|
||||
|
||||
// Generic error response helper
|
||||
error_response: function(code, msg, detail) {
|
||||
http.status(code ?? 500, msg ?? 'Internal Error');
|
||||
http.prepare_content('application/json');
|
||||
let out = { error: msg ?? 'Internal Error' };
|
||||
if (detail)
|
||||
out.detail = detail;
|
||||
http.write_json(out);
|
||||
},
|
||||
|
||||
get_api_ver: function() {
|
||||
let ctx = cursor();
|
||||
let version = ctx.get('dockerd', 'globals', 'api_version') || '';
|
||||
ctx.unload();
|
||||
return version ? `/${version}` : '';
|
||||
},
|
||||
|
||||
join_args_array: function(args) {
|
||||
return (type(args) == "array") ? join('/', args) : args;
|
||||
},
|
||||
|
||||
require_param: function(name) {
|
||||
let val = http.formvalue(name);
|
||||
if (!val || val == '') die({ code: 400, message: `Missing parameter: ${name}` });
|
||||
return val;
|
||||
},
|
||||
|
||||
// Reusable query string builder
|
||||
build_query_str: function(query_params, skip_keys) {
|
||||
let query_str = '';
|
||||
if (query_params) {
|
||||
let parts = [];
|
||||
for (let key in query_params) {
|
||||
if (skip_keys && (key in skip_keys))
|
||||
continue;
|
||||
let val = query_params[key];
|
||||
if (val == null || val == '') continue;
|
||||
if (type(val) === 'array') {
|
||||
for (let v in val) {
|
||||
push(parts, `${key}=${v}`);
|
||||
}
|
||||
} else {
|
||||
push(parts, `${key}=${val}`);
|
||||
}
|
||||
}
|
||||
if (length(parts))
|
||||
query_str = '?' + join('&', parts);
|
||||
}
|
||||
return query_str;
|
||||
},
|
||||
|
||||
get_archive: function(docker_path, id, docker_function, query_params, archive_name) {
|
||||
this.debug('get_archive called', docker_path, id, docker_function, query_params, archive_name);
|
||||
id = this.join_args_array(id);
|
||||
let id_param = '';
|
||||
if (id) id_param = `/${id}`;
|
||||
|
||||
const sock_dest = ds.get_socket_dest_compat();
|
||||
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
|
||||
|
||||
this.debug('Socket created:', !!sock);
|
||||
|
||||
if (!sock) {
|
||||
this.debug('Socket creation failed');
|
||||
this.error_response(500, 'Failed to create socket');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sock.connect(sock_dest)) {
|
||||
this.debug('Socket connect failed');
|
||||
sock.close();
|
||||
this.error_response(503, 'Failed to connect to Docker daemon');
|
||||
return;
|
||||
}
|
||||
|
||||
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params) : `?${query_params}`;
|
||||
let url = `${docker_path}${id_param}${docker_function}${query_str}`;
|
||||
let req = [
|
||||
`GET ${this.get_api_ver()}${url} ${PROTOCOL}`,
|
||||
`Host: openwrt-docker-ui`,
|
||||
`User-Agent: luci-app-dockerman-rpc-ucode/${CLIENT_VER}`,
|
||||
`Connection: close`,
|
||||
``,
|
||||
``
|
||||
];
|
||||
|
||||
this.debug('Sending request:', req);
|
||||
sock.send(join('\r\n', req));
|
||||
|
||||
let response_buff = sock.recv(BLOCKSIZE);
|
||||
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
|
||||
if (!response_buff || response_buff == '') {
|
||||
this.debug('No response from Docker daemon');
|
||||
sock.close();
|
||||
this.error_response(500, 'No response from Docker daemon');
|
||||
return;
|
||||
}
|
||||
|
||||
this.initial_response_parser(response_buff);
|
||||
|
||||
if (this.code != 200) {
|
||||
this.debug('Docker error status:', this.code, this.status_line);
|
||||
sock.close();
|
||||
this.error_response(this.code, 'Docker Error', this.status_line);
|
||||
return;
|
||||
}
|
||||
|
||||
let filename = length(id) >= 64 ? substr(id, 0, 12) : id;
|
||||
if (!filename) filename = 'multi';
|
||||
let include_headers = [`Content-Disposition: attachment; filename=\"${filename}_${archive_name}\"\r\n`];
|
||||
|
||||
this.send_initial_200_response(include_headers, response_buff);
|
||||
this.stream_response_chunks(sock, BLOCKSIZE);
|
||||
|
||||
sock.close();
|
||||
return;
|
||||
},
|
||||
|
||||
docker_send: function(method, docker_path, docker_function, query_params, headers, haveFile) {
|
||||
this.debug('docker_send called', method, docker_path, docker_function, query_params, headers, haveFile);
|
||||
const sock_dest = ds.get_socket_dest_compat();
|
||||
const sock = socket.create(sock_dest.family, socket.SOCK_STREAM);
|
||||
|
||||
this.debug('Socket created:', !!sock);
|
||||
|
||||
if (!sock) {
|
||||
this.debug('Socket creation failed');
|
||||
this.error_response(500, 'Failed to create socket');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sock.connect(sock_dest)) {
|
||||
this.debug('Socket connect failed');
|
||||
sock.close();
|
||||
this.error_response(503, 'Failed to connect to Docker daemon');
|
||||
return;
|
||||
}
|
||||
|
||||
let skip_keys = {
|
||||
token: true,
|
||||
'X-Registry-Auth': true,
|
||||
'upload-name': true,
|
||||
'upload-archive': true,
|
||||
'upload-path': true,
|
||||
};
|
||||
|
||||
let remote = false;
|
||||
|
||||
if (query_params && type(query_params) === 'object' &&
|
||||
query_params['remote'] != null && query_params['remote'] != '')
|
||||
remote = true;
|
||||
|
||||
let query_str = type(query_params) === 'object' ? this.build_query_str(query_params, skip_keys) : `?${query_params}`;
|
||||
|
||||
let hdr_str = this.build_headers(headers);
|
||||
|
||||
let url = `${docker_path}${docker_function}${query_str}`;
|
||||
|
||||
let req = [
|
||||
`${method} ${this.get_api_ver()}${url} ${PROTOCOL}`,
|
||||
`Host: openwrt-docker-ui`,
|
||||
`User-Agent: luci-app-docker-controller-ucode/${CLIENT_VER}`,
|
||||
`Connection: close`,
|
||||
];
|
||||
|
||||
if (hdr_str)
|
||||
push(req, hdr_str);
|
||||
if (haveFile) {
|
||||
push(req, 'Content-Type: application/x-tar');
|
||||
push(req, 'Transfer-Encoding: chunked');
|
||||
}
|
||||
push(req, '');
|
||||
push(req, '');
|
||||
|
||||
this.debug('Sending request:', req);
|
||||
sock.send(join('\r\n', req));
|
||||
|
||||
if (haveFile)
|
||||
this.handle_file_upload(sock);
|
||||
else
|
||||
sock.send('\r\n\r\n');
|
||||
|
||||
let response_buff = sock.recv(BLOCKSIZE);
|
||||
this.debug('Received response header block:', response_buff ? substr(response_buff, 0, 100) : 'null');
|
||||
if (!response_buff || response_buff == '') {
|
||||
this.debug('No response from Docker daemon');
|
||||
sock.close();
|
||||
this.error_response(500, 'No response from Docker daemon');
|
||||
return;
|
||||
}
|
||||
|
||||
this.initial_response_parser(response_buff);
|
||||
if (this.code != 200) {
|
||||
this.debug('Docker error status:', this.code, this.status_line);
|
||||
sock.close();
|
||||
this.error_response(this.code, 'Docker Error', this.status_line);
|
||||
return;
|
||||
}
|
||||
|
||||
this.send_initial_200_response('', response_buff);
|
||||
this.stream_response_chunks(sock, BLOCKSIZE);
|
||||
sock.close();
|
||||
return;
|
||||
},
|
||||
|
||||
// Handler methods
|
||||
container_get_archive: function(id) {
|
||||
this.require_param('path');
|
||||
this.get_archive('/containers', id, '/archive', http.message.env.QUERY_STRING, 'file_archive.tar');
|
||||
},
|
||||
|
||||
container_export: function(id) {
|
||||
this.get_archive('/containers', id, '/export', null, 'container_export.tar');
|
||||
},
|
||||
|
||||
containers_prune: function() {
|
||||
this.docker_send('POST', '/containers', '/prune', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
|
||||
container_put_archive: function(id) {
|
||||
this.require_param('path');
|
||||
this.docker_send('PUT', '/containers', `/${id}/archive`, http.message.env.QUERY_STRING, {}, true);
|
||||
},
|
||||
|
||||
docker_events: function() {
|
||||
this.docker_send('GET', '', '/events', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
|
||||
image_build: function(...args) {
|
||||
let remote = http.formvalue('remote');
|
||||
this.docker_send('POST', '', '/build', http.message.env.QUERY_STRING, {}, !remote);
|
||||
},
|
||||
|
||||
image_build_prune: function() {
|
||||
this.docker_send('POST', '/build', '/prune', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
|
||||
image_create: function() {
|
||||
let headers = {
|
||||
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
|
||||
};
|
||||
this.docker_send('POST', '/images', '/create', http.message.env.QUERY_STRING, headers, false);
|
||||
},
|
||||
|
||||
image_get: function(...args) {
|
||||
this.get_archive('/images', args, '/get', http.message.env.QUERY_STRING, 'image_export.tar');
|
||||
},
|
||||
|
||||
image_load: function() {
|
||||
this.docker_send('POST', '/images', '/load', http.message.env.QUERY_STRING, {}, true);
|
||||
},
|
||||
|
||||
images_prune: function() {
|
||||
this.docker_send('POST', '/images', '/prune', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
|
||||
image_push: function(...args) {
|
||||
let headers = {
|
||||
'X-Registry-Auth': http.formvalue('X-Registry-Auth'),
|
||||
};
|
||||
this.docker_send('POST', `/images/${this.join_args_array(args)}`, '/push', http.message.env.QUERY_STRING, headers, false);
|
||||
},
|
||||
|
||||
networks_prune: function() {
|
||||
this.docker_send('POST', '/networks', '/prune', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
|
||||
volumes_prune: function() {
|
||||
this.docker_send('POST', '/volumes', '/prune', http.message.env.QUERY_STRING, {}, false);
|
||||
},
|
||||
};
|
||||
|
||||
// Export all handlers with automatic error wrapping
|
||||
let controller = DockerController;
|
||||
let exports = {};
|
||||
for (let k, v in controller) {
|
||||
if (type(v) == 'function')
|
||||
exports[k] = v;
|
||||
}
|
||||
|
||||
return exports;
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
// Copyright 2025 Paul Donald / luci-lib-docker-js
|
||||
// Licensed to the public under the Apache License 2.0.
|
||||
// Built against the docker v1.47 API
|
||||
|
||||
import { cursor } from 'uci';
|
||||
import * as socket from 'socket';
|
||||
|
||||
/**
|
||||
* Get the Docker socket path from uci config, more backwards compatible.
|
||||
*/
|
||||
export function get_socket_dest_compat() {
|
||||
const ctx = cursor();
|
||||
let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
|
||||
ctx.unload();
|
||||
|
||||
sock_entry = lc(sock_entry);
|
||||
/* start ucode 2025-12-01 compatibility */
|
||||
let sock_split, addr = sock_entry, proto, proto_num, port = 0;
|
||||
let family;
|
||||
|
||||
if (index(sock_entry, '://') != -1) {
|
||||
let sock_split = split(lc(sock_entry), '://', 2);
|
||||
addr = sock_split?.[1];
|
||||
proto = sock_split?.[0];
|
||||
}
|
||||
if (index(addr, '/') != -1) {
|
||||
// we got '/var/run/docker.sock' format
|
||||
return socket.sockaddr(addr);
|
||||
}
|
||||
|
||||
if (proto === 'tcp' || proto === 'udp' || proto === 'inet') {
|
||||
family = socket.AF_INET;
|
||||
if (proto === 'tcp')
|
||||
proto_num = socket.IPPROTO_TCP;
|
||||
else if (proto === 'udp')
|
||||
proto_num = socket.IPPROTO_UDP;
|
||||
}
|
||||
else if (proto === 'tcp6' || proto === 'udp6' || proto === 'inet6') {
|
||||
family = socket.AF_INET6;
|
||||
if (proto === 'tcp6')
|
||||
proto_num = socket.IPPROTO_TCP;
|
||||
else if (proto === 'udp6')
|
||||
proto_num = socket.IPPROTO_UDP;
|
||||
}
|
||||
else if (proto === 'unix')
|
||||
family = socket.AF_UNIX;
|
||||
else {
|
||||
family = socket.AF_INET; // ipv4
|
||||
proto_num = socket.IPPROTO_TCP; // tcp
|
||||
}
|
||||
|
||||
let host = addr;
|
||||
const l_bracket = index(host, '[');
|
||||
const r_bracket = rindex(host, ']');
|
||||
if (l_bracket != -1 && r_bracket != -1) {
|
||||
host = substr(host, l_bracket + 1, r_bracket - 1);
|
||||
family = socket.AF_INET6;
|
||||
}
|
||||
|
||||
// find port based on addr, otherwise we find ':' in IPv6
|
||||
const port_index = rindex(addr, ':');
|
||||
if (port_index != -1) {
|
||||
port = int(substr(addr, port_index + 1)) || 0;
|
||||
host = substr(host, 0, port_index);
|
||||
}
|
||||
|
||||
const sock = socket.addrinfo(host, port, {protocol: proto_num});
|
||||
|
||||
return socket.sockaddr(sock[0].addr);
|
||||
// return {family: family, address: host, port: port};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Get the Docker socket path from uci config
|
||||
*/
|
||||
export function get_socket_dest() {
|
||||
|
||||
const ctx = cursor();
|
||||
let sock_entry = ctx.get_first('dockerd', 'globals', 'hosts')?.[0] || '/var/run/docker.sock';
|
||||
sock_entry = lc(sock_entry);
|
||||
let sock_addr = split(sock_entry, '://', 2)?.[1] ?? sock_entry;
|
||||
ctx.unload();
|
||||
|
||||
return sock_addr;
|
||||
};
|
||||
Reference in New Issue
Block a user