luci-app-dockerman: add from openwrt luci

Signed-off-by: sbwml <admin@cooluc.com>
This commit is contained in:
sbwml
2026-02-21 22:33:23 +08:00
commit fb4454678a
66 changed files with 109176 additions and 0 deletions
@@ -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,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 756.26 596.9">
<defs>
<style>
.cls-1 {
fill: #1d63ed;
stroke-width: 0px;
}
</style>
</defs>
<path class="cls-1" d="M743.96,245.25c-18.54-12.48-67.26-17.81-102.68-8.27-1.91-35.28-20.1-65.01-53.38-90.95l-12.32-8.27-8.21,12.4c-16.14,24.5-22.94,57.14-20.53,86.81,1.9,18.28,8.26,38.83,20.53,53.74-46.1,26.74-88.59,20.67-276.77,20.67H.06c-.85,42.49,5.98,124.23,57.96,190.77,5.74,7.35,12.04,14.46,18.87,21.31,42.26,42.32,106.11,73.35,201.59,73.44,145.66.13,270.46-78.6,346.37-268.97,24.98.41,90.92,4.48,123.19-57.88.79-1.05,8.21-16.54,8.21-16.54l-12.3-8.27ZM189.67,206.39h-81.7v81.7h81.7v-81.7ZM295.22,206.39h-81.7v81.7h81.7v-81.7ZM400.77,206.39h-81.7v81.7h81.7v-81.7ZM506.32,206.39h-81.7v81.7h81.7v-81.7ZM84.12,206.39H2.42v81.7h81.7v-81.7ZM189.67,103.2h-81.7v81.7h81.7v-81.7ZM295.22,103.2h-81.7v81.7h81.7v-81.7ZM400.77,103.2h-81.7v81.7h81.7v-81.7ZM400.77,0h-81.7v81.7h81.7V0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" id="icon-hub" viewBox="0 -4 42 50" stroke-width="2" fill-rule="nonzero" width="100%" height="100%">
<path d="M37.176371,36.2324812 C37.1920117,36.8041095 36.7372743,37.270685 36.1684891,37.270685 L3.74335204,37.2703476 C3.17827583,37.2703476 2.72400056,36.8091818 2.72400056,36.2397767 L2.72400056,19.6131383 C1.4312007,18.4881431 0.662551336,16.8884326 0.662551336,15.1618249 L0.664207893,14.69503 C0.63774183,14.4532127 0.650524255,14.2942438 0.711604827,14.1238231 L5.10793246,1.20935468 C5.24853286,0.797020623 5.63848594,0.511627907 6.06681069,0.511627907 L34.0728364,0.511627907 C34.5091607,0.511627907 34.889927,0.793578201 35.0316653,1.20921034 L39.4428567,14.1234095 C39.4871296,14.273204 39.5020782,14.4249444 39.4884726,14.5493649 L39.4884726,15.1505835 C39.4884726,16.9959517 38.6190601,18.6883031 37.1764746,19.7563084 L37.176371,36.2324812 Z M35.1376208,35.209311 L35.1376208,20.7057152 C34.7023924,20.8097593 34.271333,20.8633641 33.8336069,20.8633641 C32.0046019,20.8633641 30.3013756,19.9547008 29.2437221,18.4771538 C28.1860473,19.954695 26.4828515,20.8633641 24.6538444,20.8633641 C22.824803,20.8633641 21.1216155,19.9547157 20.0639591,18.4771544 C19.0062842,19.9546953 17.3030887,20.8633641 15.4740818,20.8633641 C13.6450404,20.8633641 11.9418529,19.9547157 10.8841965,18.4771544 C9.82652161,19.9546953 8.12332608,20.8633641 6.29431919,20.8633641 C5.76735555,20.8633641 5.24095778,20.7883418 4.73973398,20.644674 L4.73973398,35.209311 L35.1376208,35.209311 Z M30.2720226,15.6557626 C30.5154632,17.4501192 32.0503909,18.8018554 33.845083,18.8018554 C35.7286794,18.8018554 37.285413,17.3395134 37.4474599,15.4751932 L30.2280765,15.4751932 C30.2470638,15.532987 30.2617919,15.5932958 30.2720226,15.6557626 Z M21.0484306,15.4751932 C21.0674179,15.532987 21.0821459,15.5932958 21.0923767,15.6557626 C21.3358173,17.4501192 22.8707449,18.8018554 24.665437,18.8018554 C26.4601001,18.8018554 27.9950169,17.4501481 28.2378191,15.6611556 C28.2451225,15.5981318 28.2590045,15.5358056 28.2787375,15.4751932 L21.0484306,15.4751932 Z M11.9238102,15.6557626 C12.1672508,17.4501192 13.7021785,18.8018554 15.4968705,18.8018554 C17.2915336,18.8018554 18.8264505,17.4501481 19.0692526,15.6611556 C19.0765561,15.5981318 19.0904381,15.5358056 19.110171,15.4751932 L11.8798641,15.4751932 C11.8988514,15.532987 11.9135795,15.5932958 11.9238102,15.6557626 Z M6.31682805,18.8018317 C8.11149114,18.8018317 9.64640798,17.4501244 9.88921012,15.6611319 C9.89651357,15.5981081 9.91039559,15.5357819 9.93012856,15.4751696 L2.70318796,15.4751696 C2.86612006,17.3346852 4.42809696,18.8018317 6.31682805,18.8018317 Z M3.09670082,13.4139924 L37.04257,13.4139924 L33.3489482,2.57204736 L6.80119239,2.57204736 L3.09670082,13.4139924 Z"
id="Fill-1"></path>
<rect id="Rectangle-3" x="14" y="26" width="6" height="10"></rect>
<path d="M20,26 L20,36 L26,36 L26,26 L20,26 Z" id="Rectangle-3"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0px" y="0px" width="100%" height="100%" viewBox="0 0 48.723 48.723" xml:space="preserve">
<path d="M7.452,24.152h3.435v5.701h0.633c0.001,0,0.001,0,0.002,0h0.636v-5.701h3.51v-1.059h17.124v1.104h3.178v5.656h0.619 c0,0,0,0,0.002,0h0.619v-5.656h3.736v-0.856c0-0.012,0.006-0.021,0.006-0.032c0-0.072,0-0.143,0-0.215h5.721v-1.316h-5.721 c0-0.054,0-0.108,0-0.164c0-0.011-0.006-0.021-0.006-0.032v-0.832h-8.154v1.028h-7.911v-2.652h-0.689c-0.001,0-0.001,0-0.002,0 h-0.678v2.652h-7.846v-1.104H7.452v1.104H1.114v1.316h6.338V24.152z" />
<path d="M21.484,16.849h5.204v-2.611h7.133V1.555H14.588v12.683h6.896V16.849z M16.537,12.288V3.505h15.335v8.783H16.537z" />
<rect x="18.682" y="16.898" width="10.809" height="0.537" />
<path d="M0,43.971h6.896v2.611H12.1v-2.611h7.134V31.287H0V43.971z M1.95,33.236h15.334v8.785H1.95V33.236z" />
<rect x="4.095" y="46.631" width="10.808" height="0.537" />
<path d="M29.491,30.994v12.684h6.895v2.611h5.205v-2.611h7.133V30.994H29.491z M46.774,41.729H31.44v-8.783h15.334V41.729z" />
<rect x="33.584" y="46.338" width="10.809" height="0.537" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,144 @@
'use strict';
'require form';
'require fs';
'require tools.widgets as widgets';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return L.view.extend({
render() {
const m = new form.Map('dockerd', _('Docker - Configuration'),
_('DockerMan is a simple docker manager client for LuCI'));
let o, t, s, ss;
s = m.section(form.NamedSection, 'globals', 'section', _('Global settings'));
t = s.tab('globals', _('Globals'));
o = s.taboption('globals', form.Value, 'ps_flags',
_('Default ps flags'),
_('Flags passed to docker top (ps). Leave empty to use the built-in default.'));
o.placeholder = '-ww';
o.rmempty = true;
o.optional = true;
o = s.taboption('globals', form.Value, 'api_version',
_('Api Version'),
_('Lock API endpoint to a specific version (helps guarantee behaviour).') + '<br/>' +
_('Causes errors when a chosen API > Docker endpoint API support.'));
o.rmempty = true;
o.optional = true;
o.value('v1.44');
o.value('v1.45');
o.value('v1.46');
o.value('v1.47');
o.value('v1.48');
o.value('v1.49');
o.value('v1.50');
o.value('v1.51');
o.value('v1.52');
// Check if local dockerd is available
o = s.taboption('globals', form.DirectoryPicker, 'data_root', _('Docker Root Dir'),
_('For local dockerd socket instances only.'));
o.datatype = 'folder';
o.default = '/opt/docker';
o.root_directory = '/';
o.show_hidden = true;
o = s.taboption('globals', form.Value, 'bip',
_('Default bridge'),
_('Configure the default bridge network'));
o.placeholder = '172.17.0.1/16';
o.datatype = 'ipaddr';
o = s.taboption('globals', form.DynamicList, 'registry_mirrors',
_('Registry Mirrors'),
_('It replaces the daemon registry mirrors with a new set of registry mirrors'));
o.placeholder = _('Example: ') + 'https://hub-mirror.c.163.com';
o.value('https://docker.io');
o.value('https://ghcr.io');
o.value('https://hub-mirror.c.163.com');
o = s.taboption('globals', form.ListValue, 'log_level',
_('Log Level'),
_('Set the logging level'));
o.value('debug', _('Debug'));
o.value('', _('Info')); // default
o.value('warn', _('Warning'));
o.value('error', _('Error'));
o.value('fatal', _('Fatal'));
o.rmempty = true;
o = s.taboption('globals', form.DynamicList, 'hosts',
_('Client connection'),
_('Specifies where the Docker daemon will listen for client connections. default: ') + 'unix:///var/run/docker.sock' + '<br />' +
_('Note that dockerd no longer listens on IP:port without TLS options after v27.'));
o.placeholder = _('Example: tcp://0.0.0.0:2375');
o.default = 'unix:///var/run/docker.sock';
o.rmempty = true;
o.value('unix:///var/run/docker.sock');
o.value('tcp://0.0.0.0:2375');
o.value('tcp://0.0.0.0:2376');
o.value('tcp6://[::]:2375');
o.value('tcp6://[::]:2376');
o = s.taboption('globals', widgets.NetworkSelect, '_luci_lan',
_('LAN connection'),
_('Set your LAN interface when docker listens on all addresses like 0.0.0.0 or ::.'));
o.rmempty = true;
o.noaliases = true;
o.nocreate = true;
t = s.tab('auth', _('Registry Auth'));
o = s.taboption('auth', form.SectionValue, '__auth__', form.TableSection, 'auth', null,
_('Used for push/pull operations on custom registries.') + '<br/>' +
_('Destinations prefixed with a Registry host matching an entry in this table invoke its corresponding credentials.') + '<br/>' +
_('The first match is used.') + '<br/>' +
_('A Token is preferred over a Password.') + '<br/>' +
_('Tokes and Passwords are not encrypted in the uci configuration.'));
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = true;
ss.addremove = true;
ss.sortable = true;
o = ss.option(form.Value, 'username',
_('User'));
o.placeholder = 'jbloggs';
o.rmempty = false;
o = ss.option(form.Value, 'password',
_('Password'));
o.placeholder = 'foobar';
o.password = true;
o = ss.option(form.Value, 'serveraddress',
_('Registry'));
o.datatype = 'or(hostname,hostport,ipaddr,ipaddrport)';
o.placeholder = 'registry.foo.io[:443] | 192.0.2.1[:443]';
o.rmempty = false;
o.value('container-registry.oracle.com');
o.value('registry.docker.io');
o.value('gcr.io');
o.value('ghcr.io');
o.value('quay.io');
o.value('registry.gitlab.com');
o.value('registry.redhat.io');
o = ss.option(form.Value, 'token',
_('Token'));
o.password = true;
return m.render();
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,950 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/* API v1.52
POST /containers/create no longer supports configuring a container-wide MAC
address via the container's Config.MacAddress field. A container's MAC address
can now only be configured via endpoint settings when connecting to a network.
*/
return dm2.dv.extend({
load() {
const requestPath = L.env.requestpath;
const duplicateId = requestPath[requestPath.length-1];
const isDuplicate = requestPath[requestPath.length-2] === 'duplicate' && duplicateId;
const promises = [
dm2.image_list().then(images => {
return images.body || [];
}),
dm2.network_list().then(networks => {
return networks.body || [];
}),
dm2.volume_list().then(volumes => {
return volumes.body?.Volumes || [];
}),
dm2.docker_info().then(info => {
const numcpus = info.body?.NCPU || 1.0;
const memory = info.body?.MemTotal || 2**10;
return {numcpus: numcpus, memory: memory};
}),
];
if (isDuplicate) {
promises.push(
dm2.container_inspect({ id: duplicateId }).then(container => {
this.duplicateContainer = container.body || {};
this.isDuplicate = true;
})
);
}
return Promise.all(promises);
},
render([image_list, network_list, volume_list, cpus_mem]) {
this.volumes = volume_list;
const view = this; // Capture the view context
// Load duplicate container config if available
let containerData = {container: {}};
let pageTitle = _('Create new docker container');
if (this.isDuplicate && this.duplicateContainer) {
pageTitle = _('Duplicate/Edit Container: %s').format(this.duplicateContainer.Name?.substring(1) || '');
const resolveImageId = (imageRef) => {
if (!imageRef) return null;
const match = (image_list || []).find(img => img.Id === imageRef || (Array.isArray(img.RepoTags) && img.RepoTags.includes(imageRef)));
return match?.Id || null;
};
const c = this.duplicateContainer;
const hostConfig = c.HostConfig || {};
const resolvedImage = resolveImageId(c.Image) || resolveImageId(c.Config?.Image) || c.Image || c.Config?.Image || '';
const builtInNetworks = new Set(['none', 'bridge', 'host']);
const [netnames, nets] = Object.entries(c.NetworkSettings?.Networks || {});
containerData.container = {
name: c.Name?.substring(1) || '',
interactive: c.Config?.AttachStdin ? 1 : 0,
tty: c.Config?.Tty ? 1 : 0,
image: resolvedImage,
privileged: hostConfig.Privileged ? 1 : 0,
restart_policy: hostConfig.RestartPolicy?.Name || 'unless-stopped',
network: (() => {
return (netnames && (netnames.length > 0)) ? netnames[0] : '';
})(),
ipv4: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.IPAddress || '' : '';
})(),
ipv6: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.GlobalIPv6Address || '' : '';
})(),
ipv6_lla: (() => {
if (builtInNetworks.has(netnames[0])) return '';
return (nets && (nets.length > 0)) ? nets[0]?.LinkLocalIPv6Address || '' : '';
})(),
link: hostConfig.Links || [],
dns: hostConfig.Dns || [],
user: c.Config?.User || '',
env: c.Config?.Env || [],
volume: (hostConfig.Mounts || c.Mounts || []).map(m => {
let source;
const destination = m.Destination || m.Target || '';
let opts = '';
if (m.Type === 'image') {
source = '@image';
if (m.ImageOptions && m.ImageOptions.Subpath)
opts = 'subpath=' + m.ImageOptions.Subpath;
} else if (m.Type === 'tmpfs') {
source = '@tmpfs';
const tmpOpts = [];
if (m.TmpfsOptions) {
if (m.TmpfsOptions.SizeBytes) tmpOpts.push('size=' + m.TmpfsOptions.SizeBytes);
if (m.TmpfsOptions.Mode) tmpOpts.push('mode=' + m.TmpfsOptions.Mode);
if (Array.isArray(m.TmpfsOptions.Options)) {
for (const o of m.TmpfsOptions.Options) {
if (Array.isArray(o) && o.length === 2) tmpOpts.push(`${o[0]}=${o[1]}`);
else if (Array.isArray(o) && o.length === 1) tmpOpts.push(o[0]);
}
}
}
opts = tmpOpts.join(',');
} else if (m.Type === 'volume') {
source = m.Source || '';
// opts = m.Mode || '';
} else {
source = m.Source || '';
opts = m.Mode || '';
}
return source + ':' + destination + (opts ? ':' + opts : '');
}),
publish: (() => {
const ports = [];
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
const hostPort = bindings[0].HostPort;
ports.push(hostPort + ':' + containerPort);
}
}
return ports;
})(),
command: c.Config?.Cmd ? c.Config?.Cmd.join(' ') : '',
hostname: c.Config?.Hostname || '',
publish_all: hostConfig.PublishAllPorts ? 1 : 0,
device: (hostConfig.Devices || []).map(d => d.PathOnHost + ':' + d.PathInContainer + (d.CgroupPermissions ? ':' + d.CgroupPermissions : '')),
tmpfs: (() => {
const list = [];
if (hostConfig.Tmpfs && typeof hostConfig.Tmpfs === 'object') {
for (const [path, opts] of Object.entries(hostConfig.Tmpfs)) {
list.push(path + (opts ? ':' + opts : ''));
}
}
return list;
})(),
sysctl: (() => {
const list = [];
if (hostConfig.Sysctls && typeof hostConfig.Sysctls === 'object') {
for (const [key, value] of Object.entries(hostConfig.Sysctls)) {
list.push(key + ':' + value);
}
}
return list;
})(),
cap_add: hostConfig.CapAdd || [],
cpus: hostConfig.NanoCPUs ? (hostConfig.NanoCPUs / (10 ** 9)).toFixed(3) : '',
cpu_shares: hostConfig.CpuShares || '',
cpu_period: hostConfig.CpuPeriod || '',
cpu_quota: hostConfig.CpuQuota || '',
memory: hostConfig.Memory || '',
memory_reservation: hostConfig.MemoryReservation || '',
blkio_weight: hostConfig.BlkioWeight || '',
log_opt: (() => {
const list = [];
const logConfig = hostConfig.LogConfig?.Config;
if (logConfig && typeof logConfig === 'object') {
for (const [key, value] of Object.entries(logConfig)) {
list.push(key + '=' + value);
}
}
return list;
})(),
};
}
// stuff JSONMap with container config
const m = new form.JSONMap(containerData, _('Docker - Containers'));
m.submit = true;
m.reset = true;
let s = m.section(form.NamedSection, 'container', pageTitle);
s.anonymous = true;
s.nodescriptions = true;
s.addremove = false;
let o;
o = s.option(form.Value, 'name', _('Container Name'),
_('Name of the container that can be selected during container creation'));
o.rmempty = true;
o = s.option(form.Flag, 'interactive', _('Interactive (-i)'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Flag, 'tty', _('TTY (-t)'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.ListValue, 'image', _('Docker Image'));
o.rmempty = true;
for (const image of image_list) {
o.value(image.Id, image?.RepoTags?.[0]);
}
o = s.option(form.Flag, 'pull', _('Always pull image first'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Flag, 'privileged', _('Privileged'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.ListValue, 'restart_policy', _('Restart Policy'));
o.rmempty = true;
o.default = 'unless-stopped';
o.value('no', _('No'));
o.value('unless-stopped', _('Unless stopped'));
o.value('always', _('Always'));
o.value('on-failure', _('On failure'));
o = s.option(form.ListValue, 'network', _('Networks'));
o.rmempty = true;
this.buildNetworkListValues(network_list, o);
function not_with_a_docker_net(section_id, value) {
if (!value || value === "") return true;
const builtInNetworks = new Set(['none', 'bridge', 'host']);
let dnet = this.section.getOption('network').getUIElement(section_id).getValue();
const disallowed = builtInNetworks.has(dnet);
if (disallowed) return _('Only for user-defined networks');
};
o = s.option(form.Value, 'ipv4', _('IPv4 Address'));
o.rmempty = true;
o.datatype = 'ip4addr';
o.validate = not_with_a_docker_net;
o = s.option(form.Value, 'ipv6', _('IPv6 Address'));
o.rmempty = true;
o.datatype = 'ip6addr';
o.validate = not_with_a_docker_net;
o = s.option(form.Value, 'ipv6_lla', _('IPv6 Link-Local Address'));
o.rmempty = true;
o.datatype = 'ip6ll';
o.validate = not_with_a_docker_net;
o = s.option(form.DynamicList, 'link', _('Links with other containers'));
o.rmempty = true;
o.placeholder='container_name:alias';
o = s.option(form.DynamicList, 'dns', _('Set custom DNS servers'));
o.rmempty = true;
o.placeholder='8.8.8.8';
o = s.option(form.Value, 'user', _('User(-u)'),
_('The user that commands are run as inside the container. (format: name|uid[:group|gid])'));
o.rmempty = true;
o.placeholder='1000:1000';
o = s.option(form.DynamicList, 'env', _('Environmental Variable(-e)'),
_('Set environment variables inside the container'));
o.rmempty = true;
o.placeholder='TZ=Europe/Paris';
o = s.option(form.DummyValue, 'volume', _('Mount(--mount)'),
_('Bind mount a volume'));
o.rmempty = true;
o.cfgvalue = () => {
const c_volumes = view.map.data.get('json', 'container', 'volume') || [];
const showVolumeModal = (index, initialEntry) => {
let typeSelect, bindPicker, bindSourceField, volumeNameInput, volumeSourceField, pathInput, pathField, optionsDropdown, optionsField, subpathInput;
let tmpfsSizeInput, tmpfsModeInput, tmpfsOptsInput, tmpfsSizeField, tmpfsModeField, tmpfsOptsField;
const isEdit = index !== null;
const modalTitle = isEdit ? _('Edit Mount') : _('Add Mount');
// Parse existing entry if editing and infer type from volumes list, image, or tmpfs
let initialType = 'bind', initialSource = '', initialPath = '', initialOptions = '';
if (isEdit && initialEntry) {
const parts = (typeof initialEntry === 'string' ? initialEntry : '').split(':');
initialSource = parts[0] || '';
initialPath = parts[1] || '';
initialOptions = parts[2] || '';
// Infer type: tmpfs, volume, image, else bind
const isTmpfs = (initialSource === '@tmpfs');
const isVolume = (volume_list || []).some(v => v.Name === initialSource || v.Id === initialSource);
const isImage = (initialSource === '@image');
initialType = isTmpfs ? 'tmpfs' : (isVolume ? 'volume' : (isImage ? 'image' : 'bind'));
}
const existingOptions = (typeof initialOptions === 'string' ? initialOptions : '').split(',').map(o => o.trim()).filter(Boolean);
// Type-specific options for dropdowns
const bindOptions = {
'ro': _('Read-only (ro)'),
'rw': _('Read-write (rw)'),
'private': _('Propagation: private'),
'rprivate': _('Propagation: rprivate'),
'shared': _('Propagation: shared'),
'rshared': _('Propagation: rshared'),
'slave': _('Propagation: slave'),
'rslave': _('Propagation: rslave')
};
const volumeOptions = {
// 'ro': _('Read-only (ro)'),
// 'rw': _('Read-write (rw)'),
'nocopy': _('No copy (nocopy)')
};
const getOptionsForType = (type) => type === 'bind' ? bindOptions : volumeOptions;
const namesListId = 'volname-list-' + Math.random().toString(36).substr(2, 9);
// Create dropdown for options - updates based on type
optionsDropdown = new ui.Dropdown(existingOptions, getOptionsForType(initialType), {
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
multiple: true,
optional: true,
display_items: 2,
placeholder: _('Select options...')
});
const createField = (label, input) => {
return E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, label),
E('div', { 'class': 'cbi-value-field' }, Array.isArray(input) ? input : [input])
]);
};
// Type select
const typeOptions = [
E('option', { value: 'bind' }, _('Bind (host directory)')),
E('option', { value: 'volume' }, _('Volume (named)')),
E('option', { value: 'image' }, _('Image (from image)')),
E('option', { value: 'tmpfs' }, _('Tmpfs'))
];
typeSelect = E('select', { 'class': 'cbi-input-select' }, typeOptions);
typeSelect.value = initialType;
// Bind directory picker using ui.FileUpload
bindPicker = new ui.FileUpload(initialType === 'bind' ? initialSource : '', {
browser: false,
directory_select: true,
directory_create: false,
enable_upload: false,
enable_remove: false,
enable_download: false,
root_directory: '/',
show_hidden: true
});
// Volume name input with datalist
volumeNameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('Enter volume name or pick existing'),
'list': namesListId,
'value': initialType === 'volume' ? initialSource : ''
});
volumeSourceField = createField(_('Volume Name'), [
E('div', { 'style': 'position: relative;' }, [
volumeNameInput,
E('span', { 'style': 'pointer-events: none;' }, '▼')
]),
E('datalist', { 'id': namesListId }, [
...volume_list.map(vol => E('option', { 'value': vol.Name }, vol.Name))
])
]);
// Tmpfs inputs - pre-populate if editing
let tmpfsSizeVal = '', tmpfsModeVal = '', tmpfsOptsVal = '';
if (initialType === 'tmpfs' && existingOptions.length) {
const rest = [];
existingOptions.forEach(o => {
if (o.startsWith('size=')) tmpfsSizeVal = o.slice('size='.length);
else if (o.startsWith('mode=')) tmpfsModeVal = view.modeToRwx(o.slice('mode='.length));
else rest.push(o);
});
tmpfsOptsVal = rest.join(',');
}
tmpfsSizeField = createField(_('Size'),
tmpfsSizeInput = E('input', {
'class': 'cbi-input-text',
'placeholder': '128m',
'value': tmpfsSizeVal
})
);
tmpfsModeField = createField(_('Mode'),
tmpfsModeInput = E('input', {
'class': 'cbi-input-text',
'placeholder': 'rwxr-xr-x or 1770',
'value': tmpfsModeVal
})
);
tmpfsOptsField = createField(_('tmpfs Options'),
tmpfsOptsInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'nr_blocks=blocks,...',
'value': tmpfsOptsVal
})
);
// Render bindPicker and show modal
Promise.resolve(bindPicker.render()).then(bindPickerNode => {
bindSourceField = createField(_('Host Directory'), bindPickerNode);
const updateOptions = (selectedType) => {
optionsField.querySelector('.cbi-value-field').innerHTML = '';
if (selectedType === 'image') {
// For image mounts, show a Subpath text input (only option)
subpathInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('/path/in/image'),
'value': (initialType === 'image' && existingOptions.find(o => o.startsWith('subpath='))) ? existingOptions.find(o => o.startsWith('subpath=')).slice('subpath='.length) : ''
});
optionsField.querySelector('.cbi-value-title').textContent = _('Subpath');
optionsField.querySelector('.cbi-value-field').appendChild(subpathInput);
} else if (selectedType === 'tmpfs') {
// Tmpfs fields are shown as main fields, hide options field
optionsField.style.display = 'none';
} else {
optionsField.querySelector('.cbi-value-title').textContent = _('Options');
// Recreate dropdown with new options
const currentValue = optionsDropdown.getValue();
optionsDropdown = new ui.Dropdown(currentValue, getOptionsForType(selectedType), {
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
multiple: true,
optional: true,
display_items: 2,
placeholder: _('Select options...')
});
optionsField.querySelector('.cbi-value-field').appendChild(optionsDropdown.render());
optionsField.style.display = '';
}
};
const toggleSources = () => {
const isBind = typeSelect.value === 'bind';
const isVolume = typeSelect.value === 'volume';
const isImage = typeSelect.value === 'image';
const isTmpfs = typeSelect.value === 'tmpfs';
bindSourceField.style.display = isBind ? '' : 'none';
volumeSourceField.style.display = isVolume ? '' : 'none';
pathField.style.display = isImage ? 'none' : '';
tmpfsSizeField.style.display = isTmpfs ? '' : 'none';
tmpfsModeField.style.display = isTmpfs ? '' : 'none';
tmpfsOptsField.style.display = isTmpfs ? '' : 'none';
updateOptions(typeSelect.value);
};
optionsField = createField(_('Options'), optionsDropdown.render());
ui.showModal(modalTitle, [
createField(_('Type'), typeSelect),
bindSourceField,
volumeSourceField,
pathField = createField(_('Mount Path'),
pathInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('/mnt/path'),
'value': initialPath
})
),
tmpfsSizeField,
tmpfsModeField,
tmpfsOptsField,
optionsField,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, [_('Cancel')]),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const selectedType = typeSelect.value;
const sourcePath = selectedType === 'bind'
? (bindPicker.getValue() || '').trim()
: (selectedType === 'volume'
? (volumeNameInput.value || '').trim()
: (selectedType === 'tmpfs' ? '@tmpfs' : '@image'));
const subpathVal = (selectedType === 'image') ? (subpathInput?.value || '').trim() : '';
const mountPath = (selectedType === 'image') ? subpathVal : pathInput.value.trim();
let selectedOptions;
if (selectedType === 'image') {
selectedOptions = subpathVal ? ('subpath=' + subpathVal) : '';
} else if (selectedType === 'tmpfs') {
const opts = [];
const sizeValRaw = (tmpfsSizeInput?.value || '').trim();
const modeValRaw = (tmpfsModeInput?.value || '').trim();
const extraVal = (tmpfsOptsInput?.value || '').trim();
const parsedSize = sizeValRaw ? view.parseMemory(sizeValRaw) : undefined;
const parsedMode = view.rwxToMode(modeValRaw);
if (parsedSize) opts.push('size=' + parsedSize);
else if (sizeValRaw) opts.push('size=' + sizeValRaw); // fallback if parse fails
if (parsedMode !== undefined) opts.push('mode=' + parsedMode);
if (extraVal) opts.push(...extraVal.split(',').map(o => o.trim()).filter(Boolean));
selectedOptions = opts.join(',');
} else {
selectedOptions = optionsDropdown.getValue().join(',');
}
if (!sourcePath) {
ui.addTimeLimitedNotification(null, [_('Please choose a directory or enter a volume name')], 3000, 'warning');
return;
}
if (selectedType !== 'image' && !mountPath) {
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
return;
}
if (selectedType === 'image' && !subpathVal) {
ui.addTimeLimitedNotification(null, [_('Please enter a subpath')], 3000, 'warning');
return;
}
if (selectedType === 'tmpfs' && !mountPath) {
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
return;
}
ui.hideModal();
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
const volumeEntry = selectedOptions ? (sourcePath + ':' + mountPath + ':' + selectedOptions) : (sourcePath + ':' + mountPath);
let updatedVolumes;
if (isEdit) {
updatedVolumes = [...currentVolumes];
updatedVolumes[index] = volumeEntry;
} else {
updatedVolumes = Array.isArray(currentVolumes) ? [...currentVolumes, volumeEntry] : [volumeEntry];
}
view.map.data.set('json', 'container', 'volume', updatedVolumes);
return view.map.render();
})
}, [isEdit ? _('Update') : _('Add')])
])
]);
toggleSources();
typeSelect.addEventListener('change', toggleSources);
});
};
return E('div', { 'class': 'cbi-dynlist' }, [
...(c_volumes.length > 0 ? c_volumes.map((v, idx) => E('div', {
'class': 'cbi-dynlist-item',
'style': 'display: flex; justify-content: space-between; align-items: center; padding: 8px 5px; margin-bottom: 8px; gap: 10px;'
}, [
E('span', {
'style': 'cursor: pointer; flex: 1;',
'click': ui.createHandlerFn(view, () => {
showVolumeModal(idx, v);
})
}, v),
E('button', {
'style': 'padding: 5px; color: #c44;',
'class': 'cbi-button-negative remove',
'title': _('Delete this volume mount'),
'click': ui.createHandlerFn(view, () => {
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
const updatedVolumes = currentVolumes.filter((_, i) => i !== idx);
view.map.data.set('json', 'container', 'volume', updatedVolumes);
return view.map.render();
})
}, ['✕'])
])) : [E('div', { 'style': 'padding: 5px; color: #999;' }, _('No volumes available'))]),
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(view, () => {
showVolumeModal(null, null);
})
}, [_('Add Mount')])
]);
};
o.rmempty = true;
o = s.option(form.DynamicList, 'publish', _('Exposed Ports(-p)'),
_("Publish container's port(s) to the host"));
o.rmempty = true;
o.placeholder='2200:22/tcp';
o = s.option(form.Value, 'command', _('Run command'));
o.rmempty = true;
o.placeholder='/bin/sh init.sh';
o = s.option(form.Flag, 'advanced', _('Advanced'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o = s.option(form.Value, 'hostname', _('Host Name'),
_('The hostname to use for the container'));
o.rmempty = true;
o.placeholder='/bin/sh init.sh';
o.depends('advanced', 1);
o = s.option(form.Flag, 'publish_all', _('Exposed All Ports(-P)'),
_("Allocates an ephemeral host port for all of a container's exposed ports"));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'device', _('Device(--device)'),
_('Add host device to the container'));
o.rmempty = true;
o.placeholder='/dev/sda:/dev/xvdc:rwm';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'tmpfs', _('Tmpfs(--tmpfs)'),
_('Mount tmpfs directory'));
o.rmempty = true;
o.placeholder='/run:rw,noexec,nosuid,size=65536k';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'sysctl', _('Sysctl(--sysctl)'),
_('Sysctls (kernel parameters) options'));
o.rmempty = true;
o.placeholder='net.ipv4.ip_forward=1';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'cap_add', _('CAP-ADD(--cap-add)'),
_('A list of kernel capabilities to add to the container'));
o.rmempty = true;
o.placeholder='NET_ADMIN';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpus', _('CPUs'),
_('Number of CPUs. Number is a fractional number. 0.000 means no limit'));
o.rmempty = true;
o.placeholder='1.5';
o.datatype = 'ufloat';
o.depends('advanced', 1);
o.validate = function(section_id, value) {
if (!value) return true;
if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
return true;
};
o = s.option(form.Value, 'cpu_period', _('CPU Period'),
_('The length of a CPU period in microseconds'));
o.rmempty = true;
o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpu_quota', _('CPU Quota'),
_('Microseconds of CPU time that the container can get in a CPU period'));
o.rmempty = true;
o.datatype = 'uinteger';
o.depends('advanced', 1);
o = s.option(form.Value, 'cpu_shares', _('CPU Shares Weight'),
_('CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024'));
o.rmempty = true;
o.placeholder='1024';
o.datatype = 'uinteger';
o.depends('advanced', 1);
o = s.option(form.Value, 'memory', _('Memory'),
_('Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M'));
o.rmempty = true;
o.placeholder = '128m';
o.depends('advanced', 1);
o.write = function(section_id, value) {
if (!value || value == 0) return 0;
this.map.data.data[section_id].memory = view.parseMemory(value);;
return view.parseMemory(value);
};
o.validate = function(section_id, value) {
if (!value) return true;
if (value > view.memory) return _(`Only ${view.memory} bytes available`);
return true;
};
o = s.option(form.Value, 'memory_reservation', _('Memory Reservation'));
o.depends('advanced', 1);
o.placeholder = '128m';
o.cfgvalue = (sid, val) => {
const res = view.map.data.data[sid].memory_reservation;
return res ? '%1024.2m'.format(res) : 0;
};
o.write = function(section_id, value) {
if (!value || value == 0) return 0;
this.map.data.data[section_id].memory_reservation = view.parseMemory(value);;
return view.parseMemory(value);
};
o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'),
_('Block IO weight (relative weight) accepts a weight value between 10 and 1000.'));
o.rmempty = true;
o.placeholder='500';
o.datatype = 'and(uinteger,min(10),max(1000))';
o.depends('advanced', 1);
o = s.option(form.DynamicList, 'log_opt', _('Log driver options'),
_('The logging configuration for this container'));
o.rmempty = true;
o.placeholder='max-size=1m';
o.depends('advanced', 1);
this.map = m;
return m.render();
},
handleSave(ev) {
ev?.preventDefault();
const view = this; // Capture the view context
const map = this.map;
if (!map)
return Promise.reject(new Error(_('Form is not ready yet.')));
const listToKv = view.listToKv;
const toBool = (val) => (val === 1 || val === '1' || val === true);
const toInt = (val) => val ? Number.parseInt(val) : undefined;
const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
return map.parse()
.then(() => {
const get = (opt) => map.data.get('json', 'container', opt);
const name = get('name');
// const pull = toBool(get('pull'));
const network = get('network');
const publish = get('publish');
const command = get('command');
// const publish_all = toBool(get('publish_all'));
const device = get('device');
const tmpfs = get('tmpfs');
const sysctl = get('sysctl');
const log_opt = get('log_opt');
const createBody = {
Hostname: get('hostname'),
User: get('user'),
AttachStdin: toBool(get('interactive')),
Tty: toBool(get('tty')),
OpenStdin: toBool(get('interactive')),
Env: get('env'),
Cmd: command ? command.split(' ') : null,
Image: get('image'),
HostConfig: {
CpuShares: toInt(get('cpu_shares')),
Memory: toInt(get('memory')),
MemoryReservation: toInt(get('memory_reservation')),
BlkioWeight: toInt(get('blkio_weight')),
CapAdd: get('cap_add'),
CpuPeriod: toInt(get('cpu_period')),
CpuQuota: toInt(get('cpu_quota')),
NanoCPUs: toFloat(get('cpus')) * (10 ** 9),
Devices: device ? device
.filter(d => d && typeof d === 'string' && d.trim().length > 0)
.map(d => {
const parts = d.split(':');
return {
PathOnHost: parts[0],
PathInContainer: parts[1] || parts[0],
CgroupPermissions: parts[2] || 'rwm'
};
}) : undefined,
LogConfig: log_opt ? {
Type: 'json-file',
Config: listToKv(log_opt)
} : undefined,
NetworkMode: network,
PortBindings: publish ? Object.fromEntries(
(Array.isArray(publish) ? publish : [publish])
.filter(p => p && typeof p === 'string' && p.trim().length > 0)
.map(p => {
const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
return null;
}).filter(Boolean)
) : undefined,
Mounts: undefined,
Links: get('link'),
Privileged: toBool(get('privileged')),
PublishAllPorts: toBool(get('publish_all')),
RestartPolicy: { Name: get('restart_policy') },
Dns: get('dns'),
Tmpfs: tmpfs ? Object.fromEntries(
(Array.isArray(tmpfs) ? tmpfs : [tmpfs])
.filter(t => t && typeof t === 'string' && t.trim().length > 0)
.map(t => {
const parts = t.split(':');
return [parts[0], parts[1] || ''];
})
) : undefined,
Sysctls: sysctl ? listToKv(sysctl) : undefined,
},
NetworkingConfig: {
EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } },
}
};
// Parse volume entries and populate Mounts
const volumeEntries = get('volume') || [];
const volumeNames = new Set((view.volumes || []).map(v => v.Name));
const volumeIds = new Set((view.volumes || []).map(v => v.Id));
const mounts = [];
for (const entry of volumeEntries) {
let e = typeof entry === 'string' ? entry : '';
let f = e.split(':')?.map(e => e && e.trim() || '');
let source = f[0];
let target = f[1];
let options = f[2];
if (!options) options = '';
// Validate source and target are not empty
if (!source || !target) {
console.warn('Invalid volume entry (empty source or target):', entry);
continue;
}
// Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind
let type = 'bind';
if (source === '@image') {
type = 'image';
} else if (source === '@tmpfs') {
type = 'tmpfs';
} else if (volumeNames.has(source) || volumeIds.has(source)) {
type = 'volume';
}
const mount = {
Type: type,
Source: source,
Target: target,
ReadOnly: options.split(',').includes('ro')
};
// Add type-specific options
if (type === 'bind') {
const bindOptions = {};
const propagation = options.split(',').find(opt =>
['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt)
);
if (propagation) bindOptions.Propagation = propagation;
if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions;
} else if (type === 'volume') {
const volumeOptions = {};
if (options.includes('nocopy')) volumeOptions.NoCopy = true;
if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions;
} else if (type === 'image') {
const imageOptions = {};
const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath='));
if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length);
if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions;
// Image source is implied by selected container image
mount.Source = createBody.Image;
} else if (type === 'tmpfs') {
const tmpfsOptions = {};
const optsList = options.split(',').map(o => o.trim()).filter(Boolean);
for (const opt of optsList) {
if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length));
else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length));
else {
if (!tmpfsOptions.Options) tmpfsOptions.Options = [];
const kv = opt.split('=');
if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]);
else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]);
}
}
mount.Source = '';
if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions;
}
mounts.push(mount);
}
createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined;
// Clean up undefined values
Object.keys(createBody.HostConfig).forEach(key => {
if (createBody.HostConfig[key] === undefined)
delete createBody.HostConfig[key];
});
if (!name)
return Promise.reject(new Error(_('No name specified.')));
return { name, createBody };
})
.then(({ name, createBody }) => view.executeDockerAction(
dm2.container_create,
{ query: { name: name }, body: createBody },
_('Create container'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
const isDuplicate = view.isDuplicate && view.duplicateContainer;
const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created');
const msgText = isDuplicate ?
_('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) :
_('New container has been created.');
if (response?.body?.Warnings) {
view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning');
} else {
view.showNotification(msgTitle, msgText, 4000, 'success');
}
window.location.href = `${this.dockerman_url}/containers`;
}
}
))
.catch((err) => {
view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error');
return false;
});
},
handleSaveApply: null,
handleReset: null,
});
@@ -0,0 +1,361 @@
'use strict';
'require form';
'require fs';
'require poll';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/* API v1.52:
GET /containers/{id}/json: the NetworkSettings no longer returns the deprecated
Bridge, HairpinMode, LinkLocalIPv6Address, LinkLocalIPv6PrefixLen,
SecondaryIPAddresses, SecondaryIPv6Addresses, EndpointID, Gateway,
GlobalIPv6Address, GlobalIPv6PrefixLen, IPAddress, IPPrefixLen, IPv6Gateway,
and MacAddress fields. These fields were deprecated in API v1.21 (docker
v1.9.0) but kept around for backward compatibility.
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.container_list({query: {all: true}}),
dm2.image_list({query: {all: true}}),
dm2.network_list({query: {all: true}}),
]);
},
render([containers, images, networks]) {
if (containers?.code !== 200) {
return E('div', {}, [ containers?.body?.message ]);
}
let container_list = containers.body;
let network_list = networks.body;
let image_list = images.body;
const view = this;
let containerTable;
const m = new form.JSONMap({container: view.getContainersTable(container_list, image_list, network_list), prune: {}},
_('Docker - Containers'),
_('This page displays all docker Containers that have been created on the connected docker host.') + '<br />' +
_('Note: docker provides no container import facility.'));
m.submit = false;
m.reset = false;
let s, o;
let pollPending = null;
let conSec = null;
const calculateTotals = () => {
return {
running_total: Array.isArray(container_list) ?
container_list.filter(c => c?.State === 'running').length : 0,
paused_total: Array.isArray(container_list) ?
container_list.filter(c => c?.State === 'paused').length : 0,
stopped_total: Array.isArray(container_list) ?
container_list.filter(c => ['exited', 'created'].includes(c?.State)).length : 0
};
};
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([containers2, images2, networks2]) => {
image_list = images2.body;
container_list = containers2.body;
network_list = networks2.body;
m.data = new m.data.constructor({ container: view.getContainersTable(container_list, image_list, network_list), prune: {} });
const totals = calculateTotals();
if (conSec) {
conSec.footer = [
`${_('Total')} ${container_list.length}`,
[
`${_('Running')} ${totals.running_total}`,
E('br'),
`${_('Paused')} ${totals.paused_total}`,
E('br'),
`${_('Stopped')} ${totals.stopped_total}`,
],
'',
'',
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
s = m.section(form.TableSection, 'prune', _('Containers overview'), null);
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(section_id, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/containers/prune',
commandDPath: '/containers/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch { }
},
noFileUpload: true,
}]);
}, this);
const totals = calculateTotals();
let running_total = totals.running_total;
let paused_total = totals.paused_total;
let stopped_total = totals.stopped_total;
conSec = m.section(form.TableSection, 'container');
conSec.anonymous = true;
conSec.nodescriptions = true;
conSec.addremove = true;
conSec.sortable = true;
conSec.filterrow = true;
conSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
conSec.footer = [
`${_('Total')} ${container_list.length}`,
[
`${_('Running')} ${running_total}`,
E('br'),
`${_('Paused')} ${paused_total}`,
E('br'),
`${_('Stopped')} ${stopped_total}`,
],
'',
'',
];
conSec.handleAdd = function(section_id, ev) {
window.location.href = `${view.dockerman_url}/container_new`;
};
conSec.renderRowActions = function(sid) {
const cont = this.map.data.data[sid];
return view.buildContainerActions(cont);
}
o = conSec.option(form.DummyValue, 'cid', _('Container'));
o = conSec.option(form.DummyValue, 'State', _('State'));
o = conSec.option(form.DummyValue, 'Networks', _('Networks'));
o.rawhtml = true;
o = conSec.option(form.DummyValue, 'Ports', _('Ports'));
o.rawhtml = true;
o = conSec.option(form.DummyValue, 'Command', _('Command'));
o.width = 200;
o = conSec.option(form.DummyValue, 'Created', _('Created'));
poll.add(L.bind(() => { refresh(); }, this), 10);
this.insertOutputFrame(conSec, m);
return m.render();
},
buildContainerActions(cont, idx) {
const view = this;
const isRunning = cont?.State === 'running';
const isPaused = cont?.State === 'paused';
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': () => view.executeDockerAction(
dm2.container_inspect,
{id: cont.Id},
dm2.ActionTypes['inspect'].i18n,
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['inspect'].e]),
E('button', {
'class': 'cbi-button cbi-button-positive edit',
'title': _('Edit this container'),
'click': () => window.location.href = `${view.dockerman_url}/container/${cont?.Id}`
}, [dm2.ActionTypes['edit'].e]),
(() => {
const icon = isRunning
? dm2.Types['container'].sub['pause'].e
: (isPaused
? dm2.Types['container'].sub['unpause'].e
: dm2.Types['container'].sub['start'].e);
const title = isRunning
? _('Pause this container')
: (isPaused ? _('Unpause this container') : _('Start this container'));
const handler = isRunning
? () => view.executeDockerAction(
dm2.container_pause,
{id: cont.Id},
dm2.Types['container'].sub['pause'].i18n,
{showOutput: true, showSuccess: false}
)
: (isPaused ? () => view.executeDockerAction(
dm2.container_unpause,
{id: cont.Id},
dm2.Types['container'].sub['unpause'].i18n,
{showOutput: true, showSuccess: false}
) : () => view.executeDockerAction(
dm2.container_start,
{id: cont.Id},
dm2.Types['container'].sub['start'].i18n,
{showOutput: true, showSuccess: false}
));
const btnClass = isRunning ? 'cbi-button cbi-button-neutral' : 'cbi-button cbi-button-positive start';
return E('button', {
'class': btnClass,
'title': title,
'click': handler,
}, [icon]);
})(),
E('button', {
'class': 'cbi-button cbi-button-neutral restart',
'title': _('Restart this container'),
'click': () => view.executeDockerAction(
dm2.container_restart,
{id: cont.Id},
_('Restart'),
{showOutput: true, showSuccess: false}
)
}, [dm2.Types['container'].sub['restart'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral stop',
'title': _('Stop this container'),
'click': () => view.executeDockerAction(
dm2.container_stop,
{id: cont.Id},
dm2.Types['container'].sub['stop'].i18n,
{showOutput: true, showSuccess: false}
),
'disabled' : !(isRunning || isPaused) ? true : null
}, [dm2.Types['container'].sub['stop'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative kill',
'title': _('Kill this container'),
'click': () => view.executeDockerAction(
dm2.container_kill,
{id: cont.Id},
dm2.Types['container'].sub['kill'].i18n,
{showOutput: true, showSuccess: false}
),
'disabled' : !(isRunning || isPaused) ? true : null
}, [dm2.Types['container'].sub['kill'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral export',
'title': _('Export this container'),
'click': () => {
window.location.href = `${view.dockerman_url}/container/export/${cont.Id}`;
}
}, [dm2.Types['container'].sub['export'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': () => view.executeDockerAction(
dm2.container_remove,
{id: cont.Id, query: { force: false }},
dm2.ActionTypes['remove'].i18n,
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': () => view.executeDockerAction(
dm2.container_remove,
{id: cont.Id, query: { force: true }},
_('Force Remove'),
{showOutput: true, showSuccess: false}
)
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', {
'class': 'td',
}, E('div', btns));
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getContainersTable(containers, image_list, network_list) {
const data = [];
for (const cont of Array.isArray(containers) ? containers : []) {
// build Container ID: xxxxxxx image: xxxx
const names = Array.isArray(cont?.Names) ? cont.Names : [];
const cleanedNames = names
.map(n => (typeof n === 'string' ? n.substring(1) : ''))
.filter(Boolean)
.join(', ');
const statusColorName = this.wrapStatusText(cleanedNames, cont.State, 'font-weight:600;');
const imageName = this.getImageFirstTag(image_list, cont.ImageID);
const shortId = (cont?.Id || '').substring(0, 12);
const cid = E('div', {}, [
E('a', { href: `container/${cont.Id}`, title: dm2.ActionTypes['edit'].i18n }, [
statusColorName,
E('div', { 'style': 'font-size: 0.9em; font-family: monospace; ' }, [`ID: ${shortId}`]),
]),
E('div', { 'style': 'font-size: 0.85em;' }, [`${dm2.Types['image'].i18n}: ${imageName}`]),
])
// Just push plain data objects without UCI metadata
data.push({
...cont,
cid: cid,
_shortId: (cont?.Id || '').substring(0, 12),
Networks: this.parseNetworkLinksForContainer(network_list, cont?.NetworkSettings?.Networks || {}, true),
Created: this.buildTimeString(cont?.Created) || '',
Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
? cont.Ports.map(p => {
// const ip = p.IP || '';
const pub = p.PublicPort || '';
const priv = p.PrivatePort || '';
const type = p.Type || '';
return `${pub ? pub + ':' : ''}${priv}/${type}`;
// return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
}).join('<br/>')
: '',
});
}
return data;
},
});
@@ -0,0 +1,356 @@
'use strict';
'require form';
'require fs';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
LICENSE: GPLv2.0
*/
/* API v1.52
GET /events supports content-type negotiation and can produce either
application/x-ndjson (Newline delimited JSON object stream) or
application/json-seq (RFC7464).
application/x-ndjson:
{"some":"thing\n"}
{"some2":"thing2\n"}
...
application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
␞{"some":"thing\n"}␊
␞{"some2":"thing2\n"}␊
...
*/
return dm2.dv.extend({
load() {
const now = Math.floor(Date.now() / 1000);
this.js_api = false;
return Promise.all([
dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
dm2.js_api_ready.then(([ok, ]) => this.js_api = ok),
]);
},
render([events, js_api_available]) {
if (events?.code !== 200) {
return E('div', {}, [ events?.body?.message ]);
}
this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : '';
const event_list = events?.body || [];
const view = this;
const mainContainer = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, [_('Docker - Events')])
]);
// Filters
const now = new Date();
const nowIso = now.toISOString().slice(0, 16);
const filtersSection = E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Type')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'event-type-filter',
'class': 'cbi-input-select',
'change': () => {
view.updateSubtypeFilter(this.value);
view.renderEventsTable(event_list);
}
}, [
E('option', { 'value': '' }, _('All Types')),
...Object.keys(dm2.Types).map(type =>
E('option', { 'value': type }, `${dm2.Types[type].e} ${dm2.Types[type].i18n}`)
)
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Subtype')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'event-subtype-filter',
'class': 'cbi-input-select',
'disabled': true,
'change': () => {
view.renderEventsTable(event_list);
}
}, [
E('option', { 'value': '' }, _('Select Type First'))
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('From')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'id': 'event-from-date',
'type': 'datetime-local',
'value': '1970-01-01T00:00',
'step': 60,
'style': 'width: 180px;',
'change': () => { view.renderEventsTable(event_list); }
}),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const now = new Date();
const iso = now.toISOString().slice(0,16);
document.getElementById('event-from-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('Now')),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const unixzero = new Date(0);
const iso = unixzero.toISOString().slice(0,16);
document.getElementById('event-from-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('0'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('To')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'id': 'event-to-date',
'type': 'datetime-local',
'value': nowIso,
'step': 60,
'style': 'width: 180px;',
'change': () => { view.renderEventsTable(event_list); }
}),
E('button', {
'type': 'button',
'class': 'cbi-button',
'style': 'margin-left: 8px;',
'click': () => {
const now = new Date();
const iso = now.toISOString().slice(0,16);
document.getElementById('event-to-date').value = iso;
view.renderEventsTable(event_list);
}
}, _('Now'))
])
])
])
]);
mainContainer.appendChild(filtersSection);
this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' });
mainContainer.appendChild(this.tableSection);
this.renderEventsTable(event_list);
mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null));
return mainContainer;
},
renderEventsTable(event_list) {
const view = this;
// Get filter values
const typeFilter = document.getElementById('event-type-filter')?.value || '';
const subtypeFilter = document.getElementById('event-subtype-filter')?.value || '';
// Build filters object for docker_events API
const filters = {};
if (typeFilter) {
filters.type = [typeFilter];
}
if (subtypeFilter) {
filters.event = [subtypeFilter];
}
// Show loading indicator
this.tableSection.innerHTML = '';
// Query docker events with filters and date range
const fromInput = document.getElementById('event-from-date');
const toInput = document.getElementById('event-to-date');
let since = '0';
let until = Math.floor(Date.now() / 1000).toString();
if (fromInput && fromInput.value) {
const fromDate = new Date(fromInput.value);
if (!isNaN(fromDate.getTime())) {
since = Math.floor(fromDate.getTime() / 1000).toString();
since = since < 0 ? 0 : since;
}
}
if (toInput && toInput.value) {
const toDate = new Date(toInput.value);
if (!isNaN(toDate.getTime())) {
const now = Date.now() / 1000;
until = Math.floor(toDate.getTime() / 1000).toString();
until = !this.js_api ? until > now ? now : until : until;
}
}
const queryParams = { since, until };
if (Object.keys(filters).length > 0) {
// docker pre v27: filters => docker *streams* events. v27, send events in body.
// Some older dockerd endpoints don't like encoded filter params, even if we can't stream.
queryParams.filters = JSON.stringify(filters);
}
event_list = new Set();
view.outputText = '';
let eventsTable = null;
// Batching for speed
let batchBuffer = new Set();
let batchTimer = null;
const BATCH_SIZE = 256;
const BATCH_INTERVAL = 500; // ms
function updateTable() {
const ev_array = Array.from(event_list.keys());
const rows = ev_array.map(event => {
const type = event.Type;
const typeInfo = dm2.Types[type];
const typeDisplay = typeInfo ? `${typeInfo.e} ${typeInfo.i18n}` : type;
const actionParts = event.Action?.split(':') || [];
const action = actionParts.length > 0 ? actionParts[0] : '';
const action_sub = actionParts.length > 1 ? actionParts[1] : null;
const actionInfo = typeInfo?.sub?.[action];
const actionDisplay = actionInfo ? `${actionInfo.e} ${actionInfo.i18n}${action_sub ? ':'+action_sub : ''}` : action;
return [
view.buildTimeString(event.time),
typeDisplay,
actionDisplay,
view.objectToText(event.Actor),
event.scope || ''
];
});
const output = JSON.stringify(ev_array, null, 2);
view.outputText = output + '\n';
view.insertOutput(view.outputText);
if (!eventsTable) {
eventsTable = new L.ui.Table(
[_('Time'), _('Type'), _('Action'), _('Actor'), _('Scope')],
{ id: 'events-table', style: 'width: 100%; table-layout: auto;' },
E('em', [_('No events found')])
);
view.tableSection.innerHTML = '';
view.tableSection.appendChild(eventsTable.render());
}
eventsTable.update(rows);
}
view.tableSection.innerHTML = '';
function flushBatch() {
if (batchBuffer.size) {
batchBuffer = new Set();
}
if (batchTimer) {
clearTimeout(batchTimer);
batchTimer = null;
}
updateTable();
}
function handleEventChunk(event) {
event_list.add(event);
batchBuffer.add(event);
if (batchBuffer.size >= BATCH_SIZE) {
flushBatch();
} else if (!batchTimer) {
batchTimer = setTimeout(flushBatch, BATCH_INTERVAL);
}
}
/* Partial transfers work but XHR times out waiting, even with xhr.timeout = 0 */
// view.handleXHRTransfer({
// q_params:{ query: queryParams },
// commandCPath: '/docker/events',
// commandDPath: '/events',
// commandTitle: dm2.ActionTypes['prune'].i18n,
// showProgress: false,
// onUpdate: (msg) => {
// try {
// if(msg.error)
// ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
// event_list.add(msg);
// updateTable();
// const output = JSON.stringify(msg, null, 2) + '\n';
// view.insertOutput(output);
// } catch {
// }
// },
// noFileUpload: true,
// });
view.executeDockerAction(
dm2.docker_events,
{ query: queryParams, onChunk: handleEventChunk },
_('Load Events'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
if (response.body)
event_list = Array.isArray(response.body) ? new Set(response.body) : new Set([response.body]);
updateTable();
flushBatch();
},
onError: (err) => {
view.tableSection.innerHTML = '';
view.tableSection.appendChild(E('em', { 'style': 'color: red;' }, _('Failed to load events: %s').format(err?.message || err)));
}
}
);
},
updateSubtypeFilter(selectedType) {
const subtypeSelect = document.getElementById('event-subtype-filter');
if (!subtypeSelect) return;
// Clear existing options
subtypeSelect.innerHTML = '';
if (!selectedType || !dm2.Types[selectedType] || !dm2.Types[selectedType].sub) {
subtypeSelect.disabled = true;
subtypeSelect.appendChild(E('option', { 'value': '' }, _('Select Type First')));
return;
}
// Enable and populate with subtypes
subtypeSelect.disabled = false;
subtypeSelect.appendChild(E('option', { 'value': '' }, _('All Subtypes')));
const subtypes = dm2.Types[selectedType].sub;
for (const action in subtypes) {
subtypeSelect.appendChild(
E('option', { 'value': action }, `${subtypes[action].e} ${subtypes[action].i18n}`)
);
}
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
});
@@ -0,0 +1,724 @@
'use strict';
'require form';
'require fs';
'require poll';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.image_list(),
dm2.container_list({query: {all: true}}),
])
},
render([images, containers]) {
if (images?.code !== 200) {
return E('div', {}, [ images.body.message ]);
}
let image_list = this.getImagesTable(images.body);
let container_list = containers.body;
const view = this; // Capture the view context
view.selectedImages = {};
let s, o;
const m = new form.JSONMap({image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {}},
_('Docker - Images'),
_('On this page all images are displayed that are available on the system and with which a container can be created.'));
m.submit = false;
m.reset = false;
let pollPending = null;
let imgSec = null;
const calculateSizeTotal = () => {
return Array.isArray(image_list) ? image_list.map(c => c?.Size).reduce((acc, e) => acc + e, 0) : 0;
};
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([images2, containers2]) => {
image_list = view.getImagesTable(images2.body);
container_list = containers2.body;
m.data = new m.data.constructor({ image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {} });
const size_total = calculateSizeTotal();
if (imgSec) {
imgSec.footer = [
'',
`${_('Total')} ${image_list.length}`,
'',
`${'%1024mB'.format(size_total)}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
// Pull image
s = m.section(form.TableSection, 'pull', dm2.Types['image'].sub['pull'].i18n,
_('By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry.'));
s.anonymous = true;
s.addremove = false;
const splitImageTag = (value) => {
const input = String(value || '').trim();
if (!input || input.includes(' ')) return { name: '', tag: 'latest' };
const lastSlash = input.lastIndexOf('/');
const lastColon = input.lastIndexOf(':');
if (lastColon > lastSlash) {
return {
name: input.slice(0, lastColon) || input,
tag: input.slice(lastColon + 1) || 'latest'
};
}
return { name: input, tag: 'latest' };
};
let tagOpt = s.option(form.Value, '_image_tag_name');
tagOpt.placeholder = "[registry.io[:443]/]foobar/product:latest";
o = s.option(form.Button, '_pull');
o.inputtitle = `${dm2.Types['image'].sub['pull'].i18n} ${dm2.Types['image'].sub['pull'].e}`; // _('Pull') + ' ☁️⬇️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const raw = tagOpt.formvalue('pull') || '';
const input = String(raw).trim();
if (!input) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['pull'].i18n, _('Please enter an image tag'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(input);
return this.super('handleXHRTransfer', [{
q_params: { query: { fromImage: name, tag: ver } },
commandCPath: `/images/create`,
commandDPath: `/images/create`,
commandTitle: dm2.Types['image'].sub['pull'].i18n,
successMessage: _('Image create completed'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_create,
// { query: { fromImage: name, tag: ver } },
// dm2.Types['image'].sub['pull'].i18n,
// {
// showOutput: true,
// successMessage: _('Image create completed')
// }
// );
}, this);
// Push image
s = m.section(form.TableSection, 'push', dm2.Types['image'].sub['push'].i18n,
_('Push an image to a registry. Select an image tag from all available tags on the system.'));
s.anonymous = true;
s.addremove = false;
// Build a list of all available tags across all images
const allImageTags = [];
for (const image of image_list) {
const tags = Array.isArray(image.RepoTags) ? image.RepoTags : [];
for (const tag of tags) {
if (tag && tag !== '<none>:<none>') {
allImageTags.push(tag);
}
}
}
let pushTagOpt = s.option(form.Value, '_image_tag_push');
pushTagOpt.placeholder = _('Select image tag');
if (allImageTags.length === 0) {
pushTagOpt.value('', _('No image tags available'));
} else {
// Add all unique tags to the dropdown
const uniqueTags = [...new Set(allImageTags)].sort();
for (const tag of uniqueTags) {
pushTagOpt.value(tag, tag);
}
}
o = s.option(form.Button, '_push');
o.inputtitle = `${dm2.Types['image'].sub['push'].i18n} ${dm2.Types['image'].sub['push'].e}`; // _('Push') + ' ☁️⬆️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const selected = pushTagOpt.formvalue('push') || '';
if (!selected) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['push'].i18n, _('Please select an image tag to push'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(selected);
return this.super('handleXHRTransfer', [{
// Pass name in q_params to trigger building X-Registry-Auth header
q_params: { name: name, query: { tag: ver } },
commandCPath: `/images/push/${name}`,
commandDPath: `/images/${name}/push`,
commandTitle: dm2.Types['image'].sub['push'].i18n,
successMessage: _('Image push completed'),
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_push,
// { name: name, query: { tag: ver} },
// dm2.Types['image'].sub['push'].i18n,
// {
// showOutput: true,
// successMessage: _('Image push completed')
// }
// );
}, this);
s = m.section(form.TableSection, 'build', dm2.ActionTypes['build'].i18n,
_('Build an image.') + ' ' + _('git repositories require git installed on the docker host.'));
s.anonymous = true;
s.addremove = false;
let buildOpt = s.option(form.Value, '_image_build_uri');
buildOpt.placeholder = "https://host/foo/bar.git | https://host/foobar.tar";
let buildTagOpt = s.option(form.Value, '_image_build_tag');
buildTagOpt.placeholder = 'repository:tag';
o = s.option(form.Button, '_build');
o.inputtitle = `${dm2.ActionTypes['build'].i18n} ${dm2.ActionTypes['build'].e}`; // _('Build') + ' 🏗️'
o.inputstyle = 'add';
o.onclick = L.bind(function(ev, btn) {
const uri = buildOpt.formvalue('build') || '';
const t = buildTagOpt.formvalue('build') || '';
const q_params = { q: encodeURIComponent('false'), t: t };
if (uri) q_params.remote = encodeURIComponent(uri);
return this.super('handleXHRTransfer', [{
q_params: { query: q_params },
commandCPath: '/images/build',
commandDPath: '/build',
commandTitle: dm2.ActionTypes['build'].i18n,
successMessage: _('Image loaded successfully'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: !!uri,
}]);
}, this);
o = s.option(form.Button, '_delete_cache', null);
o.inputtitle = `${dm2.ActionTypes['clean'].i18n} ${dm2.ActionTypes['clean'].e}`;
o.inputstyle = 'negative';
o.onclick = L.bind(function(ev, btn) {
return this.super('handleXHRTransfer', [{
q_params: { query: { all: 'true' } },
commandCPath: '/images/build/prune',
commandDPath: '/build/prune',
commandTitle: dm2.Types['builder'].sub['prune'].i18n,
successMessage: _('Cleaned build cache'),
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['clean'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
}, this);
// Import image
s = m.section(form.TableSection, 'import', dm2.Types['image'].sub['import'].i18n,
_('Download a valid remote image tar.'));
s.addremove = false;
s.anonymous = true;
let imgsrc = s.option(form.Value, '_image_source');
imgsrc.placeholder = 'https://host/image.tar';
let tagimpOpt = s.option(form.Value, '_import_image_tag_name');
tagimpOpt.placeholder = 'repository:tag';
let importBtn = s.option(form.Button, '_import');
importBtn.inputtitle = `${dm2.Types['image'].sub['import'].i18n} ${dm2.Types['image'].sub['import'].e}` //_('Import') + ' ➡️';
importBtn.inputstyle = 'add';
importBtn.onclick = L.bind(function(ev, btn) {
const rawtag = tagimpOpt.formvalue('import') || '';
const input = String(rawtag).trim();
if (!input) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image repo tag'), 4000, 'warning');
return false;
}
const rawremote = imgsrc.formvalue('import') || '';
let remote = String(rawremote).trim();
if (!remote) {
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image source'), 4000, 'warning');
return false;
}
const { name, tag: ver } = splitImageTag(input);
return this.super('handleXHRTransfer', [{
q_params: { query: { fromSrc: remote, repo: ver } },
commandCPath: '/images/create',
commandDPath: '/images/create',
commandTitle: dm2.Types['image'].sub['create'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.Types['image'].sub['create'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_create,
// { query: { fromSrc: remote, repo: ver } },
// dm2.Types['image'].sub['import'].i18n,
// {
// showOutput: true,
// successMessage: _('Image create started/completed')
// }
// );
}, this);
s = m.section(form.TableSection, 'prune', _('Images overview'), );
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(ev, btn) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/images/prune',
commandDPath: '/images/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
onSuccess: () => refresh(),
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.image_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => refresh(),
// }
// );
}, this);
o = s.option(form.Button, '_export', null);
o.inputtitle = `${dm2.ActionTypes['save'].i18n} ${dm2.ActionTypes['save'].e}`;
o.inputstyle = 'cbi-button-positive';
o.onclick = L.bind(function(ev, btn) {
ev.preventDefault();
const selected = Object.keys(view.selectedImages).filter(k => view.selectedImages[k]);
if (!selected.length) {
ui.addTimeLimitedNotification(_('Export'), _('No images selected'), 3000, 'warning');
return;
}
// Get tags or IDs for selected images
const names = selected.map(sid => {
const image = s.map.data.data[sid];
const tag = image?.RepoTags?.[0];
return tag || image?.Id?.substr(12);
});
// http.uc does not yet handle parameter arrays, so /images/get needs access to the URL params
window.location.href = `${view.dockerman_url}/images/get?${names.map(e => `names=${e}`).join('&')}`;
}, this);
const size_total = calculateSizeTotal();
imgSec = m.section(form.TableSection, 'image');
imgSec.anonymous = true;
imgSec.nodescriptions = true;
imgSec.addremove = true;
imgSec.sortable = true;
imgSec.filterrow = true;
imgSec.addbtntitle = `${dm2.ActionTypes['upload'].i18n} ${dm2.ActionTypes['upload'].e}`;
imgSec.footer = [
'',
`${_('Total')} ${image_list.length}`,
'',
`${'%1024mB'.format(size_total)}`,
];
imgSec.handleAdd = function(sid, ev) {
return view.handleFileUpload();
};
imgSec.handleGet = function(image, ev) {
const tag = image.RepoTags?.[0];
const name = tag || image.Id.substr(12);
// Direct HTTP download - avoid RPC
window.location.href = `${view.dockerman_url}/images/get/${name}`;
return true;
};
imgSec.handleRemove = function(sid, image, force=false, ev) {
return view.executeDockerAction(
dm2.image_remove,
{ id: image.Id, query: { force: force } },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
delete this.map.data.data[sid];
return this.super('handleRemove', [ev]);
}
}
);
};
imgSec.handleInspect = function(image, ev) {
return view.executeDockerAction(
dm2.image_inspect,
{ id: image.Id },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
imgSec.handleHistory = function(image, ev) {
return view.executeDockerAction(
dm2.image_history,
{ id: image.Id },
dm2.ActionTypes['history'].i18n,
{ showOutput: true, showSuccess: false }
);
};
imgSec.renderRowActions = function (sid) {
const image = this.map.data.data[sid];
const btns = [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, image),
}, [dm2.ActionTypes['inspect'].e]),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': dm2.ActionTypes['history'].i18n,
'click': ui.createHandlerFn(this, this.handleHistory, image),
}, [dm2.ActionTypes['history'].e]),
E('button', {
'class': 'cbi-button cbi-button-positive save',
'title': dm2.ActionTypes['save'].i18n,
'click': ui.createHandlerFn(this, this.handleGet, image),
}, [dm2.ActionTypes['save'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, false),
'disabled': image?._disable_delete,
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, true),
'disabled': image?._disable_delete,
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
o = imgSec.option(form.Flag, '_selected');
o.onchange = function(ev, sid, value) {
if (value == 1) {
view.selectedImages[sid] = value;
}
else {
delete view.selectedImages[sid];
}
return;
}
o = imgSec.option(form.DummyValue, 'RepoTags', dm2.Types['image'].sub['tag'].e);
o.cfgvalue = function(sid) {
const image = this.map.data.data[sid];
const tags = Array.isArray(image?.RepoTags) ? image.RepoTags : [];
if (tags.length === 0 || (tags.length === 1 && tags[0] === '<none>:<none>'))
return '<none>';
const tagLinks = tags.map(tag => {
if (tag === '<none>:<none>')
return E('span', {}, tag);
/* last tag - don't link it - last tag removal == delete */
if (tags.length === 1)
return tag;
return E('a', {
'href': '#',
'title': _('Click to remove this tag'),
'click': ui.createHandlerFn(view, (tag, imageId, ev) => {
ev.preventDefault();
ui.showModal(_('Remove tag'), [
E('p', {}, _('Do you want to remove the tag "%s"?').format(tag)),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, '↩'),
' ',
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': ui.createHandlerFn(view, () => {
ui.hideModal();
return view.executeDockerAction(
dm2.image_remove,
{ id: tag, query: { noprune: 'true' } },
dm2.Types['image'].sub['untag'].i18n,
{
showOutput: true,
successMessage: _('Tag removed successfully'),
successDuration: 4000,
onSuccess: () => refresh(),
}
);
})
}, dm2.Types['image'].sub['untag'].e)
])
]);
}, tag, image.Id)
}, tag);
});
// Join with commas and spaces
const content = [];
for (const [i, tag] of tagLinks.entries()) {
if (i > 0) content.push(', ');
content.push(tag);
}
return E('span', {}, content);
};
o = imgSec.option(form.DummyValue, 'Containers', _('Containers'));
o.cfgvalue = function(sid) {
const imageId = this.map.data.data[sid].Id;
// Collect all matching container name links for this image
const anchors = container_list.reduce((acc, container) => {
if (container?.ImageID !== imageId) return acc;
for (const name of container?.Names || [])
acc.push(E('a', { href: `container/${container.Id}` }, [ name.substring(1) ]));
return acc;
}, []);
// Interleave separators
if (!anchors.length) return E('div', {});
const content = [];
for (let i = 0; i < anchors.length; i++) {
if (i) content.push(' | ');
content.push(anchors[i]);
}
return E('div', {}, content);
};
o = imgSec.option(form.DummyValue, 'Size', _('Size'));
o.cfgvalue = function(sid) {
const s = this.map.data.data[sid].Size;
return '%1024mB'.format(s);
};
imgSec.option(form.DummyValue, 'Created', _('Created'));
o = imgSec.option(form.DummyValue, '_id', _('ID'));
/* Remember: we load a JSONMap - so uci config is non-existent for these
elements, so we must pull from this.map.data, otherwise o.load returns nothing */
o.cfgvalue = function(sid) {
const image = this.map.data.data[sid];
const shortId = image?._id || '';
const fullId = image?.Id || '';
return E('a', {
'href': '#',
'style': 'font-family: monospace',
'title': _('Click to add a new tag to this image'),
'click': ui.createHandlerFn(view, function(imageId, ev) {
ev.preventDefault();
let repoInput, tagInput;
ui.showModal(_('New tag'), [
E('p', {}, _('Enter a new tag for image %s:').format(imageId.slice(7, 19))),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Repository')),
E('div', { 'class': 'cbi-value-field' }, [
repoInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': '[registry.io[:443]/]myrepo/myimage'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Tag')),
E('div', { 'class': 'cbi-value-field' }, [
tagInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'latest',
'value': 'latest'
})
])
]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, ['↩']),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const repo = repoInput.value.trim();
const tag = tagInput.value.trim() || 'latest';
if (!repo) {
ui.addTimeLimitedNotification(null, [_('Repository cannot be empty')], 3000, 'warning');
return;
}
ui.hideModal();
return view.executeDockerAction(
dm2.image_tag,
{ id: imageId, query: { repo: repo, tag: tag } },
dm2.Types['image'].sub['tag'].i18n,
{
showOutput: true,
successMessage: _('Tag added successfully'),
successDuration: 4000,
onSuccess: () => refresh(),
}
);
})
}, [dm2.Types['image'].sub['tag'].e])
])
]);
}, fullId)
}, shortId);
};
this.insertOutputFrame(s, m);
poll.add(L.bind(() => { refresh(); }, this), 10);
return m.render();
},
handleFileUpload() {
// const uploadUrl = `?quiet=${encodeURIComponent('false')}`;
return this.super('handleXHRTransfer', [{
q_params: { query: { quiet: 'false' } },
commandCPath: `/images/load`,
commandDPath: `/images/load`,
commandTitle: _('Uploading…'),
commandMessage: _('Uploading image…'),
successMessage: _('Image loaded successfully'),
defaultPath: '/tmp'
}]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getImagesTable(images) {
const data = [];
for (const image of images) {
// Just push plain data objects without UCI metadata
data.push({
...image,
_disable_delete: null,
_id: (image.Id || '').substring(7, 20),
Created: this.buildTimeString(image.Created) || '',
});
}
return data;
},
});
@@ -0,0 +1,165 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
const requestPath = L.env.requestpath;
const netId = requestPath[requestPath.length-1] || '';
this.networkId = netId;
return Promise.all([
dm2.network_inspect({ id: netId }),
dm2.container_list({query: {all: true}}),
]);
},
render([network, containers]) {
if (network?.code !== 200) {
window.location.href = `${this.dockerman_url}/networks`;
return;
}
const view = this;
const this_network = network.body || {};
const container_list = Array.isArray(containers.body) ? containers.body : [];
const m = new form.JSONMap({
network: this_network,
Driver: this_network?.IPAM?.Driver,
Config: this_network?.IPAM?.Config,
Containers: Object.entries(this_network?.Containers || {}).map(([id, info]) => ({ id, ...info })),
_inspect: {},
},
_('Docker - Networks'),
_('This page displays all docker networks that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
let s = m.section(form.NamedSection, 'network', _('Networks overview'));
s.anonymous = true;
s.addremove = false;
s.nodescriptions = true;
let o, ss;
// INFO TAB
s.tab('info', _('Info'));
o = s.taboption('info', form.DummyValue, 'Name', _('Network Name'));
o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
o = s.taboption('info', form.DummyValue, 'Scope', _('Scope'));
o = s.taboption('info', form.DummyValue, 'Driver', _('Driver'));
o = s.taboption('info', form.Flag, 'EnableIPv6', _('IPv6'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Internal', _('Internal'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Attachable', _('Attachable'));
o.readonly = true;
o = s.taboption('info', form.Flag, 'Ingress', _('Ingress'));
o.readonly = true;
o = s.taboption('info', form.DummyValue, 'ConfigFrom', _('ConfigFrom'));
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.Flag, 'ConfigOnly', _('Config Only'));
o.readonly = true;
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.DummyValue, 'Containers', _('Containers'));
o.load = function(sid) {
return view.parseContainerLinksForNetwork(this_network, container_list);
};
o = s.taboption('info', form.DummyValue, 'Options', _('Options'));
o.cfgvalue = view.objectCfgValueTT;
o = s.taboption('info', form.DummyValue, 'Labels', _('Labels'));
o.cfgvalue = view.objectCfgValueTT;
// CONFIGS TAB
s.tab('detail', _('Detail'));
o = s.taboption('detail', form.DummyValue, 'Driver', _('IPAM Driver'));
o = s.taboption('detail', form.SectionValue, '_conf_', form.TableSection, 'Config', _('Network Configurations'));
ss = o.subsection;
ss.anonymous = true;
ss.option(form.DummyValue, 'Subnet', _('Subnet'));
ss.option(form.DummyValue, 'Gateway', _('Gateway'));
o = s.taboption('detail', form.SectionValue, '_cont_', form.TableSection, 'Containers', _('Containers'));
ss = o.subsection;
ss.anonymous = true;
o = ss.option(form.DummyValue, 'Name', _('Name'));
o.cfgvalue = function(sid) {
const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
const containerId = container_list.find(c => c.Names.find(e => e.substring(1) === val)).Id;
return E('a', {
href: `${view.dockerman_url}/container/${containerId}`,
title: containerId,
style: 'white-space: nowrap;'
}, [val]);
};
ss.option(form.DummyValue, 'MacAddress', _('Mac Address'));
ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
// Show IPv6 column when at least one entry contains a non-empty IPv6Address
const _networkContainers = Object.values(this_network?.Containers || {});
const _hasIPv6 = _networkContainers.some(c => c?.IPv6Address && String(c.IPv6Address).trim() !== '');
if (_hasIPv6) {
ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
}
// INSPECT TAB
s.tab('inspect', _('Inspect'));
o = s.taboption('inspect', form.SectionValue, '__ins__', form.NamedSection, '_inspect', null);
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = true;
o = ss.option(form.Button, '_inspect_button', null);
o.inputtitle = `${dm2.ActionTypes['inspect'].i18n} ${dm2.ActionTypes['inspect'].e}`;
o.inputstyle = 'neutral';
o.onclick = L.bind(function(section_id, ev) {
return dm2.network_inspect({ id: this_network.Id }).then((response) => {
const inspectField = document.getElementById('inspect-output-text');
if (inspectField && response?.body) {
inspectField.textContent = JSON.stringify(response.body, null, 2);
}
});
}, this);
o = s.taboption('inspect', form.SectionValue, '__insoutput__', form.NamedSection, null, null);
o.render = L.bind(() => {
return this.insertOutputFrame(null, null);
}, this);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
});
@@ -0,0 +1,257 @@
'use strict';
'require form';
'require fs';
'require ui';
'require tools.widgets as widgets';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
]);
},
render() {
// stuff JSONMap with {network: {}} to prime it with a new empty entry
const m = new form.JSONMap({network: {}}, _('Docker - New Network'));
m.submit = true;
m.reset = true;
let s = m.section(form.NamedSection, 'network', _('Create new docker network'));
s.anonymous = true;
s.nodescriptions = true;
s.addremove = false;
let o;
o = s.option(form.Value, 'name', _('Network Name'),
_('Name of the network that can be selected during container creation'));
o.rmempty = true;
o = s.option(form.ListValue, 'driver', _('Driver'));
o.rmempty = true;
o.value('bridge', _('Bridge device'));
o.value('macvlan', _('MAC VLAN'));
o.value('ipvlan', _('IP VLAN'));
o.value('overlay', _('Overlay network'));
o = s.option(widgets.DeviceSelect, 'parent', _('Base device'));
o.rmempty = true;
o.create = false
o.noaliases = true;
o.nocreate = true;
o.depends('driver', 'macvlan');
o = s.option(form.ListValue, 'macvlan_mode', _('Mode'));
o.rmempty = true;
o.depends('driver', 'macvlan');
o.default = 'bridge';
o.value('bridge', _('Bridge (Support direct communication between MAC VLANs)'));
o.value('private', _('Private (Prevent communication between MAC VLANs)'));
o.value('vepa', _('VEPA (Virtual Ethernet Port Aggregator)'));
o.value('passthru', _('Pass-through (Mirror physical device to single MAC VLAN)'));
o = s.option(form.ListValue, 'ipvlan_mode', _('Ipvlan Mode'));
o.rmempty = true;
o.depends('driver', 'ipvlan');
o.default='l3';
o.value('l2', _('L2 bridge'));
o.value('l3', _('L3 bridge'));
o = s.option(form.Flag, 'ingress',
_('Ingress'),
_('Ingress network is the network which provides the routing-mesh in swarm mode'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = 0;
o.depends('driver', 'overlay');
o = s.option(form.DynamicList, 'options', _('Options'));
o.rmempty = true;
o.placeholder='com.docker.network.driver.mtu=1500';
o = s.option(form.DynamicList, 'labels', _('Labels'));
o.rmempty = true;
o.placeholder='foo=bar';
o = s.option(form.Flag, 'internal', _('Internal'), _('Restrict external access to the network'));
o.rmempty = true;
o.depends('driver', 'overlay');
o.disabled = 0;
o.enabled = 1;
o.default = o.disabled;
// if nixio.fs.access('/etc/config/network') and nixio.fs.access('/etc/config/firewall')then
// o = s.option(form.Flag, 'op_macvlan', _('Create macvlan interface'), _('Auto create macvlan interface in Openwrt'))
// o.depends('driver', 'macvlan')
// o.disabled = 0
// o.enabled = 1
// o.default = 1
// end
o = s.option(form.Value, 'subnet', _('Subnet'));
o.rmempty = true;
o.placeholder = '10.1.0.0/16';
o.datatype = 'ip4addr';
o = s.option(form.Value, 'gateway', _('Gateway'));
o.rmempty = true;
o.placeholder = '10.1.1.1';
o.datatype = 'ip4addr';
o = s.option(form.Value, 'ip_range', _('IP range'));
o.rmempty = true;
o.placeholder='10.1.1.0/24';
o.datatype = 'ip4addr';
o = s.option(form.DynamicList, 'aux_address', _('Exclude IPs'));
o.rmempty = true;
o.placeholder = 'my-route=10.1.1.1';
o = s.option(form.Flag, 'ipv6', _('Enable IPv6'));
o.rmempty = true;
o.disabled = 0;
o.enabled = 1;
o.default = o.disabled;
o = s.option(form.Value, 'subnet6', _('IPv6 Subnet'));
o.rmempty = true;
o.placeholder='fe80::/10'
o.datatype = 'ip6addr';
o.depends('ipv6', 1);
o = s.option(form.Value, 'gateway6', _('IPv6 Gateway'));
o.rmempty = true;
o.placeholder='fe80::1';
o.datatype = 'ip6addr';
o.depends('ipv6', 1);
this.map = m;
return m.render();
},
handleSave(ev) {
ev?.preventDefault();
const view = this;
const map = this.map;
if (!map)
return Promise.reject(new Error(_('Form is not ready yet.')));
const listToKv = view.listToKv;
const toBool = (val) => (val === 1 || val === '1' || val === true);
return map.parse()
.then(() => {
const get = (opt) => map.data.get('json', 'network', opt);
const name = get('name');
const driver = get('driver');
const internal = toBool(get('internal'));
const ingress = toBool(get('ingress'));
const ipv6 = toBool(get('ipv6'));
const subnet = get('subnet');
const gateway = get('gateway');
const ipRange = get('ip_range');
const auxAddress = listToKv(get('aux_address'));
const optionsList = listToKv(get('options'));
const labelsList = listToKv(get('labels'));
const subnet6 = get('subnet6');
const gateway6 = get('gateway6');
const createBody = {
Name: name,
Driver: driver,
EnableIPv6: ipv6,
IPAM: {
Driver: 'default'
},
Internal: internal,
Labels: labelsList,
};
if (subnet || gateway || ipRange
|| (auxAddress && typeof auxAddress === 'object' && Object.keys(auxAddress).length)) {
createBody.IPAM.Config = [{
Subnet: subnet,
Gateway: gateway,
IPRange: ipRange,
AuxAddress: auxAddress,
AuxiliaryAddresses: auxAddress,
}];
}
if (driver === 'macvlan') {
createBody.Options = {
macvlan_mode: get('macvlan_mode'),
parent: get('parent'),
};
}
else if (driver === 'ipvlan') {
createBody.Options = {
ipvlan_mode: get('ipvlan_mode'),
};
}
else if (driver === 'overlay') {
createBody.Ingress = ingress;
}
if (ipv6 && (subnet6 || gateway6)) {
createBody.IPAM.Config = createBody.IPAM.Config || [];
createBody.IPAM.Config.push({
Subnet: subnet6,
Gateway: gateway6,
});
}
if (optionsList && typeof optionsList === 'object' && Object.keys(optionsList).length) {
createBody.Options = Object.assign(createBody.Options || {}, optionsList);
}
if (labelsList && typeof labelsList === 'object' && Object.keys(labelsList).length) {
createBody.Labels = Object.assign(createBody.Labels || {}, labelsList);
}
return createBody;
})
.then((createBody) => view.executeDockerAction(
dm2.network_create,
{ body: createBody },
_('Create network'),
{
showOutput: false,
showSuccess: false,
onSuccess: (response) => {
if (response?.body?.Warning) {
view.showNotification(_('Network created with warning'), response.body.Warning, 5000, 'warning');
} else {
view.showNotification(_('Network created'), _('OK'), 4000, 'success');
}
window.location.href = `${this.dockerman_url}/networks`;
}
}
))
.catch((err) => {
view.showNotification(_('Create network failed'), err?.message || String(err), 7000, 'error');
return false;
});
},
handleSaveApply: null,
handleReset: null,
});
@@ -0,0 +1,236 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.network_list(),
dm2.container_list({query: {all: true}}),
]);
},
render([networks, containers]) {
if (networks?.code !== 200) {
return E('div', {}, [ networks?.body?.message ]);
}
let network_list = this.getNetworksTable(networks.body, containers.body);
// let container_list = containers.body;
const view = this; // Capture the view context
let pollPending = null;
let netSec = null;
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([networks2, containers2]) => {
network_list = view.getNetworksTable(networks2.body, containers2.body);
// container_list = containers2.body;
m.data = new m.data.constructor({network: network_list, prune: {}});
if (netSec) {
netSec.footer = [
`${_('Total')} ${network_list.length}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
let s, o;
const m = new form.JSONMap({network: network_list, prune: {}},
_('Docker - Networks'),
_('This page displays all docker networks that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
s = m.section(form.TableSection, 'prune', _('Networks overview'), null);
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(section_id, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/networks/prune',
commandDPath: '/networks/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.network_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => {
// setTimeout(() => window.location.href = `${this.dockerman_url}/networks`, 1000);
// }
// }
// );
}, this);
netSec = m.section(form.TableSection, 'network');
netSec.anonymous = true;
netSec.nodescriptions = true;
netSec.addremove = true;
netSec.sortable = true;
netSec.filterrow = true;
netSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
netSec.footer = [
`${_('Total')} ${network_list.length}`,
];
netSec.handleAdd = function(section_id, ev) {
window.location.href = `${view.dockerman_url}/network_new`;
};
netSec.handleRemove = function(section_id, force, ev) {
const network = network_list.find(net => net['.name'] === section_id);
if (!network?.Id) return false;
return view.executeDockerAction(
dm2.network_remove,
{ id: network.Id },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
};
netSec.handleInspect = function(section_id, ev) {
const network = network_list.find(net => net['.name'] === section_id);
if (!network?.Id) return false;
return view.executeDockerAction(
dm2.network_inspect,
{ id: network.Id },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
netSec.renderRowActions = function (section_id) {
const network = network_list.find(net => net['.name'] === section_id);
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, section_id),
}, [dm2.ActionTypes['inspect'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, section_id, false),
'disabled': network?._disable_delete,
}, dm2.ActionTypes['remove'].e),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, section_id, true),
'disabled': network?._disable_delete,
}, dm2.ActionTypes['force_remove'].e),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
o = netSec.option(form.DummyValue, '_shortId', _('ID'));
o = netSec.option(form.DummyValue, 'Name', _('Name'));
o = netSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
o.cfgvalue = view.objectCfgValueTT;
o = netSec.option(form.DummyValue, '_container', _('Containers'));
o = netSec.option(form.DummyValue, 'Driver', _('Driver'));
o = netSec.option(form.DummyValue, '_interface', _('Parent Interface'));
o = netSec.option(form.DummyValue, '_subnet', _('Subnet'));
o = netSec.option(form.DummyValue, '_gateway', _('Gateway'));
this.insertOutputFrame(s, m);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getNetworksTable(networks, containers) {
const data = [];
for (const [ , net] of (networks || []).entries()) {
const n = net.Name;
const _shortId = (net.Id || '').substring(0, 12);
const shortLink = E('a', {
'href': `${view.dockerman_url}/network/${net.Id}`,
'style': 'font-family: monospace;',
'title': _('Click to view this network'),
}, [_shortId]);
// Just push plain data objects without UCI metadata
const configs = Array.isArray(net?.IPAM?.Config) ? net.IPAM.Config : [];
data.push({
...net,
_gateway: configs.map(o => o.Gateway).filter(o => o).join(', ') || '',
_subnet: configs.map(o => o.Subnet).filter(o => o).join(', ') || '',
_disable_delete: ( n === 'bridge' || n === 'none' || n === 'host' ) ? true : null,
_shortId: shortLink,
_container: this.parseContainerLinksForNetwork(net, containers),
_interface: (net.Driver === 'bridge')
? net.Options?.['com.docker.network.bridge.name'] || ''
: (net.Driver === 'macvlan')
? net?.Options?.parent
: '',
});
}
return data;
},
});
@@ -0,0 +1,280 @@
'use strict';
'require form';
'require fs';
'require uci';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
/**
* Returns a Set of image IDs in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of image IDs
*/
function getImagesInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
if (c.ImageID) inUse.add(c.ImageID);
else if (c.Image) inUse.add(c.Image);
}
return inUse;
}
/**
* Returns a Set of network IDs in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of network IDs
*/
function getNetworksInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
const networks = c.NetworkSettings?.Networks;
if (networks && typeof networks === 'object') {
for (const netName in networks) {
const net = networks[netName];
if (net.NetworkID) inUse.add(net.NetworkID);
else if (netName) inUse.add(netName);
}
}
}
return inUse;
}
/**
* Returns a Set of volume mountpoints in use by containers
* @param {Array} containers - Array of container objects
* @returns {Set<string>} Set of volume names or mountpoints
*/
function getVolumesInUseByContainers(containers) {
const inUse = new Set();
for (const c of containers || []) {
const mounts = c.Mounts;
if (Array.isArray(mounts)) {
for (const m of mounts) {
if (m.Type === 'volume' && m.Name) inUse.add(m.Name);
}
}
}
return inUse;
}
return dm2.dv.extend({
load() {
// const now = Math.floor(Date.now() / 1000);
return Promise.all([
dm2.docker_version(),
dm2.docker_info(),
// dm2.docker_df(), // takes > 20 seconds on large docker environments
dm2.container_list().then(r => r.body || []),
dm2.image_list().then(r => r.body || []),
dm2.network_list().then(r => r.body || []),
dm2.volume_list().then(r => r.body || []),
dm2.callMountPoints(),
]);
},
handleAction(name, action, ev) {
return dm2.callRcInit(name, action).then(function(ret) {
if (ret)
throw _('Command failed');
return true;
}).catch(function(e) {
L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning');
});
},
render([version_response,
info_response,
// df_response,
container_list,
image_list,
network_list,
volume_list,
mounts,
]) {
const version_headers = [];
const version_body = [];
const info_body = [];
// const df_body = [];
const docker_ep = uci.get('dockerd', 'globals', 'hosts');
let isLocal = false;
if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1)
isLocal = true;
if (info_response?.code !== 200) {
return E('div', {}, [ info_response?.body?.message ]);
}
this.parseHeaders(version_response.headers, version_headers);
this.parseBody(version_response.body, version_body);
this.parseBody(info_response.body, info_body);
// this.parseBody(df_response.body, df_body);
const view = this;
const info = info_response.body;
this.concount = info?.Containers || 0;
this.conactivecount = info?.ContainersRunning || 0;
/* Because the df function that reconciles Volumes, Networks and Containers
is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */
this.imgcount = image_list.length;
this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0;
this.netcount = network_list.length;
this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0;
this.volcount = volume_list?.Volumes?.length;
this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0;
this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0;
if (isLocal && this.freespace !== 0)
this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')';
const mainContainer = E('div', { 'class': 'cbi-map' });
// Add heading and description first
mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
_('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
E('br'),
E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README'])
]));
if (isLocal)
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')),
E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')),
])
]));
// Create the info table
const summaryTable = new L.ui.Table(
[_('Info'), ''],
{ id: 'containers-table', style: 'width: 100%; table-layout: auto;' },
[]
);
summaryTable.update([
[ _('Docker Version'), version_response.body.Version ],
[ _('Api Version'), version_response.body.ApiVersion ],
[ _('CPUs'), info_response.body.NCPU ],
[ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ],
[ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ],
[ _('Index Server Address'), info_response.body.IndexServerAddress ],
[ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ],
]);
// Wrap the table in a cbi-section
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
summaryTable.render()
]));
// Create a container div with grid layout for the status badges
let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [
this.overviewBadge(`${this.dockerman_url}/containers`,
E('img', {
src: L.resource('dockerman/containers.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Containers'),
_('Total: '),
view.concount,
_('Running: '),
view.conactivecount),
this.overviewBadge(`${this.dockerman_url}/images`,
E('img', {
src: L.resource('dockerman/images.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Images'),
_('Total: '),
view.imgcount,
view.imgactivecount ? _('In Use: ') : '',
view.imgactivecount ? view.imgactivecount : ''),
this.overviewBadge(`${this.dockerman_url}/networks`,
E('img', {
src: L.resource('dockerman/networks.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Networks'),
_('Total: '),
view.netcount,
view.netactivecount ? _('In Use: ') : '',
view.netactivecount ? view.netactivecount : ''),
this.overviewBadge(`${this.dockerman_url}/volumes`,
E('img', {
src: L.resource('dockerman/volumes.svg'),
style: 'width: 80px; height: 80px;'
}, []),
_('Volumes'),
_('Total: '),
view.volcount,
view.volactivecount ? _('In Use: ') : '',
view.volactivecount ? view.volactivecount : ''),
]);
// Add badges section
mainContainer.appendChild(statusContainer);
const m = new form.JSONMap({
// df: df_body,
vb: version_body,
ib: info_body
});
m.readonly = true;
m.tabbed = false;
let s;
// Add Version and Environment tables
s = m.section(form.TableSection, 'vb', _('Version'));
s.anonymous = true;
s.option(form.DummyValue, 'entry', _('Name'));
s.option(form.DummyValue, 'value', _('Value'));
s = m.section(form.TableSection, 'ib', _('Environment'));
s.anonymous = true;
s.filterrow = true;
s.option(form.DummyValue, 'entry', _('Entry'));
s.option(form.DummyValue, 'value', _('Value'));
// Render the form sections and append them
return m.render()
.then(fe => {
mainContainer.appendChild(fe);
return mainContainer;
});
},
overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) {
return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [
E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [
E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [
resource_div,
]),
E('div', { style: 'flex: 1;' }, [
E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption),
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)])
]),
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)])
])
])
])
])
}
});
@@ -0,0 +1,331 @@
'use strict';
'require form';
'require fs';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
return dm2.dv.extend({
load() {
return Promise.all([
dm2.volume_list(),
dm2.container_list({query: {all: true}}),
]);
},
render([volumes, containers]) {
if (volumes?.code !== 200) {
return E('div', {}, [ volumes.body.message ]);
}
// this.volumes = volumes || {};
let container_list = containers.body || [];
let volume_list = this.getVolumesTable(volumes.body);
const view = this; // Capture the view context
let pollPending = null;
let volSec = null;
const refresh = () => {
if (pollPending) return pollPending;
pollPending = view.load().then(([volumes2, containers2]) => {
volume_list = view.getVolumesTable(volumes2.body);
container_list = containers2.body;
m.data = new m.data.constructor({volume: volume_list, prune: {}});
if (volSec) {
volSec.footer = [
`${_('Total')} ${volume_list.length}`,
];
}
return m.render();
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
return pollPending;
};
let s, o;
const m = new form.JSONMap({volume: volume_list, prune: {}},
_('Docker - Volumes'),
_('This page displays all docker volumes that have been created on the connected docker host.'));
m.submit = false;
m.reset = false;
s = m.section(form.TableSection, 'prune', null, _('Volumes overview'));
s.addremove = false;
s.anonymous = true;
const prune = s.option(form.Button, '_prune', null);
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
prune.inputstyle = 'negative';
prune.onclick = L.bind(function(sid, ev) {
return this.super('handleXHRTransfer', [{
q_params: { },
commandCPath: '/volumes/prune',
commandDPath: '/volumes/prune',
commandTitle: dm2.ActionTypes['prune'].i18n,
onUpdate: (msg) => {
try {
if(msg.error)
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
const output = JSON.stringify(msg, null, 2) + '\n';
view.insertOutput(output);
} catch {
}
},
noFileUpload: true,
}]);
// return view.executeDockerAction(
// dm2.volume_prune,
// { query: { filters: '' } },
// dm2.ActionTypes['prune'].i18n,
// {
// showOutput: true,
// successMessage: _('started/completed'),
// onSuccess: () => {
// setTimeout(() => window.location.href = `${this.dockerman_url}/volumes`, 1000);
// }
// }
// );
}, this);
volSec = m.section(form.TableSection, 'volume');
volSec.anonymous = true;
volSec.nodescriptions = true;
volSec.addremove = true;
volSec.sortable = true;
volSec.filterrow = true;
volSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
volSec.footer = [
`${_('Total')} ${volume_list.length}`,
];
volSec.handleAdd = function(ev) {
ev.preventDefault();
let nameInput, labelsInput;
return ui.showModal(_('New volume'), [
E('p', {}, _('Enter an optional name and labels for the new volume')),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Name')),
E('div', { 'class': 'cbi-value-field' }, [
nameInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('volume name'),
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Labels')),
E('div', { 'class': 'cbi-value-field' }, [
labelsInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': 'key=value, key2=value2, ...',
})
// labelsInput = new ui.DynamicList([], [], {}).render(),
])
]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button',
'click': ui.hideModal
}, ['↩']),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': ui.createHandlerFn(view, () => {
const name = nameInput.value.trim();
const labels = Object.fromEntries(
(labelsInput.value.trim()?.split(',') || [])
.map(e => e.trim())
.filter(Boolean)
.map(e => e.split('='))
.filter(pair => pair.length === 2)
);
ui.hideModal();
return view.executeDockerAction(
dm2.volume_create,
{ opts: { Name: name, Labels: labels } },
dm2.Types['volume'].sub['create'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
})
}, [dm2.Types['volume'].sub['create'].e])
])
]);
};
volSec.handleRemove = function(sid, force, ev) {
const volume = volume_list.find(net => net['.name'] === sid);
if (!volume?.Name) return false;
return view.executeDockerAction(
dm2.volume_remove,
{ id: volume.Name, query: { force: force } },
dm2.ActionTypes['remove'].i18n,
{
showOutput: true,
onSuccess: () => {
return refresh();
}
}
);
};
volSec.handleInspect = function(sid, ev) {
const volume = volume_list.find(net => net['.name'] === sid);
if (!volume?.Name) return false;
return view.executeDockerAction(
dm2.volume_inspect,
{ id: volume.Name },
dm2.ActionTypes['inspect'].i18n,
{ showOutput: true, showSuccess: false }
);
};
volSec.renderRowActions = function (sid) {
const volume = volume_list.find(net => net['.name'] === sid);
const btns = [
E('button', {
'class': 'cbi-button view',
'title': dm2.ActionTypes['inspect'].i18n,
'click': ui.createHandlerFn(this, this.handleInspect, sid),
}, [dm2.ActionTypes['inspect'].e]),
E('div', {
'style': 'width: 20px',
// Some safety margin for mis-clicks
}, [' ']),
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'title': dm2.ActionTypes['remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, false),
'disabled': volume?._disable_delete,
}, [dm2.ActionTypes['remove'].e]),
E('button', {
'class': 'cbi-button cbi-button-negative important remove',
'title': dm2.ActionTypes['force_remove'].i18n,
'click': ui.createHandlerFn(this, this.handleRemove, sid, true),
}, [dm2.ActionTypes['force_remove'].e]),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
volSec.option(form.DummyValue, '_name', _('Name'));
o = volSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
o.cfgvalue = view.objectCfgValueTT;
volSec.option(form.DummyValue, 'Driver', _('Driver'));
o = volSec.option(form.DummyValue, 'Containers', _('Containers'));
o.cfgvalue = function(sid) {
const vol = this.map.data.data[sid] || {};
return view.parseContainerLinksForVolume(vol, container_list);
};
o = volSec.option(form.DummyValue, 'Mountpoint', _('Mount Point'));
o.cfgvalue = function(sid) {
const mp = this.map.data.get(this.map.config, sid, this.option);
if (!mp) return;
// Try to match Docker volume mountpoint pattern: /var/lib/docker/volumes/<id>/_data
const match = mp.match(/^(.*\/volumes\/)([^/]+)(\/.*)?$/);
if (match && match[2].length > 36) {
// Show the first 12 characters of the ID portion
return match[1] + match[2].substring(0, 12) + '...' + (match[3] || '');
}
return mp;
};
o = volSec.option(form.DummyValue, 'CreatedAt', _('Created'));
this.insertOutputFrame(s, m);
return m.render();
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
getVolumesTable(volumes) {
const data = [];
for (const [ , vol] of (volumes?.Volumes || []).entries()) {
const labels = vol?.Labels || {};
// Just push plain data objects without UCI metadata
data.push({
...vol,
Labels: labels,
_name: (vol.Name || '').substring(0, 12),
Containers: vol.Containers || '',
});
}
return data;
},
parseContainerLinksForVolume(volume, containers) {
const links = [];
for (const cont of containers || []) {
const mounts = cont?.Mounts || [];
const usesVolume = mounts.some(m => {
if (m?.Type !== 'volume' && m?.Type !== 'bind') return false;
const byName = !!volume?.Name && m?.Name === volume.Name;
const bySource = !!volume?.Mountpoint && (m?.Source === volume.Mountpoint || (m?.Source || '').startsWith(volume.Mountpoint));
return byName || bySource;
});
if (usesVolume) {
const containerName = cont?.Names?.[0]?.replace(/^\//, '') || (cont?.Id || '').substring(0, 12);
const containerId = cont?.Id;
links.push(E('a', {
href: `${this.dockerman_url}/container/${containerId}`,
title: containerId,
style: 'white-space: nowrap;'
}, [containerName]));
}
}
if (!links.length)
return '-';
const out = [];
for (let i = 0; i < links.length; i++) {
out.push(links[i]);
if (i < links.length - 1)
out.push(' | ');
}
return E('div', {}, out);
},
});