mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 07:35:13 +08:00
🍕 Sync 2026-03-20 22:17:54
This commit is contained in:
1 parent
e67803267a
commit
6680797fe5
91 files changed
+82161
-19938
No files matched your search
@@ -0,0 +1,534 @@
|
||||
'use strict';
|
||||
'require rpc';
|
||||
'require uci';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
const callNetworkInterfaceDump = rpc.declare({
|
||||
object: 'network.interface',
|
||||
method: 'dump',
|
||||
expect: { 'interface': [] }
|
||||
});
|
||||
|
||||
|
||||
let dockerHosts = null;
|
||||
let dockerHost = null;
|
||||
let localIPv4 = null;
|
||||
let localIPv6 = null;
|
||||
let js_api_available = false;
|
||||
|
||||
|
||||
// Load both UCI config and network interfaces in parallel
|
||||
const loadPromise = Promise.all([
|
||||
callNetworkInterfaceDump(),
|
||||
uci.load('dockerd'),
|
||||
]).then(([interfaceData]) => {
|
||||
|
||||
const lan_device = uci.get('dockerd', 'globals', '_luci_lan') || 'lan';
|
||||
|
||||
// Find local IPs from network interfaces
|
||||
if (interfaceData) {
|
||||
interfaceData.forEach(iface => {
|
||||
// console.log(iface.up)
|
||||
if (!iface.up || iface.interface !== lan_device) return;
|
||||
|
||||
// Get IPv4 address
|
||||
if (!localIPv4 && iface['ipv4-address']) {
|
||||
const addr4 = iface['ipv4-address'].find(a =>
|
||||
a.address && !a.address.startsWith('127.')
|
||||
);
|
||||
if (addr4) localIPv4 = addr4.address;
|
||||
}
|
||||
|
||||
// Get IPv6 address
|
||||
if (!localIPv6) {
|
||||
// Try ipv6-address array first
|
||||
if (iface['ipv6-address']) {
|
||||
const addr6 = iface['ipv6-address'].find(a =>
|
||||
a.address && a.address !== '::1' && !a.address.startsWith('fe80:')
|
||||
);
|
||||
if (addr6) localIPv6 = addr6.address;
|
||||
}
|
||||
|
||||
// Try ipv6-prefix-assignment if no address found
|
||||
if (!localIPv6 && iface['ipv6-prefix-assignment']) {
|
||||
const prefix = iface['ipv6-prefix-assignment'].find(p =>
|
||||
p['local-address'] && p['local-address'].address
|
||||
);
|
||||
if (prefix) localIPv6 = prefix['local-address'].address;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dockerHosts = uci.get_first('dockerd', 'globals', 'hosts');
|
||||
|
||||
// Find and convert first tcp:// or tcp6:// host
|
||||
const hostsList = Array.isArray(dockerHosts) ? dockerHosts : [];
|
||||
const dh = hostsList.find(h => h
|
||||
&& (h.startsWith('tcp://')
|
||||
|| h.startsWith('tcp6://')
|
||||
|| h.startsWith('inet6://')
|
||||
|| h.startsWith('http://')
|
||||
|| h.startsWith('https://')
|
||||
));
|
||||
|
||||
if (dh) {
|
||||
// const isTcp6 = dh.startsWith('tcp6://');
|
||||
const protocol = dh.includes(':2376') ? 'https://' : 'http://';
|
||||
dockerHost = dh.replace(/^(tcp|inet)6?:\/\//, protocol);
|
||||
|
||||
// Replace 0.0.0.0 or :: with appropriate local IP
|
||||
if (localIPv6) {
|
||||
dockerHost = dockerHost.replace(/\[::1?\]/, `[${localIPv6}]`);
|
||||
// dockerHost = dockerHost.replace(/::/, localIPv6);
|
||||
}
|
||||
|
||||
if (localIPv4) {
|
||||
dockerHost = dockerHost.replace(/0\.0\.0\.0/, localIPv4);
|
||||
}
|
||||
|
||||
console.log('Docker configured to use JS API to:', dockerHost);
|
||||
}
|
||||
|
||||
return dockerHost;
|
||||
});
|
||||
|
||||
|
||||
// Helper to process NDJSON or line-delimited JSON chunks
|
||||
function processLines(buffer, onChunk) {
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
onChunk(json);
|
||||
} catch (e) {
|
||||
onChunk({ raw: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
function call_docker(method, path, options = {}) {
|
||||
return loadPromise.then(() => {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
const payload = options.payload || null;
|
||||
const query = options.query || null;
|
||||
const host = dockerHost;
|
||||
const onChunk = options.onChunk || null; // Optional callback for streaming NDJSON
|
||||
const api_ver = uci.get('dockerd', 'globals', 'api_version') || '';
|
||||
const api_ver_str = api_ver ? `/${api_ver}` : '';
|
||||
|
||||
|
||||
if (!host) {
|
||||
return Promise.reject(new Error('Docker host not configured'));
|
||||
}
|
||||
|
||||
// Check if WebSocket upgrade is requested
|
||||
const isWebSocketUpgrade = headers['Connection']?.toLowerCase() === 'upgrade' ||
|
||||
headers['connection']?.toLowerCase() === 'upgrade';
|
||||
|
||||
if (isWebSocketUpgrade) {
|
||||
return createWebSocketConnection(host, path, query);
|
||||
}
|
||||
|
||||
// Build URL
|
||||
let url = `${host}${api_ver_str}${path}`;
|
||||
if (query) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
if (query[key] != null) {
|
||||
params.append(key, query[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// dockerd does not like encoded params here.
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
url += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build fetch options
|
||||
const fetchOptions = {
|
||||
method,
|
||||
headers: {
|
||||
...headers // Always include custom headers
|
||||
},
|
||||
};
|
||||
|
||||
if (payload) {
|
||||
fetchOptions.body = JSON.stringify(payload);
|
||||
if (!fetchOptions.headers['Content-Type']) {
|
||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
// Make the request
|
||||
return fetch(url, fetchOptions)
|
||||
.then(response => {
|
||||
// If streaming callback provided, use streaming response
|
||||
if (onChunk) {
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Process any remaining data in buffer
|
||||
buffer = processLines(buffer, onChunk);
|
||||
break;
|
||||
}
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// Use generic processor for NDJSON/line chunks
|
||||
buffer = processLines(buffer, onChunk);
|
||||
}
|
||||
|
||||
// Return final response
|
||||
resolve({
|
||||
code: response.status,
|
||||
headers: response.headers
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Streaming error:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
processStream();
|
||||
});
|
||||
}
|
||||
|
||||
// Normal buffered response
|
||||
if (response?.status >= 304) {
|
||||
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const headersObj = {};
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
headersObj[key] = value;
|
||||
}
|
||||
|
||||
return response.text().then(text => {
|
||||
const safeText = (typeof text === 'string') ? text : '';
|
||||
let parsed = safeText || text;
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
// Try normal JSON parse first
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
// If the payload is newline-delimited JSON (Docker events), split and parse each line
|
||||
if (['application/json',
|
||||
'application/x-ndjson',
|
||||
'application/json-seq'].includes(contentType) || safeText.includes('\n')) {
|
||||
const lines = safeText.split(/\r?\n/).filter(Boolean);
|
||||
try {
|
||||
parsed = lines.map(l => JSON.parse(l));
|
||||
} catch (err2) {
|
||||
// Fall back to raw text if parsing fails
|
||||
parsed = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: response.status,
|
||||
body: parsed,
|
||||
headers: headersObj,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Docker API error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createWebSocketConnection(host, path, query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Convert http/https to ws/wss
|
||||
const wsHost = host
|
||||
.replace(/^https:/, 'wss:')
|
||||
.replace(/^http:/, 'ws:');
|
||||
|
||||
// Build WebSocket URL
|
||||
let wsUrl = `${wsHost}${path}`;
|
||||
if (query) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
if (query[key] != null) {
|
||||
params.append(key, query[key]);
|
||||
}
|
||||
}
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
wsUrl += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Opening WebSocket connection to:', wsUrl);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let resolved = false;
|
||||
|
||||
// Handle connection open
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
// Return a Response-like object with WebSocket support
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Map(),
|
||||
body: ws,
|
||||
ws: ws,
|
||||
// Add helper for sending messages
|
||||
send: (data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
},
|
||||
// Add helper for receiving messages as async iterator
|
||||
async *[Symbol.asyncIterator]() {
|
||||
while (ws.readyState === WebSocket.OPEN) {
|
||||
yield new Promise((res, rej) => {
|
||||
const messageHandler = (event) => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.removeEventListener('error', errorHandler);
|
||||
res(event.data);
|
||||
};
|
||||
const errorHandler = (error) => {
|
||||
ws.removeEventListener('message', messageHandler);
|
||||
ws.removeEventListener('error', errorHandler);
|
||||
rej(error);
|
||||
};
|
||||
ws.addEventListener('message', messageHandler);
|
||||
ws.addEventListener('error', errorHandler);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection error
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`WebSocket connection failed: ${error.message || 'Unknown error'}`));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close (including handshake failures)
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket closed');
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`WebSocket closed before open (${event?.code || 'unknown'})`));
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const core_methods = {
|
||||
version: { call: () => call_docker('GET', '/version') },
|
||||
info: { call: () => call_docker('GET', '/info') },
|
||||
ping: { call: () => call_docker('GET', '/_ping') },
|
||||
df: { call: () => call_docker('GET', '/system/df') },
|
||||
events: { args: { query: { 'since': '', 'until': `${Date.now()}`, 'filters': '' } }, call: (request) => call_docker('GET', '/events', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
};
|
||||
|
||||
/*
|
||||
const exec_methods = {
|
||||
start: { args: { id: '', body: '' }, call: (request) => call_docker('POST', `/exec/${request?.id}/start`, { payload: request?.body }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/exec/${request?.id}/resize`, { query: request?.query }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/exec/${request?.id}/json`) },
|
||||
};
|
||||
*/
|
||||
|
||||
const container_methods = {
|
||||
list: { args: { query: { 'all': false, 'limit': false, 'size': false, 'filters': '' } }, call: (request) => call_docker('GET', '/containers/json', { query: request?.query }) },
|
||||
create: { args: { query: { 'name': '', 'platform': '' }, body: {} }, call: (request) => call_docker('POST', '/containers/create', { query: request?.query, payload: request?.body }) },
|
||||
inspect: { args: { id: '', query: { 'size': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/json`, { query: request?.query }) },
|
||||
top: { args: { id: '', query: { 'ps_args': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/top`, { query: request?.query }) },
|
||||
logs: { args: { id: '', query: {} }, call: (request) => call_docker('GET', `/containers/${request?.id}/logs`, { query: request?.query, onChunk: request?.onChunk }) },
|
||||
changes: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/changes`) },
|
||||
export: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/export`) },
|
||||
stats: { args: { id: '', query: { 'stream': false, 'one-shot': false } }, call: (request) => call_docker('GET', `/containers/${request?.id}/stats`, { query: request?.query }) },
|
||||
resize: { args: { id: '', query: { 'h': 0, 'w': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/resize`, { query: request?.query }) },
|
||||
start: { args: { id: '', query: { 'detachKeys': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/start`, { query: request?.query }) },
|
||||
stop: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/stop`, { query: request?.query }) },
|
||||
restart: { args: { id: '', query: { 'signal': '', 't': 0 } }, call: (request) => call_docker('POST', `/containers/${request?.id}/restart`, { query: request?.query }) },
|
||||
kill: { args: { id: '', query: { 'signal': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/kill`, { query: request?.query }) },
|
||||
update: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/update`, { payload: request?.body }) },
|
||||
rename: { args: { id: '', query: { 'name': '' } }, call: (request) => call_docker('POST', `/containers/${request?.id}/rename`, { query: request?.query }) },
|
||||
pause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/pause`) },
|
||||
unpause: { args: { id: '' }, call: (request) => call_docker('POST', `/containers/${request?.id}/unpause`) },
|
||||
// attach
|
||||
// attach websocket
|
||||
attach_ws: { args: { id: '' }, call: (request) => call_docker('GET', `/containers/${request?.id}/attach/ws`, { query: request?.query, headers: { 'Connection': 'Upgrade' } }) },
|
||||
// wait
|
||||
remove: { args: { id: '', query: { 'v': false, 'force': false, 'link': false } }, call: (request) => call_docker('DELETE', `/containers/${request?.id}`, { query: request?.query }) },
|
||||
// archive info
|
||||
info_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('HEAD', `/containers/${request?.id}/archive`, { query: request?.query }) },
|
||||
// archive get
|
||||
get_archive: { args: { id: '', query: { 'path': '' } }, call: (request) => call_docker('GET', `/containers/${request?.id}/archive`, { query: request?.query }) },
|
||||
// archive extract
|
||||
put_archive: { args: { id: '', query: { 'path': '', 'noOverwriteDirNonDir': '', 'copyUIDGID': '' }, body: '' }, call: (request) => call_docker('PUT', `/containers/${request?.id}/archive`, { query: request?.query, payload: request?.body }) },
|
||||
exec: { args: { id: '', opts: {} }, call: (request) => call_docker('POST', `/containers/${request?.id}/exec`, { payload: request?.opts }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/containers/prune', { query: request?.query }) },
|
||||
|
||||
// Not a docker command - but a local command to invoke ttyd so our browser can open websocket to docker
|
||||
// ttyd_start: { args: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, call: (request) => run_ttyd(request) },
|
||||
|
||||
};
|
||||
|
||||
|
||||
const image_methods = {
|
||||
list: { args: { query: { 'all': false, 'digests': false, 'shared-size': false, 'manifests': false, 'filters': '' } }, call: (request) => call_docker('GET', '/images/json', { query: request?.query }) },
|
||||
build: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
build_prune: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/build/prune', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
create: { args: { query: { '': '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', '/images/create', { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/json`) },
|
||||
history: { args: { id: '' }, call: (request) => call_docker('GET', `/images/${request?.id}/history`) },
|
||||
push: { args: { name: '', query: { tag: '', platform: '' }, headers: {}, onChunk: null }, call: (request) => call_docker('POST', `/images/${request?.name}/push`, { query: request?.query, headers: request?.headers, onChunk: request?.onChunk }) },
|
||||
tag: { args: { id: '', query: { 'repo': '', 'tag': '' } }, call: (request) => call_docker('POST', `/images/${request?.id}/tag`, { query: request?.query }) },
|
||||
remove: { args: { id: '', query: { 'force': false, 'noprune': false }, onChunk: null }, call: (request) => call_docker('DELETE', `/images/${request?.id}`, { query: request?.query, onChunk: request?.onChunk }) },
|
||||
search: { args: { query: { 'term': '', 'limit': 0, 'filters': '' } }, call: (request) => call_docker('GET', '/images/search', { query: request?.query }) },
|
||||
prune: { args: { query: { 'filters': '' }, onChunk: null }, call: (request) => call_docker('POST', '/images/prune', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
// create/commit
|
||||
get: { args: { id: '', onChunk: null }, call: (request) => call_docker('GET', `/images/${request?.id}/get`, { onChunk: request?.onChunk }) },
|
||||
// get == export several
|
||||
load: { args: { query: { 'quiet': false }, onChunk: null }, call: (request) => call_docker('POST', '/images/load', { query: request?.query, onChunk: request?.onChunk }) },
|
||||
};
|
||||
|
||||
|
||||
const network_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/networks', { query: request?.query }) },
|
||||
inspect: { args: { id: '', query: { 'verbose': false, 'scope': '' } }, call: (request) => call_docker('GET', `/networks/${request?.id}`, { query: request?.query }) },
|
||||
remove: { args: { id: '' }, call: (request) => call_docker('DELETE', `/networks/${request?.id}`) },
|
||||
create: { args: { body: {} }, call: (request) => call_docker('POST', '/networks/create', { payload: request?.body }) },
|
||||
connect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/connect`, { payload: request?.body }) },
|
||||
disconnect: { args: { id: '', body: {} }, call: (request) => call_docker('POST', `/networks/${request?.id}/disconnect`, { payload: request?.body }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/networks/prune', { query: request?.query }) },
|
||||
};
|
||||
|
||||
|
||||
const volume_methods = {
|
||||
list: { args: { query: { 'filters': '' } }, call: (request) => call_docker('GET', '/volumes', { query: request?.query }) },
|
||||
create: { args: { opts: {} }, call: (request) => call_docker('POST', '/volumes/create', { payload: request?.opts }) },
|
||||
inspect: { args: { id: '' }, call: (request) => call_docker('GET', `/volumes/${request?.id}`) },
|
||||
update: { args: { id: '', query: { 'version': 0 }, spec: {} }, call: (request) => call_docker('PUT', `/volumes/${request?.id}`, { query: request?.query, payload: request?.spec }) },
|
||||
remove: { args: { id: '', query: { 'force': false } }, call: (request) => call_docker('DELETE', `/volumes/${request?.id}`, { query: request?.query }) },
|
||||
prune: { args: { query: { 'filters': '' } }, call: (request) => call_docker('POST', '/volumes/prune', { query: request?.query }) },
|
||||
};
|
||||
|
||||
|
||||
// const methods = {
|
||||
// 'docker': core_methods,
|
||||
// 'docker.container': container_methods,
|
||||
// 'docker.exec': exec_methods,
|
||||
// 'docker.image': image_methods,
|
||||
// 'docker.network': network_methods,
|
||||
// 'docker.volume': volume_methods,
|
||||
// };
|
||||
|
||||
|
||||
// Determine JS API availability after core methods are ready
|
||||
const apiAvailabilityPromise = loadPromise.then(() => {
|
||||
if (!dockerHost) {
|
||||
js_api_available = false;
|
||||
return [js_api_available, dockerHost];
|
||||
}
|
||||
|
||||
return core_methods.ping.call()
|
||||
.then(res => {
|
||||
// ping returns raw 'OK' text; treat any truthy/OK as success
|
||||
const body = res?.body;
|
||||
js_api_available = body === 'OK';
|
||||
return [js_api_available, dockerHost];
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('JS API unavailable (likely CORS or network):', error?.message || error);
|
||||
js_api_available = false;
|
||||
return [js_api_available, dockerHost];
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return L.Class.extend({
|
||||
js_api_available: () => apiAvailabilityPromise.then(() => [js_api_available, dockerHost]),
|
||||
container_attach_ws: container_methods.attach_ws.call,
|
||||
container_changes: container_methods.changes.call,
|
||||
container_create: container_methods.create.call,
|
||||
// container_export: container_export, // use controller instead
|
||||
container_info_archive: container_methods.info_archive.call,
|
||||
container_inspect: container_methods.inspect.call,
|
||||
container_kill: container_methods.kill.call,
|
||||
container_list: container_methods.list.call,
|
||||
container_logs: container_methods.logs.call,
|
||||
container_pause: container_methods.pause.call,
|
||||
container_prune: container_methods.prune.call,
|
||||
container_remove: container_methods.remove.call,
|
||||
container_rename: container_methods.rename.call,
|
||||
container_restart: container_methods.restart.call,
|
||||
container_start: container_methods.start.call,
|
||||
container_stats: container_methods.stats.call,
|
||||
container_stop: container_methods.stop.call,
|
||||
container_top: container_methods.top.call,
|
||||
// container_ttyd_start: container_methods.ttyd_start.call,
|
||||
container_unpause: container_methods.unpause.call,
|
||||
container_update: container_methods.update.call,
|
||||
docker_version: core_methods.version.call,
|
||||
docker_info: core_methods.info.call,
|
||||
docker_ping: core_methods.ping.call,
|
||||
docker_df: core_methods.df.call,
|
||||
docker_events: core_methods.events.call,
|
||||
image_build: image_methods.build.call,
|
||||
image_create: image_methods.create.call,
|
||||
image_history: image_methods.history.call,
|
||||
image_inspect: image_methods.inspect.call,
|
||||
image_list: image_methods.list.call,
|
||||
image_prune: image_methods.prune.call,
|
||||
image_push: image_methods.push.call,
|
||||
image_remove: image_methods.remove.call,
|
||||
image_tag: image_methods.tag.call,
|
||||
network_connect: network_methods.connect.call,
|
||||
network_create: network_methods.create.call,
|
||||
network_disconnect: network_methods.disconnect.call,
|
||||
network_inspect: network_methods.inspect.call,
|
||||
network_list: network_methods.list.call,
|
||||
network_prune: network_methods.prune.call,
|
||||
network_remove: network_methods.remove.call,
|
||||
volume_create: volume_methods.create.call,
|
||||
volume_inspect: volume_methods.inspect.call,
|
||||
volume_list: volume_methods.list.call,
|
||||
volume_prune: volume_methods.prune.call,
|
||||
volume_remove: volume_methods.remove.call,
|
||||
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,12 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<title>Docker icon</title>
|
||||
<path d="M4.82 17.275c-.684 0-1.304-.56-1.304-1.24s.56-1.243 1.305-1.243c.748 0 1.31.56 1.31 1.242s-.622 1.24-1.305 1.24zm16.012-6.763c-.135-.992-.75-1.8-1.56-2.42l-.315-.25-.254.31c-.494.56-.69 1.553-.63 2.295.06.562.24 1.12.554 1.554-.254.13-.568.25-.81.377-.57.187-1.124.25-1.68.25H.097l-.06.37c-.12 1.182.06 2.42.562 3.54l.244.435v.06c1.5 2.483 4.17 3.6 7.078 3.6 5.594 0 10.182-2.42 12.357-7.633 1.425.062 2.864-.31 3.54-1.676l.18-.31-.3-.187c-.81-.494-1.92-.56-2.85-.31l-.018.002zm-8.008-.992h-2.428v2.42h2.43V9.518l-.002.003zm0-3.043h-2.428v2.42h2.43V6.48l-.002-.003zm0-3.104h-2.428v2.42h2.43v-2.42h-.002zm2.97 6.147H13.38v2.42h2.42V9.518l-.007.003zm-8.998 0H4.383v2.42h2.422V9.518l-.01.003zm3.03 0h-2.4v2.42H9.84V9.518l-.015.003zm-6.03 0H1.4v2.42h2.428V9.518l-.03.003zm6.03-3.043h-2.4v2.42H9.84V6.48l-.015-.003zm-3.045 0H4.387v2.42H6.8V6.48l-.016-.003z" />
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 756.26 596.9">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #1d63ed;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M743.96,245.25c-18.54-12.48-67.26-17.81-102.68-8.27-1.91-35.28-20.1-65.01-53.38-90.95l-12.32-8.27-8.21,12.4c-16.14,24.5-22.94,57.14-20.53,86.81,1.9,18.28,8.26,38.83,20.53,53.74-46.1,26.74-88.59,20.67-276.77,20.67H.06c-.85,42.49,5.98,124.23,57.96,190.77,5.74,7.35,12.04,14.46,18.87,21.31,42.26,42.32,106.11,73.35,201.59,73.44,145.66.13,270.46-78.6,346.37-268.97,24.98.41,90.92,4.48,123.19-57.88.79-1.05,8.21-16.54,8.21-16.54l-12.3-8.27ZM189.67,206.39h-81.7v81.7h81.7v-81.7ZM295.22,206.39h-81.7v81.7h81.7v-81.7ZM400.77,206.39h-81.7v81.7h81.7v-81.7ZM506.32,206.39h-81.7v81.7h81.7v-81.7ZM84.12,206.39H2.42v81.7h81.7v-81.7ZM189.67,103.2h-81.7v81.7h81.7v-81.7ZM295.22,103.2h-81.7v81.7h81.7v-81.7ZM400.77,103.2h-81.7v81.7h81.7v-81.7ZM400.77,0h-81.7v81.7h81.7V0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return L.view.extend({
|
||||
render() {
|
||||
const m = new form.Map('dockerd', _('Docker - Configuration'),
|
||||
_('DockerMan is a simple docker manager client for LuCI'));
|
||||
|
||||
let o, t, s, ss;
|
||||
|
||||
s = m.section(form.NamedSection, 'globals', 'section', _('Global settings'));
|
||||
|
||||
t = s.tab('globals', _('Globals'));
|
||||
|
||||
o = s.taboption('globals', form.Value, 'ps_flags',
|
||||
_('Default ps flags'),
|
||||
_('Flags passed to docker top (ps). Leave empty to use the built-in default.'));
|
||||
o.placeholder = '-ww';
|
||||
o.rmempty = true;
|
||||
o.optional = true;
|
||||
|
||||
o = s.taboption('globals', form.Value, 'api_version',
|
||||
_('Api Version'),
|
||||
_('Lock API endpoint to a specific version (helps guarantee behaviour).') + '<br/>' +
|
||||
_('Causes errors when a chosen API > Docker endpoint API support.'));
|
||||
o.rmempty = true;
|
||||
o.optional = true;
|
||||
o.value('v1.44');
|
||||
o.value('v1.45');
|
||||
o.value('v1.46');
|
||||
o.value('v1.47');
|
||||
o.value('v1.48');
|
||||
o.value('v1.49');
|
||||
o.value('v1.50');
|
||||
o.value('v1.51');
|
||||
o.value('v1.52');
|
||||
|
||||
// Check if local dockerd is available
|
||||
o = s.taboption('globals', form.DirectoryPicker, 'data_root', _('Docker Root Dir'),
|
||||
_('For local dockerd socket instances only.'));
|
||||
o.datatype = 'folder';
|
||||
o.default = '/opt/docker';
|
||||
o.root_directory = '/';
|
||||
o.show_hidden = true;
|
||||
|
||||
o = s.taboption('globals', form.Value, 'bip',
|
||||
_('Default bridge'),
|
||||
_('Configure the default bridge network'));
|
||||
o.placeholder = '172.17.0.1/16';
|
||||
o.datatype = 'ipaddr';
|
||||
|
||||
o = s.taboption('globals', form.DynamicList, 'registry_mirrors',
|
||||
_('Registry Mirrors'),
|
||||
_('It replaces the daemon registry mirrors with a new set of registry mirrors'));
|
||||
o.placeholder = _('Example: ') + 'https://hub-mirror.c.163.com';
|
||||
o.value('https://docker.io');
|
||||
o.value('https://ghcr.io');
|
||||
o.value('https://hub-mirror.c.163.com');
|
||||
|
||||
o = s.taboption('globals', form.ListValue, 'log_level',
|
||||
_('Log Level'),
|
||||
_('Set the logging level'));
|
||||
o.value('debug', _('Debug'));
|
||||
o.value('', _('Info')); // default
|
||||
o.value('warn', _('Warning'));
|
||||
o.value('error', _('Error'));
|
||||
o.value('fatal', _('Fatal'));
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.taboption('globals', form.DynamicList, 'hosts',
|
||||
_('Client connection'),
|
||||
_('Specifies where the Docker daemon will listen for client connections. default: ') + 'unix:///var/run/docker.sock' + '<br />' +
|
||||
_('Note that dockerd no longer listens on IP:port without TLS options after v27.'));
|
||||
o.placeholder = _('Example: tcp://0.0.0.0:2375');
|
||||
o.default = 'unix:///var/run/docker.sock';
|
||||
o.rmempty = true;
|
||||
o.value('unix:///var/run/docker.sock');
|
||||
o.value('tcp://0.0.0.0:2375');
|
||||
o.value('tcp://0.0.0.0:2376');
|
||||
o.value('tcp6://[::]:2375');
|
||||
o.value('tcp6://[::]:2376');
|
||||
|
||||
o = s.taboption('globals', widgets.NetworkSelect, '_luci_lan',
|
||||
_('LAN connection'),
|
||||
_('Set your LAN interface when docker listens on all addresses like 0.0.0.0 or ::.'));
|
||||
o.rmempty = true;
|
||||
o.noaliases = true;
|
||||
o.nocreate = true;
|
||||
|
||||
t = s.tab('auth', _('Registry Auth'));
|
||||
|
||||
o = s.taboption('auth', form.SectionValue, '__auth__', form.TableSection, 'auth', null,
|
||||
_('Used for push/pull operations on custom registries.') + '<br/>' +
|
||||
_('Destinations prefixed with a Registry host matching an entry in this table invoke its corresponding credentials.') + '<br/>' +
|
||||
_('The first match is used.') + '<br/>' +
|
||||
_('A Token is preferred over a Password.') + '<br/>' +
|
||||
_('Tokes and Passwords are not encrypted in the uci configuration.'));
|
||||
ss = o.subsection;
|
||||
ss.anonymous = true;
|
||||
ss.nodescriptions = true;
|
||||
ss.addremove = true;
|
||||
ss.sortable = true;
|
||||
|
||||
o = ss.option(form.Value, 'username',
|
||||
_('User'));
|
||||
o.placeholder = 'jbloggs';
|
||||
o.rmempty = false;
|
||||
|
||||
o = ss.option(form.Value, 'password',
|
||||
_('Password'));
|
||||
o.placeholder = 'foobar';
|
||||
o.password = true;
|
||||
|
||||
o = ss.option(form.Value, 'serveraddress',
|
||||
_('Registry'));
|
||||
o.datatype = 'or(hostname,hostport,ipaddr,ipaddrport)';
|
||||
o.placeholder = 'registry.foo.io[:443] | 192.0.2.1[:443]';
|
||||
o.rmempty = false;
|
||||
o.value('container-registry.oracle.com');
|
||||
o.value('registry.docker.io');
|
||||
o.value('gcr.io');
|
||||
o.value('ghcr.io');
|
||||
o.value('quay.io');
|
||||
o.value('registry.gitlab.com');
|
||||
o.value('registry.redhat.io');
|
||||
|
||||
o = ss.option(form.Value, 'token',
|
||||
_('Token'));
|
||||
o.password = true;
|
||||
|
||||
|
||||
return m.render();
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,950 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
/* API v1.52
|
||||
|
||||
POST /containers/create no longer supports configuring a container-wide MAC
|
||||
address via the container's Config.MacAddress field. A container's MAC address
|
||||
can now only be configured via endpoint settings when connecting to a network.
|
||||
|
||||
*/
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
const requestPath = L.env.requestpath;
|
||||
const duplicateId = requestPath[requestPath.length-1];
|
||||
const isDuplicate = requestPath[requestPath.length-2] === 'duplicate' && duplicateId;
|
||||
|
||||
const promises = [
|
||||
dm2.image_list().then(images => {
|
||||
return images.body || [];
|
||||
}),
|
||||
dm2.network_list().then(networks => {
|
||||
return networks.body || [];
|
||||
}),
|
||||
dm2.volume_list().then(volumes => {
|
||||
return volumes.body?.Volumes || [];
|
||||
}),
|
||||
dm2.docker_info().then(info => {
|
||||
const numcpus = info.body?.NCPU || 1.0;
|
||||
const memory = info.body?.MemTotal || 2**10;
|
||||
return {numcpus: numcpus, memory: memory};
|
||||
}),
|
||||
];
|
||||
|
||||
if (isDuplicate) {
|
||||
promises.push(
|
||||
dm2.container_inspect({ id: duplicateId }).then(container => {
|
||||
this.duplicateContainer = container.body || {};
|
||||
this.isDuplicate = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
},
|
||||
|
||||
render([image_list, network_list, volume_list, cpus_mem]) {
|
||||
this.volumes = volume_list;
|
||||
const view = this; // Capture the view context
|
||||
|
||||
// Load duplicate container config if available
|
||||
let containerData = {container: {}};
|
||||
let pageTitle = _('Create new docker container');
|
||||
|
||||
if (this.isDuplicate && this.duplicateContainer) {
|
||||
pageTitle = _('Duplicate/Edit Container: %s').format(this.duplicateContainer.Name?.substring(1) || '');
|
||||
const resolveImageId = (imageRef) => {
|
||||
if (!imageRef) return null;
|
||||
const match = (image_list || []).find(img => img.Id === imageRef || (Array.isArray(img.RepoTags) && img.RepoTags.includes(imageRef)));
|
||||
return match?.Id || null;
|
||||
};
|
||||
const c = this.duplicateContainer;
|
||||
const hostConfig = c.HostConfig || {};
|
||||
const resolvedImage = resolveImageId(c.Image) || resolveImageId(c.Config?.Image) || c.Image || c.Config?.Image || '';
|
||||
const builtInNetworks = new Set(['none', 'bridge', 'host']);
|
||||
const [netnames, nets] = Object.entries(c.NetworkSettings?.Networks || {});
|
||||
|
||||
containerData.container = {
|
||||
name: c.Name?.substring(1) || '',
|
||||
interactive: c.Config?.AttachStdin ? 1 : 0,
|
||||
tty: c.Config?.Tty ? 1 : 0,
|
||||
image: resolvedImage,
|
||||
privileged: hostConfig.Privileged ? 1 : 0,
|
||||
restart_policy: hostConfig.RestartPolicy?.Name || 'unless-stopped',
|
||||
network: (() => {
|
||||
return (netnames && (netnames.length > 0)) ? netnames[0] : '';
|
||||
})(),
|
||||
ipv4: (() => {
|
||||
if (builtInNetworks.has(netnames[0])) return '';
|
||||
return (nets && (nets.length > 0)) ? nets[0]?.IPAddress || '' : '';
|
||||
})(),
|
||||
ipv6: (() => {
|
||||
if (builtInNetworks.has(netnames[0])) return '';
|
||||
return (nets && (nets.length > 0)) ? nets[0]?.GlobalIPv6Address || '' : '';
|
||||
})(),
|
||||
ipv6_lla: (() => {
|
||||
if (builtInNetworks.has(netnames[0])) return '';
|
||||
return (nets && (nets.length > 0)) ? nets[0]?.LinkLocalIPv6Address || '' : '';
|
||||
})(),
|
||||
link: hostConfig.Links || [],
|
||||
dns: hostConfig.Dns || [],
|
||||
user: c.Config?.User || '',
|
||||
env: c.Config?.Env || [],
|
||||
volume: (hostConfig.Mounts || c.Mounts || []).map(m => {
|
||||
let source;
|
||||
const destination = m.Destination || m.Target || '';
|
||||
let opts = '';
|
||||
if (m.Type === 'image') {
|
||||
source = '@image';
|
||||
if (m.ImageOptions && m.ImageOptions.Subpath)
|
||||
opts = 'subpath=' + m.ImageOptions.Subpath;
|
||||
} else if (m.Type === 'tmpfs') {
|
||||
source = '@tmpfs';
|
||||
const tmpOpts = [];
|
||||
if (m.TmpfsOptions) {
|
||||
if (m.TmpfsOptions.SizeBytes) tmpOpts.push('size=' + m.TmpfsOptions.SizeBytes);
|
||||
if (m.TmpfsOptions.Mode) tmpOpts.push('mode=' + m.TmpfsOptions.Mode);
|
||||
if (Array.isArray(m.TmpfsOptions.Options)) {
|
||||
for (const o of m.TmpfsOptions.Options) {
|
||||
if (Array.isArray(o) && o.length === 2) tmpOpts.push(`${o[0]}=${o[1]}`);
|
||||
else if (Array.isArray(o) && o.length === 1) tmpOpts.push(o[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
opts = tmpOpts.join(',');
|
||||
} else if (m.Type === 'volume') {
|
||||
source = m.Source || '';
|
||||
// opts = m.Mode || '';
|
||||
} else {
|
||||
source = m.Source || '';
|
||||
opts = m.Mode || '';
|
||||
}
|
||||
return source + ':' + destination + (opts ? ':' + opts : '');
|
||||
}),
|
||||
publish: (() => {
|
||||
const ports = [];
|
||||
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
|
||||
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
|
||||
const hostPort = bindings[0].HostPort;
|
||||
ports.push(hostPort + ':' + containerPort);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
})(),
|
||||
command: c.Config?.Cmd ? c.Config?.Cmd.join(' ') : '',
|
||||
hostname: c.Config?.Hostname || '',
|
||||
publish_all: hostConfig.PublishAllPorts ? 1 : 0,
|
||||
device: (hostConfig.Devices || []).map(d => d.PathOnHost + ':' + d.PathInContainer + (d.CgroupPermissions ? ':' + d.CgroupPermissions : '')),
|
||||
tmpfs: (() => {
|
||||
const list = [];
|
||||
if (hostConfig.Tmpfs && typeof hostConfig.Tmpfs === 'object') {
|
||||
for (const [path, opts] of Object.entries(hostConfig.Tmpfs)) {
|
||||
list.push(path + (opts ? ':' + opts : ''));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
})(),
|
||||
sysctl: (() => {
|
||||
const list = [];
|
||||
if (hostConfig.Sysctls && typeof hostConfig.Sysctls === 'object') {
|
||||
for (const [key, value] of Object.entries(hostConfig.Sysctls)) {
|
||||
list.push(key + ':' + value);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
})(),
|
||||
cap_add: hostConfig.CapAdd || [],
|
||||
cpus: hostConfig.NanoCPUs ? (hostConfig.NanoCPUs / (10 ** 9)).toFixed(3) : '',
|
||||
cpu_shares: hostConfig.CpuShares || '',
|
||||
cpu_period: hostConfig.CpuPeriod || '',
|
||||
cpu_quota: hostConfig.CpuQuota || '',
|
||||
memory: hostConfig.Memory || '',
|
||||
memory_reservation: hostConfig.MemoryReservation || '',
|
||||
blkio_weight: hostConfig.BlkioWeight || '',
|
||||
log_opt: (() => {
|
||||
const list = [];
|
||||
const logConfig = hostConfig.LogConfig?.Config;
|
||||
if (logConfig && typeof logConfig === 'object') {
|
||||
for (const [key, value] of Object.entries(logConfig)) {
|
||||
list.push(key + '=' + value);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
// stuff JSONMap with container config
|
||||
const m = new form.JSONMap(containerData, _('Docker - Containers'));
|
||||
m.submit = true;
|
||||
m.reset = true;
|
||||
|
||||
let s = m.section(form.NamedSection, 'container', pageTitle);
|
||||
s.anonymous = true;
|
||||
s.nodescriptions = true;
|
||||
s.addremove = false;
|
||||
|
||||
let o;
|
||||
|
||||
o = s.option(form.Value, 'name', _('Container Name'),
|
||||
_('Name of the container that can be selected during container creation'));
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.option(form.Flag, 'interactive', _('Interactive (-i)'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
|
||||
o = s.option(form.Flag, 'tty', _('TTY (-t)'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
|
||||
o = s.option(form.ListValue, 'image', _('Docker Image'));
|
||||
o.rmempty = true;
|
||||
for (const image of image_list) {
|
||||
o.value(image.Id, image?.RepoTags?.[0]);
|
||||
}
|
||||
|
||||
o = s.option(form.Flag, 'pull', _('Always pull image first'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
|
||||
o = s.option(form.Flag, 'privileged', _('Privileged'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
|
||||
o = s.option(form.ListValue, 'restart_policy', _('Restart Policy'));
|
||||
o.rmempty = true;
|
||||
o.default = 'unless-stopped';
|
||||
o.value('no', _('No'));
|
||||
o.value('unless-stopped', _('Unless stopped'));
|
||||
o.value('always', _('Always'));
|
||||
o.value('on-failure', _('On failure'));
|
||||
|
||||
o = s.option(form.ListValue, 'network', _('Networks'));
|
||||
o.rmempty = true;
|
||||
this.buildNetworkListValues(network_list, o);
|
||||
|
||||
function not_with_a_docker_net(section_id, value) {
|
||||
if (!value || value === "") return true;
|
||||
const builtInNetworks = new Set(['none', 'bridge', 'host']);
|
||||
let dnet = this.section.getOption('network').getUIElement(section_id).getValue();
|
||||
const disallowed = builtInNetworks.has(dnet);
|
||||
if (disallowed) return _('Only for user-defined networks');
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'ipv4', _('IPv4 Address'));
|
||||
o.rmempty = true;
|
||||
o.datatype = 'ip4addr';
|
||||
o.validate = not_with_a_docker_net;
|
||||
|
||||
o = s.option(form.Value, 'ipv6', _('IPv6 Address'));
|
||||
o.rmempty = true;
|
||||
o.datatype = 'ip6addr';
|
||||
o.validate = not_with_a_docker_net;
|
||||
|
||||
o = s.option(form.Value, 'ipv6_lla', _('IPv6 Link-Local Address'));
|
||||
o.rmempty = true;
|
||||
o.datatype = 'ip6ll';
|
||||
o.validate = not_with_a_docker_net;
|
||||
|
||||
o = s.option(form.DynamicList, 'link', _('Links with other containers'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='container_name:alias';
|
||||
|
||||
o = s.option(form.DynamicList, 'dns', _('Set custom DNS servers'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='8.8.8.8';
|
||||
|
||||
o = s.option(form.Value, 'user', _('User(-u)'),
|
||||
_('The user that commands are run as inside the container. (format: name|uid[:group|gid])'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='1000:1000';
|
||||
|
||||
o = s.option(form.DynamicList, 'env', _('Environmental Variable(-e)'),
|
||||
_('Set environment variables inside the container'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='TZ=Europe/Paris';
|
||||
|
||||
o = s.option(form.DummyValue, 'volume', _('Mount(--mount)'),
|
||||
_('Bind mount a volume'));
|
||||
o.rmempty = true;
|
||||
o.cfgvalue = () => {
|
||||
const c_volumes = view.map.data.get('json', 'container', 'volume') || [];
|
||||
|
||||
const showVolumeModal = (index, initialEntry) => {
|
||||
let typeSelect, bindPicker, bindSourceField, volumeNameInput, volumeSourceField, pathInput, pathField, optionsDropdown, optionsField, subpathInput;
|
||||
let tmpfsSizeInput, tmpfsModeInput, tmpfsOptsInput, tmpfsSizeField, tmpfsModeField, tmpfsOptsField;
|
||||
const isEdit = index !== null;
|
||||
const modalTitle = isEdit ? _('Edit Mount') : _('Add Mount');
|
||||
|
||||
// Parse existing entry if editing and infer type from volumes list, image, or tmpfs
|
||||
let initialType = 'bind', initialSource = '', initialPath = '', initialOptions = '';
|
||||
if (isEdit && initialEntry) {
|
||||
const parts = (typeof initialEntry === 'string' ? initialEntry : '').split(':');
|
||||
initialSource = parts[0] || '';
|
||||
initialPath = parts[1] || '';
|
||||
initialOptions = parts[2] || '';
|
||||
// Infer type: tmpfs, volume, image, else bind
|
||||
const isTmpfs = (initialSource === '@tmpfs');
|
||||
const isVolume = (volume_list || []).some(v => v.Name === initialSource || v.Id === initialSource);
|
||||
const isImage = (initialSource === '@image');
|
||||
initialType = isTmpfs ? 'tmpfs' : (isVolume ? 'volume' : (isImage ? 'image' : 'bind'));
|
||||
}
|
||||
|
||||
const existingOptions = (typeof initialOptions === 'string' ? initialOptions : '').split(',').map(o => o.trim()).filter(Boolean);
|
||||
|
||||
// Type-specific options for dropdowns
|
||||
const bindOptions = {
|
||||
'ro': _('Read-only (ro)'),
|
||||
'rw': _('Read-write (rw)'),
|
||||
'private': _('Propagation: private'),
|
||||
'rprivate': _('Propagation: rprivate'),
|
||||
'shared': _('Propagation: shared'),
|
||||
'rshared': _('Propagation: rshared'),
|
||||
'slave': _('Propagation: slave'),
|
||||
'rslave': _('Propagation: rslave')
|
||||
};
|
||||
|
||||
const volumeOptions = {
|
||||
// 'ro': _('Read-only (ro)'),
|
||||
// 'rw': _('Read-write (rw)'),
|
||||
'nocopy': _('No copy (nocopy)')
|
||||
};
|
||||
|
||||
const getOptionsForType = (type) => type === 'bind' ? bindOptions : volumeOptions;
|
||||
|
||||
const namesListId = 'volname-list-' + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
// Create dropdown for options - updates based on type
|
||||
optionsDropdown = new ui.Dropdown(existingOptions, getOptionsForType(initialType), {
|
||||
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
|
||||
multiple: true,
|
||||
optional: true,
|
||||
display_items: 2,
|
||||
placeholder: _('Select options...')
|
||||
});
|
||||
|
||||
const createField = (label, input) => {
|
||||
return E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, label),
|
||||
E('div', { 'class': 'cbi-value-field' }, Array.isArray(input) ? input : [input])
|
||||
]);
|
||||
};
|
||||
|
||||
// Type select
|
||||
const typeOptions = [
|
||||
E('option', { value: 'bind' }, _('Bind (host directory)')),
|
||||
E('option', { value: 'volume' }, _('Volume (named)')),
|
||||
E('option', { value: 'image' }, _('Image (from image)')),
|
||||
E('option', { value: 'tmpfs' }, _('Tmpfs'))
|
||||
];
|
||||
typeSelect = E('select', { 'class': 'cbi-input-select' }, typeOptions);
|
||||
typeSelect.value = initialType;
|
||||
|
||||
// Bind directory picker using ui.FileUpload
|
||||
bindPicker = new ui.FileUpload(initialType === 'bind' ? initialSource : '', {
|
||||
browser: false,
|
||||
directory_select: true,
|
||||
directory_create: false,
|
||||
enable_upload: false,
|
||||
enable_remove: false,
|
||||
enable_download: false,
|
||||
root_directory: '/',
|
||||
show_hidden: true
|
||||
});
|
||||
|
||||
// Volume name input with datalist
|
||||
volumeNameInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('Enter volume name or pick existing'),
|
||||
'list': namesListId,
|
||||
'value': initialType === 'volume' ? initialSource : ''
|
||||
});
|
||||
volumeSourceField = createField(_('Volume Name'), [
|
||||
E('div', { 'style': 'position: relative;' }, [
|
||||
volumeNameInput,
|
||||
E('span', { 'style': 'pointer-events: none;' }, '▼')
|
||||
]),
|
||||
E('datalist', { 'id': namesListId }, [
|
||||
...volume_list.map(vol => E('option', { 'value': vol.Name }, vol.Name))
|
||||
])
|
||||
]);
|
||||
|
||||
// Tmpfs inputs - pre-populate if editing
|
||||
let tmpfsSizeVal = '', tmpfsModeVal = '', tmpfsOptsVal = '';
|
||||
if (initialType === 'tmpfs' && existingOptions.length) {
|
||||
const rest = [];
|
||||
existingOptions.forEach(o => {
|
||||
if (o.startsWith('size=')) tmpfsSizeVal = o.slice('size='.length);
|
||||
else if (o.startsWith('mode=')) tmpfsModeVal = view.modeToRwx(o.slice('mode='.length));
|
||||
else rest.push(o);
|
||||
});
|
||||
tmpfsOptsVal = rest.join(',');
|
||||
}
|
||||
|
||||
tmpfsSizeField = createField(_('Size'),
|
||||
tmpfsSizeInput = E('input', {
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': '128m',
|
||||
'value': tmpfsSizeVal
|
||||
})
|
||||
);
|
||||
tmpfsModeField = createField(_('Mode'),
|
||||
tmpfsModeInput = E('input', {
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'rwxr-xr-x or 1770',
|
||||
'value': tmpfsModeVal
|
||||
})
|
||||
);
|
||||
tmpfsOptsField = createField(_('tmpfs Options'),
|
||||
tmpfsOptsInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'nr_blocks=blocks,...',
|
||||
'value': tmpfsOptsVal
|
||||
})
|
||||
);
|
||||
|
||||
// Render bindPicker and show modal
|
||||
Promise.resolve(bindPicker.render()).then(bindPickerNode => {
|
||||
bindSourceField = createField(_('Host Directory'), bindPickerNode);
|
||||
|
||||
const updateOptions = (selectedType) => {
|
||||
optionsField.querySelector('.cbi-value-field').innerHTML = '';
|
||||
if (selectedType === 'image') {
|
||||
// For image mounts, show a Subpath text input (only option)
|
||||
subpathInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('/path/in/image'),
|
||||
'value': (initialType === 'image' && existingOptions.find(o => o.startsWith('subpath='))) ? existingOptions.find(o => o.startsWith('subpath=')).slice('subpath='.length) : ''
|
||||
});
|
||||
optionsField.querySelector('.cbi-value-title').textContent = _('Subpath');
|
||||
optionsField.querySelector('.cbi-value-field').appendChild(subpathInput);
|
||||
} else if (selectedType === 'tmpfs') {
|
||||
// Tmpfs fields are shown as main fields, hide options field
|
||||
optionsField.style.display = 'none';
|
||||
} else {
|
||||
optionsField.querySelector('.cbi-value-title').textContent = _('Options');
|
||||
// Recreate dropdown with new options
|
||||
const currentValue = optionsDropdown.getValue();
|
||||
optionsDropdown = new ui.Dropdown(currentValue, getOptionsForType(selectedType), {
|
||||
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
|
||||
multiple: true,
|
||||
optional: true,
|
||||
display_items: 2,
|
||||
placeholder: _('Select options...')
|
||||
});
|
||||
optionsField.querySelector('.cbi-value-field').appendChild(optionsDropdown.render());
|
||||
optionsField.style.display = '';
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSources = () => {
|
||||
const isBind = typeSelect.value === 'bind';
|
||||
const isVolume = typeSelect.value === 'volume';
|
||||
const isImage = typeSelect.value === 'image';
|
||||
const isTmpfs = typeSelect.value === 'tmpfs';
|
||||
bindSourceField.style.display = isBind ? '' : 'none';
|
||||
volumeSourceField.style.display = isVolume ? '' : 'none';
|
||||
pathField.style.display = isImage ? 'none' : '';
|
||||
tmpfsSizeField.style.display = isTmpfs ? '' : 'none';
|
||||
tmpfsModeField.style.display = isTmpfs ? '' : 'none';
|
||||
tmpfsOptsField.style.display = isTmpfs ? '' : 'none';
|
||||
updateOptions(typeSelect.value);
|
||||
};
|
||||
|
||||
optionsField = createField(_('Options'), optionsDropdown.render());
|
||||
|
||||
ui.showModal(modalTitle, [
|
||||
createField(_('Type'), typeSelect),
|
||||
bindSourceField,
|
||||
volumeSourceField,
|
||||
pathField = createField(_('Mount Path'),
|
||||
pathInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('/mnt/path'),
|
||||
'value': initialPath
|
||||
})
|
||||
),
|
||||
tmpfsSizeField,
|
||||
tmpfsModeField,
|
||||
tmpfsOptsField,
|
||||
optionsField,
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, [_('Cancel')]),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
const selectedType = typeSelect.value;
|
||||
const sourcePath = selectedType === 'bind'
|
||||
? (bindPicker.getValue() || '').trim()
|
||||
: (selectedType === 'volume'
|
||||
? (volumeNameInput.value || '').trim()
|
||||
: (selectedType === 'tmpfs' ? '@tmpfs' : '@image'));
|
||||
const subpathVal = (selectedType === 'image') ? (subpathInput?.value || '').trim() : '';
|
||||
const mountPath = (selectedType === 'image') ? subpathVal : pathInput.value.trim();
|
||||
let selectedOptions;
|
||||
if (selectedType === 'image') {
|
||||
selectedOptions = subpathVal ? ('subpath=' + subpathVal) : '';
|
||||
} else if (selectedType === 'tmpfs') {
|
||||
const opts = [];
|
||||
const sizeValRaw = (tmpfsSizeInput?.value || '').trim();
|
||||
const modeValRaw = (tmpfsModeInput?.value || '').trim();
|
||||
const extraVal = (tmpfsOptsInput?.value || '').trim();
|
||||
const parsedSize = sizeValRaw ? view.parseMemory(sizeValRaw) : undefined;
|
||||
const parsedMode = view.rwxToMode(modeValRaw);
|
||||
if (parsedSize) opts.push('size=' + parsedSize);
|
||||
else if (sizeValRaw) opts.push('size=' + sizeValRaw); // fallback if parse fails
|
||||
if (parsedMode !== undefined) opts.push('mode=' + parsedMode);
|
||||
if (extraVal) opts.push(...extraVal.split(',').map(o => o.trim()).filter(Boolean));
|
||||
selectedOptions = opts.join(',');
|
||||
} else {
|
||||
selectedOptions = optionsDropdown.getValue().join(',');
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
ui.addTimeLimitedNotification(null, [_('Please choose a directory or enter a volume name')], 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType !== 'image' && !mountPath) {
|
||||
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
if (selectedType === 'image' && !subpathVal) {
|
||||
ui.addTimeLimitedNotification(null, [_('Please enter a subpath')], 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
if (selectedType === 'tmpfs' && !mountPath) {
|
||||
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
|
||||
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
|
||||
const volumeEntry = selectedOptions ? (sourcePath + ':' + mountPath + ':' + selectedOptions) : (sourcePath + ':' + mountPath);
|
||||
let updatedVolumes;
|
||||
if (isEdit) {
|
||||
updatedVolumes = [...currentVolumes];
|
||||
updatedVolumes[index] = volumeEntry;
|
||||
} else {
|
||||
updatedVolumes = Array.isArray(currentVolumes) ? [...currentVolumes, volumeEntry] : [volumeEntry];
|
||||
}
|
||||
view.map.data.set('json', 'container', 'volume', updatedVolumes);
|
||||
|
||||
return view.map.render();
|
||||
})
|
||||
}, [isEdit ? _('Update') : _('Add')])
|
||||
])
|
||||
]);
|
||||
|
||||
toggleSources();
|
||||
typeSelect.addEventListener('change', toggleSources);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return E('div', { 'class': 'cbi-dynlist' }, [
|
||||
...(c_volumes.length > 0 ? c_volumes.map((v, idx) => E('div', {
|
||||
'class': 'cbi-dynlist-item',
|
||||
'style': 'display: flex; justify-content: space-between; align-items: center; padding: 8px 5px; margin-bottom: 8px; gap: 10px;'
|
||||
}, [
|
||||
E('span', {
|
||||
'style': 'cursor: pointer; flex: 1;',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
showVolumeModal(idx, v);
|
||||
})
|
||||
}, v),
|
||||
E('button', {
|
||||
'style': 'padding: 5px; color: #c44;',
|
||||
'class': 'cbi-button-negative remove',
|
||||
'title': _('Delete this volume mount'),
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
|
||||
const updatedVolumes = currentVolumes.filter((_, i) => i !== idx);
|
||||
view.map.data.set('json', 'container', 'volume', updatedVolumes);
|
||||
return view.map.render();
|
||||
})
|
||||
}, ['✕'])
|
||||
])) : [E('div', { 'style': 'padding: 5px; color: #999;' }, _('No volumes available'))]),
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
showVolumeModal(null, null);
|
||||
})
|
||||
}, [_('Add Mount')])
|
||||
]);
|
||||
};
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'publish', _('Exposed Ports(-p)'),
|
||||
_("Publish container's port(s) to the host"));
|
||||
o.rmempty = true;
|
||||
o.placeholder='2200:22/tcp';
|
||||
|
||||
o = s.option(form.Value, 'command', _('Run command'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='/bin/sh init.sh';
|
||||
|
||||
o = s.option(form.Flag, 'advanced', _('Advanced'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
|
||||
o = s.option(form.Value, 'hostname', _('Host Name'),
|
||||
_('The hostname to use for the container'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='/bin/sh init.sh';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.Flag, 'publish_all', _('Exposed All Ports(-P)'),
|
||||
_("Allocates an ephemeral host port for all of a container's exposed ports"));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.DynamicList, 'device', _('Device(--device)'),
|
||||
_('Add host device to the container'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='/dev/sda:/dev/xvdc:rwm';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.DynamicList, 'tmpfs', _('Tmpfs(--tmpfs)'),
|
||||
_('Mount tmpfs directory'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='/run:rw,noexec,nosuid,size=65536k';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.DynamicList, 'sysctl', _('Sysctl(--sysctl)'),
|
||||
_('Sysctls (kernel parameters) options'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='net.ipv4.ip_forward=1';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.DynamicList, 'cap_add', _('CAP-ADD(--cap-add)'),
|
||||
_('A list of kernel capabilities to add to the container'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='NET_ADMIN';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.Value, 'cpus', _('CPUs'),
|
||||
_('Number of CPUs. Number is a fractional number. 0.000 means no limit'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='1.5';
|
||||
o.datatype = 'ufloat';
|
||||
o.depends('advanced', 1);
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'cpu_period', _('CPU Period'),
|
||||
_('The length of a CPU period in microseconds'));
|
||||
o.rmempty = true;
|
||||
o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.Value, 'cpu_quota', _('CPU Quota'),
|
||||
_('Microseconds of CPU time that the container can get in a CPU period'));
|
||||
o.rmempty = true;
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.Value, 'cpu_shares', _('CPU Shares Weight'),
|
||||
_('CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='1024';
|
||||
o.datatype = 'uinteger';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.Value, 'memory', _('Memory'),
|
||||
_('Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M'));
|
||||
o.rmempty = true;
|
||||
o.placeholder = '128m';
|
||||
o.depends('advanced', 1);
|
||||
o.write = function(section_id, value) {
|
||||
if (!value || value == 0) return 0;
|
||||
this.map.data.data[section_id].memory = view.parseMemory(value);;
|
||||
return view.parseMemory(value);
|
||||
};
|
||||
o.validate = function(section_id, value) {
|
||||
if (!value) return true;
|
||||
if (value > view.memory) return _(`Only ${view.memory} bytes available`);
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'memory_reservation', _('Memory Reservation'));
|
||||
o.depends('advanced', 1);
|
||||
o.placeholder = '128m';
|
||||
o.cfgvalue = (sid, val) => {
|
||||
const res = view.map.data.data[sid].memory_reservation;
|
||||
return res ? '%1024.2m'.format(res) : 0;
|
||||
};
|
||||
o.write = function(section_id, value) {
|
||||
if (!value || value == 0) return 0;
|
||||
this.map.data.data[section_id].memory_reservation = view.parseMemory(value);;
|
||||
return view.parseMemory(value);
|
||||
};
|
||||
|
||||
o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'),
|
||||
_('Block IO weight (relative weight) accepts a weight value between 10 and 1000.'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='500';
|
||||
o.datatype = 'and(uinteger,min(10),max(1000))';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
o = s.option(form.DynamicList, 'log_opt', _('Log driver options'),
|
||||
_('The logging configuration for this container'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='max-size=1m';
|
||||
o.depends('advanced', 1);
|
||||
|
||||
|
||||
this.map = m;
|
||||
|
||||
return m.render();
|
||||
|
||||
},
|
||||
|
||||
handleSave(ev) {
|
||||
ev?.preventDefault();
|
||||
const view = this; // Capture the view context
|
||||
const map = this.map;
|
||||
if (!map)
|
||||
return Promise.reject(new Error(_('Form is not ready yet.')));
|
||||
|
||||
const listToKv = view.listToKv;
|
||||
|
||||
const toBool = (val) => (val === 1 || val === '1' || val === true);
|
||||
const toInt = (val) => val ? Number.parseInt(val) : undefined;
|
||||
const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
|
||||
|
||||
return map.parse()
|
||||
.then(() => {
|
||||
const get = (opt) => map.data.get('json', 'container', opt);
|
||||
const name = get('name');
|
||||
// const pull = toBool(get('pull'));
|
||||
const network = get('network');
|
||||
const publish = get('publish');
|
||||
const command = get('command');
|
||||
// const publish_all = toBool(get('publish_all'));
|
||||
const device = get('device');
|
||||
const tmpfs = get('tmpfs');
|
||||
const sysctl = get('sysctl');
|
||||
const log_opt = get('log_opt');
|
||||
|
||||
const createBody = {
|
||||
Hostname: get('hostname'),
|
||||
User: get('user'),
|
||||
AttachStdin: toBool(get('interactive')),
|
||||
Tty: toBool(get('tty')),
|
||||
OpenStdin: toBool(get('interactive')),
|
||||
Env: get('env'),
|
||||
Cmd: command ? command.split(' ') : null,
|
||||
Image: get('image'),
|
||||
HostConfig: {
|
||||
CpuShares: toInt(get('cpu_shares')),
|
||||
Memory: toInt(get('memory')),
|
||||
MemoryReservation: toInt(get('memory_reservation')),
|
||||
BlkioWeight: toInt(get('blkio_weight')),
|
||||
CapAdd: get('cap_add'),
|
||||
CpuPeriod: toInt(get('cpu_period')),
|
||||
CpuQuota: toInt(get('cpu_quota')),
|
||||
NanoCPUs: toFloat(get('cpus')) * (10 ** 9),
|
||||
Devices: device ? device
|
||||
.filter(d => d && typeof d === 'string' && d.trim().length > 0)
|
||||
.map(d => {
|
||||
const parts = d.split(':');
|
||||
return {
|
||||
PathOnHost: parts[0],
|
||||
PathInContainer: parts[1] || parts[0],
|
||||
CgroupPermissions: parts[2] || 'rwm'
|
||||
};
|
||||
}) : undefined,
|
||||
LogConfig: log_opt ? {
|
||||
Type: 'json-file',
|
||||
Config: listToKv(log_opt)
|
||||
} : undefined,
|
||||
NetworkMode: network,
|
||||
PortBindings: publish ? Object.fromEntries(
|
||||
(Array.isArray(publish) ? publish : [publish])
|
||||
.filter(p => p && typeof p === 'string' && p.trim().length > 0)
|
||||
.map(p => {
|
||||
const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
|
||||
if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
|
||||
return null;
|
||||
}).filter(Boolean)
|
||||
) : undefined,
|
||||
Mounts: undefined,
|
||||
Links: get('link'),
|
||||
Privileged: toBool(get('privileged')),
|
||||
PublishAllPorts: toBool(get('publish_all')),
|
||||
RestartPolicy: { Name: get('restart_policy') },
|
||||
Dns: get('dns'),
|
||||
Tmpfs: tmpfs ? Object.fromEntries(
|
||||
(Array.isArray(tmpfs) ? tmpfs : [tmpfs])
|
||||
.filter(t => t && typeof t === 'string' && t.trim().length > 0)
|
||||
.map(t => {
|
||||
const parts = t.split(':');
|
||||
return [parts[0], parts[1] || ''];
|
||||
})
|
||||
) : undefined,
|
||||
Sysctls: sysctl ? listToKv(sysctl) : undefined,
|
||||
},
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } },
|
||||
}
|
||||
};
|
||||
|
||||
// Parse volume entries and populate Mounts
|
||||
const volumeEntries = get('volume') || [];
|
||||
const volumeNames = new Set((view.volumes || []).map(v => v.Name));
|
||||
const volumeIds = new Set((view.volumes || []).map(v => v.Id));
|
||||
const mounts = [];
|
||||
for (const entry of volumeEntries) {
|
||||
let e = typeof entry === 'string' ? entry : '';
|
||||
let f = e.split(':')?.map(e => e && e.trim() || '');
|
||||
let source = f[0];
|
||||
let target = f[1];
|
||||
let options = f[2];
|
||||
|
||||
if (!options) options = '';
|
||||
|
||||
// Validate source and target are not empty
|
||||
if (!source || !target) {
|
||||
console.warn('Invalid volume entry (empty source or target):', entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind
|
||||
let type = 'bind';
|
||||
if (source === '@image') {
|
||||
type = 'image';
|
||||
} else if (source === '@tmpfs') {
|
||||
type = 'tmpfs';
|
||||
} else if (volumeNames.has(source) || volumeIds.has(source)) {
|
||||
type = 'volume';
|
||||
}
|
||||
|
||||
const mount = {
|
||||
Type: type,
|
||||
Source: source,
|
||||
Target: target,
|
||||
ReadOnly: options.split(',').includes('ro')
|
||||
};
|
||||
|
||||
// Add type-specific options
|
||||
if (type === 'bind') {
|
||||
const bindOptions = {};
|
||||
const propagation = options.split(',').find(opt =>
|
||||
['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt)
|
||||
);
|
||||
if (propagation) bindOptions.Propagation = propagation;
|
||||
if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions;
|
||||
} else if (type === 'volume') {
|
||||
const volumeOptions = {};
|
||||
if (options.includes('nocopy')) volumeOptions.NoCopy = true;
|
||||
if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions;
|
||||
} else if (type === 'image') {
|
||||
const imageOptions = {};
|
||||
const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath='));
|
||||
if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length);
|
||||
if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions;
|
||||
// Image source is implied by selected container image
|
||||
mount.Source = createBody.Image;
|
||||
} else if (type === 'tmpfs') {
|
||||
const tmpfsOptions = {};
|
||||
const optsList = options.split(',').map(o => o.trim()).filter(Boolean);
|
||||
for (const opt of optsList) {
|
||||
if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length));
|
||||
else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length));
|
||||
else {
|
||||
if (!tmpfsOptions.Options) tmpfsOptions.Options = [];
|
||||
const kv = opt.split('=');
|
||||
if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]);
|
||||
else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]);
|
||||
}
|
||||
}
|
||||
mount.Source = '';
|
||||
if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions;
|
||||
}
|
||||
|
||||
mounts.push(mount);
|
||||
}
|
||||
createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined;
|
||||
|
||||
// Clean up undefined values
|
||||
Object.keys(createBody.HostConfig).forEach(key => {
|
||||
if (createBody.HostConfig[key] === undefined)
|
||||
delete createBody.HostConfig[key];
|
||||
});
|
||||
|
||||
if (!name)
|
||||
return Promise.reject(new Error(_('No name specified.')));
|
||||
|
||||
return { name, createBody };
|
||||
})
|
||||
.then(({ name, createBody }) => view.executeDockerAction(
|
||||
dm2.container_create,
|
||||
{ query: { name: name }, body: createBody },
|
||||
_('Create container'),
|
||||
{
|
||||
showOutput: false,
|
||||
showSuccess: false,
|
||||
onSuccess: (response) => {
|
||||
const isDuplicate = view.isDuplicate && view.duplicateContainer;
|
||||
const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created');
|
||||
const msgText = isDuplicate ?
|
||||
_('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) :
|
||||
_('New container has been created.');
|
||||
|
||||
if (response?.body?.Warnings) {
|
||||
view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning');
|
||||
} else {
|
||||
view.showNotification(msgTitle, msgText, 4000, 'success');
|
||||
}
|
||||
window.location.href = `${this.dockerman_url}/containers`;
|
||||
}
|
||||
}
|
||||
))
|
||||
.catch((err) => {
|
||||
view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error');
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require poll';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
/* API v1.52:
|
||||
|
||||
GET /containers/{id}/json: the NetworkSettings no longer returns the deprecated
|
||||
Bridge, HairpinMode, LinkLocalIPv6Address, LinkLocalIPv6PrefixLen,
|
||||
SecondaryIPAddresses, SecondaryIPv6Addresses, EndpointID, Gateway,
|
||||
GlobalIPv6Address, GlobalIPv6PrefixLen, IPAddress, IPPrefixLen, IPv6Gateway,
|
||||
and MacAddress fields. These fields were deprecated in API v1.21 (docker
|
||||
v1.9.0) but kept around for backward compatibility.
|
||||
|
||||
*/
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
dm2.container_list({query: {all: true}}),
|
||||
dm2.image_list({query: {all: true}}),
|
||||
dm2.network_list({query: {all: true}}),
|
||||
]);
|
||||
},
|
||||
|
||||
render([containers, images, networks]) {
|
||||
if (containers?.code !== 200) {
|
||||
return E('div', {}, [ containers?.body?.message ]);
|
||||
}
|
||||
|
||||
let container_list = containers.body;
|
||||
let network_list = networks.body;
|
||||
let image_list = images.body;
|
||||
|
||||
const view = this;
|
||||
let containerTable;
|
||||
|
||||
|
||||
const m = new form.JSONMap({container: view.getContainersTable(container_list, image_list, network_list), prune: {}},
|
||||
_('Docker - Containers'),
|
||||
_('This page displays all docker Containers that have been created on the connected docker host.') + '<br />' +
|
||||
_('Note: docker provides no container import facility.'));
|
||||
m.submit = false;
|
||||
m.reset = false;
|
||||
|
||||
let s, o;
|
||||
|
||||
|
||||
let pollPending = null;
|
||||
let conSec = null;
|
||||
const calculateTotals = () => {
|
||||
return {
|
||||
running_total: Array.isArray(container_list) ?
|
||||
container_list.filter(c => c?.State === 'running').length : 0,
|
||||
paused_total: Array.isArray(container_list) ?
|
||||
container_list.filter(c => c?.State === 'paused').length : 0,
|
||||
stopped_total: Array.isArray(container_list) ?
|
||||
container_list.filter(c => ['exited', 'created'].includes(c?.State)).length : 0
|
||||
};
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (pollPending) return pollPending;
|
||||
pollPending = view.load().then(([containers2, images2, networks2]) => {
|
||||
image_list = images2.body;
|
||||
container_list = containers2.body;
|
||||
network_list = networks2.body;
|
||||
m.data = new m.data.constructor({ container: view.getContainersTable(container_list, image_list, network_list), prune: {} });
|
||||
|
||||
const totals = calculateTotals();
|
||||
if (conSec) {
|
||||
conSec.footer = [
|
||||
`${_('Total')} ${container_list.length}`,
|
||||
[
|
||||
`${_('Running')} ${totals.running_total}`,
|
||||
E('br'),
|
||||
`${_('Paused')} ${totals.paused_total}`,
|
||||
E('br'),
|
||||
`${_('Stopped')} ${totals.stopped_total}`,
|
||||
],
|
||||
'',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
return m.render();
|
||||
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
|
||||
return pollPending;
|
||||
};
|
||||
|
||||
s = m.section(form.TableSection, 'prune', _('Containers overview'), null);
|
||||
s.addremove = false;
|
||||
s.anonymous = true;
|
||||
|
||||
const prune = s.option(form.Button, '_prune', null);
|
||||
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
|
||||
prune.inputstyle = 'negative';
|
||||
prune.onclick = L.bind(function(section_id, ev) {
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { },
|
||||
commandCPath: '/containers/prune',
|
||||
commandDPath: '/containers/prune',
|
||||
commandTitle: dm2.ActionTypes['prune'].i18n,
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch { }
|
||||
},
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
}, this);
|
||||
|
||||
const totals = calculateTotals();
|
||||
let running_total = totals.running_total;
|
||||
let paused_total = totals.paused_total;
|
||||
let stopped_total = totals.stopped_total;
|
||||
|
||||
conSec = m.section(form.TableSection, 'container');
|
||||
conSec.anonymous = true;
|
||||
conSec.nodescriptions = true;
|
||||
conSec.addremove = true;
|
||||
conSec.sortable = true;
|
||||
conSec.filterrow = true;
|
||||
conSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
|
||||
conSec.footer = [
|
||||
`${_('Total')} ${container_list.length}`,
|
||||
[
|
||||
`${_('Running')} ${running_total}`,
|
||||
E('br'),
|
||||
`${_('Paused')} ${paused_total}`,
|
||||
E('br'),
|
||||
`${_('Stopped')} ${stopped_total}`,
|
||||
],
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
conSec.handleAdd = function(section_id, ev) {
|
||||
window.location.href = `${view.dockerman_url}/container_new`;
|
||||
};
|
||||
|
||||
conSec.renderRowActions = function(sid) {
|
||||
const cont = this.map.data.data[sid];
|
||||
return view.buildContainerActions(cont);
|
||||
}
|
||||
|
||||
o = conSec.option(form.DummyValue, 'cid', _('Container'));
|
||||
o = conSec.option(form.DummyValue, 'State', _('State'));
|
||||
o = conSec.option(form.DummyValue, 'Networks', _('Networks'));
|
||||
o.rawhtml = true;
|
||||
o = conSec.option(form.DummyValue, 'Ports', _('Ports'));
|
||||
o.rawhtml = true;
|
||||
o = conSec.option(form.DummyValue, 'Command', _('Command'));
|
||||
o.width = 200;
|
||||
o = conSec.option(form.DummyValue, 'Created', _('Created'));
|
||||
|
||||
poll.add(L.bind(() => { refresh(); }, this), 10);
|
||||
|
||||
this.insertOutputFrame(conSec, m);
|
||||
return m.render();
|
||||
|
||||
},
|
||||
|
||||
buildContainerActions(cont, idx) {
|
||||
const view = this;
|
||||
const isRunning = cont?.State === 'running';
|
||||
const isPaused = cont?.State === 'paused';
|
||||
const btns = [
|
||||
E('button', {
|
||||
'class': 'cbi-button view',
|
||||
'title': dm2.ActionTypes['inspect'].i18n,
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_inspect,
|
||||
{id: cont.Id},
|
||||
dm2.ActionTypes['inspect'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
)
|
||||
}, [dm2.ActionTypes['inspect'].e]),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive edit',
|
||||
'title': _('Edit this container'),
|
||||
'click': () => window.location.href = `${view.dockerman_url}/container/${cont?.Id}`
|
||||
}, [dm2.ActionTypes['edit'].e]),
|
||||
|
||||
(() => {
|
||||
const icon = isRunning
|
||||
? dm2.Types['container'].sub['pause'].e
|
||||
: (isPaused
|
||||
? dm2.Types['container'].sub['unpause'].e
|
||||
: dm2.Types['container'].sub['start'].e);
|
||||
const title = isRunning
|
||||
? _('Pause this container')
|
||||
: (isPaused ? _('Unpause this container') : _('Start this container'));
|
||||
const handler = isRunning
|
||||
? () => view.executeDockerAction(
|
||||
dm2.container_pause,
|
||||
{id: cont.Id},
|
||||
dm2.Types['container'].sub['pause'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
)
|
||||
: (isPaused ? () => view.executeDockerAction(
|
||||
dm2.container_unpause,
|
||||
{id: cont.Id},
|
||||
dm2.Types['container'].sub['unpause'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
) : () => view.executeDockerAction(
|
||||
dm2.container_start,
|
||||
{id: cont.Id},
|
||||
dm2.Types['container'].sub['start'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
));
|
||||
const btnClass = isRunning ? 'cbi-button cbi-button-neutral' : 'cbi-button cbi-button-positive start';
|
||||
|
||||
return E('button', {
|
||||
'class': btnClass,
|
||||
'title': title,
|
||||
'click': handler,
|
||||
}, [icon]);
|
||||
})(),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral restart',
|
||||
'title': _('Restart this container'),
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_restart,
|
||||
{id: cont.Id},
|
||||
_('Restart'),
|
||||
{showOutput: true, showSuccess: false}
|
||||
)
|
||||
}, [dm2.Types['container'].sub['restart'].e]),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral stop',
|
||||
'title': _('Stop this container'),
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_stop,
|
||||
{id: cont.Id},
|
||||
dm2.Types['container'].sub['stop'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
),
|
||||
'disabled' : !(isRunning || isPaused) ? true : null
|
||||
}, [dm2.Types['container'].sub['stop'].e]),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative kill',
|
||||
'title': _('Kill this container'),
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_kill,
|
||||
{id: cont.Id},
|
||||
dm2.Types['container'].sub['kill'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
),
|
||||
'disabled' : !(isRunning || isPaused) ? true : null
|
||||
}, [dm2.Types['container'].sub['kill'].e]),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral export',
|
||||
'title': _('Export this container'),
|
||||
'click': () => {
|
||||
window.location.href = `${view.dockerman_url}/container/export/${cont.Id}`;
|
||||
}
|
||||
}, [dm2.Types['container'].sub['export'].e]),
|
||||
|
||||
E('div', {
|
||||
'style': 'width: 20px',
|
||||
// Some safety margin for mis-clicks
|
||||
}, [' ']),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative remove',
|
||||
'title': dm2.ActionTypes['remove'].i18n,
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_remove,
|
||||
{id: cont.Id, query: { force: false }},
|
||||
dm2.ActionTypes['remove'].i18n,
|
||||
{showOutput: true, showSuccess: false}
|
||||
)
|
||||
}, [dm2.ActionTypes['remove'].e]),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative important remove',
|
||||
'title': dm2.ActionTypes['force_remove'].i18n,
|
||||
'click': () => view.executeDockerAction(
|
||||
dm2.container_remove,
|
||||
{id: cont.Id, query: { force: true }},
|
||||
_('Force Remove'),
|
||||
{showOutput: true, showSuccess: false}
|
||||
)
|
||||
}, [dm2.ActionTypes['force_remove'].e]),
|
||||
];
|
||||
|
||||
return E('td', {
|
||||
'class': 'td',
|
||||
}, E('div', btns));
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
getContainersTable(containers, image_list, network_list) {
|
||||
const data = [];
|
||||
|
||||
for (const cont of Array.isArray(containers) ? containers : []) {
|
||||
|
||||
// build Container ID: xxxxxxx image: xxxx
|
||||
const names = Array.isArray(cont?.Names) ? cont.Names : [];
|
||||
const cleanedNames = names
|
||||
.map(n => (typeof n === 'string' ? n.substring(1) : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const statusColorName = this.wrapStatusText(cleanedNames, cont.State, 'font-weight:600;');
|
||||
const imageName = this.getImageFirstTag(image_list, cont.ImageID);
|
||||
const shortId = (cont?.Id || '').substring(0, 12);
|
||||
|
||||
const cid = E('div', {}, [
|
||||
E('a', { href: `container/${cont.Id}`, title: dm2.ActionTypes['edit'].i18n }, [
|
||||
statusColorName,
|
||||
E('div', { 'style': 'font-size: 0.9em; font-family: monospace; ' }, [`ID: ${shortId}`]),
|
||||
]),
|
||||
E('div', { 'style': 'font-size: 0.85em;' }, [`${dm2.Types['image'].i18n}: ${imageName}`]),
|
||||
])
|
||||
|
||||
// Just push plain data objects without UCI metadata
|
||||
data.push({
|
||||
...cont,
|
||||
cid: cid,
|
||||
_shortId: (cont?.Id || '').substring(0, 12),
|
||||
Networks: this.parseNetworkLinksForContainer(network_list, cont?.NetworkSettings?.Networks || {}, true),
|
||||
Created: this.buildTimeString(cont?.Created) || '',
|
||||
Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
|
||||
? cont.Ports.map(p => {
|
||||
// const ip = p.IP || '';
|
||||
const pub = p.PublicPort || '';
|
||||
const priv = p.PrivatePort || '';
|
||||
const type = p.Type || '';
|
||||
return `${pub ? pub + ':' : ''}${priv}/${type}`;
|
||||
// return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
|
||||
}).join('<br/>')
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
/* API v1.52
|
||||
|
||||
GET /events supports content-type negotiation and can produce either
|
||||
application/x-ndjson (Newline delimited JSON object stream) or
|
||||
application/json-seq (RFC7464).
|
||||
|
||||
application/x-ndjson:
|
||||
|
||||
{"some":"thing\n"}
|
||||
{"some2":"thing2\n"}
|
||||
...
|
||||
|
||||
application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
|
||||
|
||||
␞{"some":"thing\n"}␊
|
||||
␞{"some2":"thing2\n"}␊
|
||||
...
|
||||
|
||||
*/
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
this.js_api = false;
|
||||
|
||||
return Promise.all([
|
||||
dm2.docker_events({ query: { since: `0`, until: `${now}` } }),
|
||||
dm2.js_api_ready.then(([ok, ]) => this.js_api = ok),
|
||||
]);
|
||||
},
|
||||
|
||||
render([events, js_api_available]) {
|
||||
if (events?.code !== 200) {
|
||||
return E('div', {}, [ events?.body?.message ]);
|
||||
}
|
||||
|
||||
this.outputText = events?.body ? JSON.stringify(events?.body, null, 2) + '\n' : '';
|
||||
const event_list = events?.body || [];
|
||||
const view = this;
|
||||
|
||||
const mainContainer = E('div', { 'class': 'cbi-map' }, [
|
||||
E('h2', {}, [_('Docker - Events')])
|
||||
]);
|
||||
|
||||
// Filters
|
||||
const now = new Date();
|
||||
const nowIso = now.toISOString().slice(0, 16);
|
||||
const filtersSection = E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'cbi-section-node' }, [
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Type')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('select', {
|
||||
'id': 'event-type-filter',
|
||||
'class': 'cbi-input-select',
|
||||
'change': () => {
|
||||
view.updateSubtypeFilter(this.value);
|
||||
view.renderEventsTable(event_list);
|
||||
}
|
||||
}, [
|
||||
E('option', { 'value': '' }, _('All Types')),
|
||||
...Object.keys(dm2.Types).map(type =>
|
||||
E('option', { 'value': type }, `${dm2.Types[type].e} ${dm2.Types[type].i18n}`)
|
||||
)
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Subtype')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('select', {
|
||||
'id': 'event-subtype-filter',
|
||||
'class': 'cbi-input-select',
|
||||
'disabled': true,
|
||||
'change': () => {
|
||||
view.renderEventsTable(event_list);
|
||||
}
|
||||
}, [
|
||||
E('option', { 'value': '' }, _('Select Type First'))
|
||||
])
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('From')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'id': 'event-from-date',
|
||||
'type': 'datetime-local',
|
||||
'value': '1970-01-01T00:00',
|
||||
'step': 60,
|
||||
'style': 'width: 180px;',
|
||||
'change': () => { view.renderEventsTable(event_list); }
|
||||
}),
|
||||
E('button', {
|
||||
'type': 'button',
|
||||
'class': 'cbi-button',
|
||||
'style': 'margin-left: 8px;',
|
||||
'click': () => {
|
||||
const now = new Date();
|
||||
const iso = now.toISOString().slice(0,16);
|
||||
document.getElementById('event-from-date').value = iso;
|
||||
view.renderEventsTable(event_list);
|
||||
}
|
||||
}, _('Now')),
|
||||
E('button', {
|
||||
'type': 'button',
|
||||
'class': 'cbi-button',
|
||||
'style': 'margin-left: 8px;',
|
||||
'click': () => {
|
||||
const unixzero = new Date(0);
|
||||
const iso = unixzero.toISOString().slice(0,16);
|
||||
document.getElementById('event-from-date').value = iso;
|
||||
view.renderEventsTable(event_list);
|
||||
}
|
||||
}, _('0'))
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('To')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
E('input', {
|
||||
'id': 'event-to-date',
|
||||
'type': 'datetime-local',
|
||||
'value': nowIso,
|
||||
'step': 60,
|
||||
'style': 'width: 180px;',
|
||||
'change': () => { view.renderEventsTable(event_list); }
|
||||
}),
|
||||
E('button', {
|
||||
'type': 'button',
|
||||
'class': 'cbi-button',
|
||||
'style': 'margin-left: 8px;',
|
||||
'click': () => {
|
||||
const now = new Date();
|
||||
const iso = now.toISOString().slice(0,16);
|
||||
document.getElementById('event-to-date').value = iso;
|
||||
view.renderEventsTable(event_list);
|
||||
}
|
||||
}, _('Now'))
|
||||
])
|
||||
])
|
||||
])
|
||||
]);
|
||||
mainContainer.appendChild(filtersSection);
|
||||
|
||||
this.tableSection = E('div', { 'class': 'cbi-section', 'id': 'events-section' });
|
||||
mainContainer.appendChild(this.tableSection);
|
||||
|
||||
this.renderEventsTable(event_list);
|
||||
|
||||
mainContainer.appendChild(this.insertOutputFrame(E('div', {}), null));
|
||||
|
||||
return mainContainer;
|
||||
},
|
||||
|
||||
renderEventsTable(event_list) {
|
||||
const view = this;
|
||||
|
||||
// Get filter values
|
||||
const typeFilter = document.getElementById('event-type-filter')?.value || '';
|
||||
const subtypeFilter = document.getElementById('event-subtype-filter')?.value || '';
|
||||
|
||||
// Build filters object for docker_events API
|
||||
const filters = {};
|
||||
if (typeFilter) {
|
||||
filters.type = [typeFilter];
|
||||
}
|
||||
if (subtypeFilter) {
|
||||
filters.event = [subtypeFilter];
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
this.tableSection.innerHTML = '';
|
||||
|
||||
// Query docker events with filters and date range
|
||||
const fromInput = document.getElementById('event-from-date');
|
||||
const toInput = document.getElementById('event-to-date');
|
||||
let since = '0';
|
||||
let until = Math.floor(Date.now() / 1000).toString();
|
||||
if (fromInput && fromInput.value) {
|
||||
const fromDate = new Date(fromInput.value);
|
||||
if (!isNaN(fromDate.getTime())) {
|
||||
since = Math.floor(fromDate.getTime() / 1000).toString();
|
||||
since = since < 0 ? 0 : since;
|
||||
}
|
||||
}
|
||||
if (toInput && toInput.value) {
|
||||
const toDate = new Date(toInput.value);
|
||||
if (!isNaN(toDate.getTime())) {
|
||||
const now = Date.now() / 1000;
|
||||
until = Math.floor(toDate.getTime() / 1000).toString();
|
||||
until = !this.js_api ? until > now ? now : until : until;
|
||||
}
|
||||
}
|
||||
const queryParams = { since, until };
|
||||
if (Object.keys(filters).length > 0) {
|
||||
// docker pre v27: filters => docker *streams* events. v27, send events in body.
|
||||
// Some older dockerd endpoints don't like encoded filter params, even if we can't stream.
|
||||
queryParams.filters = JSON.stringify(filters);
|
||||
}
|
||||
|
||||
event_list = new Set();
|
||||
view.outputText = '';
|
||||
let eventsTable = null;
|
||||
// Batching for speed
|
||||
let batchBuffer = new Set();
|
||||
let batchTimer = null;
|
||||
const BATCH_SIZE = 256;
|
||||
const BATCH_INTERVAL = 500; // ms
|
||||
|
||||
function updateTable() {
|
||||
const ev_array = Array.from(event_list.keys());
|
||||
const rows = ev_array.map(event => {
|
||||
const type = event.Type;
|
||||
const typeInfo = dm2.Types[type];
|
||||
const typeDisplay = typeInfo ? `${typeInfo.e} ${typeInfo.i18n}` : type;
|
||||
const actionParts = event.Action?.split(':') || [];
|
||||
const action = actionParts.length > 0 ? actionParts[0] : '';
|
||||
const action_sub = actionParts.length > 1 ? actionParts[1] : null;
|
||||
const actionInfo = typeInfo?.sub?.[action];
|
||||
const actionDisplay = actionInfo ? `${actionInfo.e} ${actionInfo.i18n}${action_sub ? ':'+action_sub : ''}` : action;
|
||||
return [
|
||||
view.buildTimeString(event.time),
|
||||
typeDisplay,
|
||||
actionDisplay,
|
||||
view.objectToText(event.Actor),
|
||||
event.scope || ''
|
||||
];
|
||||
});
|
||||
|
||||
const output = JSON.stringify(ev_array, null, 2);
|
||||
view.outputText = output + '\n';
|
||||
view.insertOutput(view.outputText);
|
||||
|
||||
if (!eventsTable) {
|
||||
eventsTable = new L.ui.Table(
|
||||
[_('Time'), _('Type'), _('Action'), _('Actor'), _('Scope')],
|
||||
{ id: 'events-table', style: 'width: 100%; table-layout: auto;' },
|
||||
E('em', [_('No events found')])
|
||||
);
|
||||
view.tableSection.innerHTML = '';
|
||||
view.tableSection.appendChild(eventsTable.render());
|
||||
}
|
||||
eventsTable.update(rows);
|
||||
}
|
||||
|
||||
view.tableSection.innerHTML = '';
|
||||
|
||||
function flushBatch() {
|
||||
if (batchBuffer.size) {
|
||||
batchBuffer = new Set();
|
||||
}
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
batchTimer = null;
|
||||
}
|
||||
updateTable();
|
||||
}
|
||||
|
||||
function handleEventChunk(event) {
|
||||
event_list.add(event);
|
||||
batchBuffer.add(event);
|
||||
if (batchBuffer.size >= BATCH_SIZE) {
|
||||
flushBatch();
|
||||
} else if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBatch, BATCH_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/* Partial transfers work but XHR times out waiting, even with xhr.timeout = 0 */
|
||||
// view.handleXHRTransfer({
|
||||
// q_params:{ query: queryParams },
|
||||
// commandCPath: '/docker/events',
|
||||
// commandDPath: '/events',
|
||||
// commandTitle: dm2.ActionTypes['prune'].i18n,
|
||||
// showProgress: false,
|
||||
// onUpdate: (msg) => {
|
||||
// try {
|
||||
// if(msg.error)
|
||||
// ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
// event_list.add(msg);
|
||||
// updateTable();
|
||||
|
||||
// const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
// view.insertOutput(output);
|
||||
// } catch {
|
||||
|
||||
// }
|
||||
// },
|
||||
// noFileUpload: true,
|
||||
// });
|
||||
|
||||
view.executeDockerAction(
|
||||
dm2.docker_events,
|
||||
{ query: queryParams, onChunk: handleEventChunk },
|
||||
_('Load Events'),
|
||||
{
|
||||
showOutput: false,
|
||||
showSuccess: false,
|
||||
onSuccess: (response) => {
|
||||
if (response.body)
|
||||
event_list = Array.isArray(response.body) ? new Set(response.body) : new Set([response.body]);
|
||||
updateTable();
|
||||
flushBatch();
|
||||
},
|
||||
onError: (err) => {
|
||||
view.tableSection.innerHTML = '';
|
||||
view.tableSection.appendChild(E('em', { 'style': 'color: red;' }, _('Failed to load events: %s').format(err?.message || err)));
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updateSubtypeFilter(selectedType) {
|
||||
const subtypeSelect = document.getElementById('event-subtype-filter');
|
||||
if (!subtypeSelect) return;
|
||||
|
||||
// Clear existing options
|
||||
subtypeSelect.innerHTML = '';
|
||||
|
||||
if (!selectedType || !dm2.Types[selectedType] || !dm2.Types[selectedType].sub) {
|
||||
subtypeSelect.disabled = true;
|
||||
subtypeSelect.appendChild(E('option', { 'value': '' }, _('Select Type First')));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable and populate with subtypes
|
||||
subtypeSelect.disabled = false;
|
||||
subtypeSelect.appendChild(E('option', { 'value': '' }, _('All Subtypes')));
|
||||
|
||||
const subtypes = dm2.Types[selectedType].sub;
|
||||
for (const action in subtypes) {
|
||||
subtypeSelect.appendChild(
|
||||
E('option', { 'value': action }, `${subtypes[action].e} ${subtypes[action].i18n}`)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
});
|
||||
@@ -0,0 +1,724 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require poll';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
dm2.image_list(),
|
||||
dm2.container_list({query: {all: true}}),
|
||||
])
|
||||
},
|
||||
|
||||
render([images, containers]) {
|
||||
if (images?.code !== 200) {
|
||||
return E('div', {}, [ images.body.message ]);
|
||||
}
|
||||
|
||||
let image_list = this.getImagesTable(images.body);
|
||||
let container_list = containers.body;
|
||||
const view = this; // Capture the view context
|
||||
view.selectedImages = {};
|
||||
|
||||
let s, o;
|
||||
const m = new form.JSONMap({image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {}},
|
||||
_('Docker - Images'),
|
||||
_('On this page all images are displayed that are available on the system and with which a container can be created.'));
|
||||
m.submit = false;
|
||||
m.reset = false;
|
||||
|
||||
let pollPending = null;
|
||||
let imgSec = null;
|
||||
const calculateSizeTotal = () => {
|
||||
return Array.isArray(image_list) ? image_list.map(c => c?.Size).reduce((acc, e) => acc + e, 0) : 0;
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
if (pollPending) return pollPending;
|
||||
pollPending = view.load().then(([images2, containers2]) => {
|
||||
image_list = view.getImagesTable(images2.body);
|
||||
container_list = containers2.body;
|
||||
m.data = new m.data.constructor({ image: image_list, pull: {}, push: {}, build: {}, import: {}, prune: {} });
|
||||
|
||||
const size_total = calculateSizeTotal();
|
||||
if (imgSec) {
|
||||
imgSec.footer = [
|
||||
'',
|
||||
`${_('Total')} ${image_list.length}`,
|
||||
'',
|
||||
`${'%1024mB'.format(size_total)}`,
|
||||
];
|
||||
}
|
||||
|
||||
return m.render();
|
||||
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
|
||||
return pollPending;
|
||||
};
|
||||
|
||||
// Pull image
|
||||
|
||||
s = m.section(form.TableSection, 'pull', dm2.Types['image'].sub['pull'].i18n,
|
||||
_('By entering a valid image name with the corresponding version, the docker image can be downloaded from the configured registry.'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
|
||||
const splitImageTag = (value) => {
|
||||
const input = String(value || '').trim();
|
||||
if (!input || input.includes(' ')) return { name: '', tag: 'latest' };
|
||||
|
||||
const lastSlash = input.lastIndexOf('/');
|
||||
const lastColon = input.lastIndexOf(':');
|
||||
if (lastColon > lastSlash) {
|
||||
return {
|
||||
name: input.slice(0, lastColon) || input,
|
||||
tag: input.slice(lastColon + 1) || 'latest'
|
||||
};
|
||||
}
|
||||
|
||||
return { name: input, tag: 'latest' };
|
||||
};
|
||||
|
||||
let tagOpt = s.option(form.Value, '_image_tag_name');
|
||||
tagOpt.placeholder = "[registry.io[:443]/]foobar/product:latest";
|
||||
|
||||
o = s.option(form.Button, '_pull');
|
||||
o.inputtitle = `${dm2.Types['image'].sub['pull'].i18n} ${dm2.Types['image'].sub['pull'].e}`; // _('Pull') + ' ☁️⬇️'
|
||||
o.inputstyle = 'add';
|
||||
o.onclick = L.bind(function(ev, btn) {
|
||||
const raw = tagOpt.formvalue('pull') || '';
|
||||
const input = String(raw).trim();
|
||||
if (!input) {
|
||||
ui.addTimeLimitedNotification(dm2.Types['image'].sub['pull'].i18n, _('Please enter an image tag'), 4000, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { name, tag: ver } = splitImageTag(input);
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { query: { fromImage: name, tag: ver } },
|
||||
commandCPath: `/images/create`,
|
||||
commandDPath: `/images/create`,
|
||||
commandTitle: dm2.Types['image'].sub['pull'].i18n,
|
||||
successMessage: _('Image create completed'),
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.image_create,
|
||||
// { query: { fromImage: name, tag: ver } },
|
||||
// dm2.Types['image'].sub['pull'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('Image create completed')
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
// Push image
|
||||
|
||||
s = m.section(form.TableSection, 'push', dm2.Types['image'].sub['push'].i18n,
|
||||
_('Push an image to a registry. Select an image tag from all available tags on the system.'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
|
||||
// Build a list of all available tags across all images
|
||||
const allImageTags = [];
|
||||
for (const image of image_list) {
|
||||
const tags = Array.isArray(image.RepoTags) ? image.RepoTags : [];
|
||||
for (const tag of tags) {
|
||||
if (tag && tag !== '<none>:<none>') {
|
||||
allImageTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pushTagOpt = s.option(form.Value, '_image_tag_push');
|
||||
pushTagOpt.placeholder = _('Select image tag');
|
||||
if (allImageTags.length === 0) {
|
||||
pushTagOpt.value('', _('No image tags available'));
|
||||
} else {
|
||||
// Add all unique tags to the dropdown
|
||||
const uniqueTags = [...new Set(allImageTags)].sort();
|
||||
for (const tag of uniqueTags) {
|
||||
pushTagOpt.value(tag, tag);
|
||||
}
|
||||
}
|
||||
|
||||
o = s.option(form.Button, '_push');
|
||||
o.inputtitle = `${dm2.Types['image'].sub['push'].i18n} ${dm2.Types['image'].sub['push'].e}`; // _('Push') + ' ☁️⬆️'
|
||||
o.inputstyle = 'add';
|
||||
o.onclick = L.bind(function(ev, btn) {
|
||||
const selected = pushTagOpt.formvalue('push') || '';
|
||||
if (!selected) {
|
||||
ui.addTimeLimitedNotification(dm2.Types['image'].sub['push'].i18n, _('Please select an image tag to push'), 4000, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { name, tag: ver } = splitImageTag(selected);
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
// Pass name in q_params to trigger building X-Registry-Auth header
|
||||
q_params: { name: name, query: { tag: ver } },
|
||||
commandCPath: `/images/push/${name}`,
|
||||
commandDPath: `/images/${name}/push`,
|
||||
commandTitle: dm2.Types['image'].sub['push'].i18n,
|
||||
successMessage: _('Image push completed'),
|
||||
onSuccess: () => refresh(),
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.image_push,
|
||||
// { name: name, query: { tag: ver} },
|
||||
// dm2.Types['image'].sub['push'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('Image push completed')
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
|
||||
s = m.section(form.TableSection, 'build', dm2.ActionTypes['build'].i18n,
|
||||
_('Build an image.') + ' ' + _('git repositories require git installed on the docker host.'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
|
||||
let buildOpt = s.option(form.Value, '_image_build_uri');
|
||||
buildOpt.placeholder = "https://host/foo/bar.git | https://host/foobar.tar";
|
||||
|
||||
let buildTagOpt = s.option(form.Value, '_image_build_tag');
|
||||
buildTagOpt.placeholder = 'repository:tag';
|
||||
|
||||
o = s.option(form.Button, '_build');
|
||||
o.inputtitle = `${dm2.ActionTypes['build'].i18n} ${dm2.ActionTypes['build'].e}`; // _('Build') + ' 🏗️'
|
||||
o.inputstyle = 'add';
|
||||
o.onclick = L.bind(function(ev, btn) {
|
||||
const uri = buildOpt.formvalue('build') || '';
|
||||
const t = buildTagOpt.formvalue('build') || '';
|
||||
|
||||
const q_params = { q: encodeURIComponent('false'), t: t };
|
||||
if (uri) q_params.remote = encodeURIComponent(uri);
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { query: q_params },
|
||||
commandCPath: '/images/build',
|
||||
commandDPath: '/build',
|
||||
commandTitle: dm2.ActionTypes['build'].i18n,
|
||||
successMessage: _('Image loaded successfully'),
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['build'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
noFileUpload: !!uri,
|
||||
}]);
|
||||
}, this);
|
||||
|
||||
o = s.option(form.Button, '_delete_cache', null);
|
||||
o.inputtitle = `${dm2.ActionTypes['clean'].i18n} ${dm2.ActionTypes['clean'].e}`;
|
||||
o.inputstyle = 'negative';
|
||||
o.onclick = L.bind(function(ev, btn) {
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { query: { all: 'true' } },
|
||||
commandCPath: '/images/build/prune',
|
||||
commandDPath: '/build/prune',
|
||||
commandTitle: dm2.Types['builder'].sub['prune'].i18n,
|
||||
successMessage: _('Cleaned build cache'),
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['clean'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
}, this);
|
||||
|
||||
// Import image
|
||||
|
||||
s = m.section(form.TableSection, 'import', dm2.Types['image'].sub['import'].i18n,
|
||||
_('Download a valid remote image tar.'));
|
||||
s.addremove = false;
|
||||
s.anonymous = true;
|
||||
|
||||
let imgsrc = s.option(form.Value, '_image_source');
|
||||
imgsrc.placeholder = 'https://host/image.tar';
|
||||
|
||||
let tagimpOpt = s.option(form.Value, '_import_image_tag_name');
|
||||
tagimpOpt.placeholder = 'repository:tag';
|
||||
|
||||
let importBtn = s.option(form.Button, '_import');
|
||||
importBtn.inputtitle = `${dm2.Types['image'].sub['import'].i18n} ${dm2.Types['image'].sub['import'].e}` //_('Import') + ' ➡️';
|
||||
importBtn.inputstyle = 'add';
|
||||
importBtn.onclick = L.bind(function(ev, btn) {
|
||||
const rawtag = tagimpOpt.formvalue('import') || '';
|
||||
const input = String(rawtag).trim();
|
||||
if (!input) {
|
||||
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image repo tag'), 4000, 'warning');
|
||||
return false;
|
||||
}
|
||||
const rawremote = imgsrc.formvalue('import') || '';
|
||||
let remote = String(rawremote).trim();
|
||||
if (!remote) {
|
||||
ui.addTimeLimitedNotification(dm2.Types['image'].sub['import'].i18n, _('Please enter an image source'), 4000, 'warning');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { name, tag: ver } = splitImageTag(input);
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { query: { fromSrc: remote, repo: ver } },
|
||||
commandCPath: '/images/create',
|
||||
commandDPath: '/images/create',
|
||||
commandTitle: dm2.Types['image'].sub['create'].i18n,
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.Types['image'].sub['create'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.image_create,
|
||||
// { query: { fromSrc: remote, repo: ver } },
|
||||
// dm2.Types['image'].sub['import'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('Image create started/completed')
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
|
||||
s = m.section(form.TableSection, 'prune', _('Images overview'), );
|
||||
s.addremove = false;
|
||||
s.anonymous = true;
|
||||
|
||||
const prune = s.option(form.Button, '_prune', null);
|
||||
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
|
||||
prune.inputstyle = 'negative';
|
||||
prune.onclick = L.bind(function(ev, btn) {
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { },
|
||||
commandCPath: '/images/prune',
|
||||
commandDPath: '/images/prune',
|
||||
commandTitle: dm2.ActionTypes['prune'].i18n,
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.image_prune,
|
||||
// { query: { filters: '' } },
|
||||
// dm2.ActionTypes['prune'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('started/completed'),
|
||||
// onSuccess: () => refresh(),
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
o = s.option(form.Button, '_export', null);
|
||||
o.inputtitle = `${dm2.ActionTypes['save'].i18n} ${dm2.ActionTypes['save'].e}`;
|
||||
o.inputstyle = 'cbi-button-positive';
|
||||
o.onclick = L.bind(function(ev, btn) {
|
||||
ev.preventDefault();
|
||||
|
||||
const selected = Object.keys(view.selectedImages).filter(k => view.selectedImages[k]);
|
||||
if (!selected.length) {
|
||||
ui.addTimeLimitedNotification(_('Export'), _('No images selected'), 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tags or IDs for selected images
|
||||
const names = selected.map(sid => {
|
||||
const image = s.map.data.data[sid];
|
||||
const tag = image?.RepoTags?.[0];
|
||||
return tag || image?.Id?.substr(12);
|
||||
});
|
||||
|
||||
// http.uc does not yet handle parameter arrays, so /images/get needs access to the URL params
|
||||
window.location.href = `${view.dockerman_url}/images/get?${names.map(e => `names=${e}`).join('&')}`;
|
||||
|
||||
}, this);
|
||||
|
||||
const size_total = calculateSizeTotal();
|
||||
|
||||
imgSec = m.section(form.TableSection, 'image');
|
||||
imgSec.anonymous = true;
|
||||
imgSec.nodescriptions = true;
|
||||
imgSec.addremove = true;
|
||||
imgSec.sortable = true;
|
||||
imgSec.filterrow = true;
|
||||
imgSec.addbtntitle = `${dm2.ActionTypes['upload'].i18n} ${dm2.ActionTypes['upload'].e}`;
|
||||
imgSec.footer = [
|
||||
'',
|
||||
`${_('Total')} ${image_list.length}`,
|
||||
'',
|
||||
`${'%1024mB'.format(size_total)}`,
|
||||
];
|
||||
|
||||
imgSec.handleAdd = function(sid, ev) {
|
||||
return view.handleFileUpload();
|
||||
};
|
||||
|
||||
imgSec.handleGet = function(image, ev) {
|
||||
const tag = image.RepoTags?.[0];
|
||||
const name = tag || image.Id.substr(12);
|
||||
|
||||
// Direct HTTP download - avoid RPC
|
||||
window.location.href = `${view.dockerman_url}/images/get/${name}`;
|
||||
return true;
|
||||
};
|
||||
|
||||
imgSec.handleRemove = function(sid, image, force=false, ev) {
|
||||
return view.executeDockerAction(
|
||||
dm2.image_remove,
|
||||
{ id: image.Id, query: { force: force } },
|
||||
dm2.ActionTypes['remove'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
onSuccess: () => {
|
||||
delete this.map.data.data[sid];
|
||||
return this.super('handleRemove', [ev]);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
imgSec.handleInspect = function(image, ev) {
|
||||
return view.executeDockerAction(
|
||||
dm2.image_inspect,
|
||||
{ id: image.Id },
|
||||
dm2.ActionTypes['inspect'].i18n,
|
||||
{ showOutput: true, showSuccess: false }
|
||||
);
|
||||
};
|
||||
|
||||
imgSec.handleHistory = function(image, ev) {
|
||||
return view.executeDockerAction(
|
||||
dm2.image_history,
|
||||
{ id: image.Id },
|
||||
dm2.ActionTypes['history'].i18n,
|
||||
{ showOutput: true, showSuccess: false }
|
||||
);
|
||||
};
|
||||
|
||||
imgSec.renderRowActions = function (sid) {
|
||||
const image = this.map.data.data[sid];
|
||||
const btns = [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'title': dm2.ActionTypes['inspect'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleInspect, image),
|
||||
}, [dm2.ActionTypes['inspect'].e]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'title': dm2.ActionTypes['history'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleHistory, image),
|
||||
}, [dm2.ActionTypes['history'].e]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive save',
|
||||
'title': dm2.ActionTypes['save'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleGet, image),
|
||||
}, [dm2.ActionTypes['save'].e]),
|
||||
E('div', {
|
||||
'style': 'width: 20px',
|
||||
// Some safety margin for mis-clicks
|
||||
}, [' ']),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative remove',
|
||||
'title': dm2.ActionTypes['remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, false),
|
||||
'disabled': image?._disable_delete,
|
||||
}, [dm2.ActionTypes['remove'].e]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative important remove',
|
||||
'title': dm2.ActionTypes['force_remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, sid, image, true),
|
||||
'disabled': image?._disable_delete,
|
||||
}, [dm2.ActionTypes['force_remove'].e]),
|
||||
];
|
||||
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
|
||||
};
|
||||
|
||||
o = imgSec.option(form.Flag, '_selected');
|
||||
o.onchange = function(ev, sid, value) {
|
||||
if (value == 1) {
|
||||
view.selectedImages[sid] = value;
|
||||
}
|
||||
else {
|
||||
delete view.selectedImages[sid];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
o = imgSec.option(form.DummyValue, 'RepoTags', dm2.Types['image'].sub['tag'].e);
|
||||
o.cfgvalue = function(sid) {
|
||||
const image = this.map.data.data[sid];
|
||||
const tags = Array.isArray(image?.RepoTags) ? image.RepoTags : [];
|
||||
|
||||
if (tags.length === 0 || (tags.length === 1 && tags[0] === '<none>:<none>'))
|
||||
return '<none>';
|
||||
|
||||
const tagLinks = tags.map(tag => {
|
||||
if (tag === '<none>:<none>')
|
||||
return E('span', {}, tag);
|
||||
|
||||
/* last tag - don't link it - last tag removal == delete */
|
||||
if (tags.length === 1)
|
||||
return tag;
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'title': _('Click to remove this tag'),
|
||||
'click': ui.createHandlerFn(view, (tag, imageId, ev) => {
|
||||
|
||||
ev.preventDefault();
|
||||
ui.showModal(_('Remove tag'), [
|
||||
E('p', {}, _('Do you want to remove the tag "%s"?').format(tag)),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, '↩'),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
ui.hideModal();
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.image_remove,
|
||||
{ id: tag, query: { noprune: 'true' } },
|
||||
dm2.Types['image'].sub['untag'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
successMessage: _('Tag removed successfully'),
|
||||
successDuration: 4000,
|
||||
onSuccess: () => refresh(),
|
||||
}
|
||||
);
|
||||
})
|
||||
}, dm2.Types['image'].sub['untag'].e)
|
||||
])
|
||||
]);
|
||||
}, tag, image.Id)
|
||||
}, tag);
|
||||
});
|
||||
|
||||
// Join with commas and spaces
|
||||
const content = [];
|
||||
for (const [i, tag] of tagLinks.entries()) {
|
||||
if (i > 0) content.push(', ');
|
||||
content.push(tag);
|
||||
}
|
||||
|
||||
return E('span', {}, content);
|
||||
};
|
||||
|
||||
o = imgSec.option(form.DummyValue, 'Containers', _('Containers'));
|
||||
o.cfgvalue = function(sid) {
|
||||
const imageId = this.map.data.data[sid].Id;
|
||||
// Collect all matching container name links for this image
|
||||
const anchors = container_list.reduce((acc, container) => {
|
||||
if (container?.ImageID !== imageId) return acc;
|
||||
for (const name of container?.Names || [])
|
||||
acc.push(E('a', { href: `container/${container.Id}` }, [ name.substring(1) ]));
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Interleave separators
|
||||
if (!anchors.length) return E('div', {});
|
||||
const content = [];
|
||||
for (let i = 0; i < anchors.length; i++) {
|
||||
if (i) content.push(' | ');
|
||||
content.push(anchors[i]);
|
||||
}
|
||||
|
||||
return E('div', {}, content);
|
||||
};
|
||||
|
||||
o = imgSec.option(form.DummyValue, 'Size', _('Size'));
|
||||
o.cfgvalue = function(sid) {
|
||||
const s = this.map.data.data[sid].Size;
|
||||
return '%1024mB'.format(s);
|
||||
};
|
||||
imgSec.option(form.DummyValue, 'Created', _('Created'));
|
||||
o = imgSec.option(form.DummyValue, '_id', _('ID'));
|
||||
|
||||
/* Remember: we load a JSONMap - so uci config is non-existent for these
|
||||
elements, so we must pull from this.map.data, otherwise o.load returns nothing */
|
||||
o.cfgvalue = function(sid) {
|
||||
const image = this.map.data.data[sid];
|
||||
const shortId = image?._id || '';
|
||||
const fullId = image?.Id || '';
|
||||
|
||||
return E('a', {
|
||||
'href': '#',
|
||||
'style': 'font-family: monospace',
|
||||
'title': _('Click to add a new tag to this image'),
|
||||
'click': ui.createHandlerFn(view, function(imageId, ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
let repoInput, tagInput;
|
||||
ui.showModal(_('New tag'), [
|
||||
E('p', {}, _('Enter a new tag for image %s:').format(imageId.slice(7, 19))),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Repository')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
repoInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': '[registry.io[:443]/]myrepo/myimage'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Tag')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
tagInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'latest',
|
||||
'value': 'latest'
|
||||
})
|
||||
])
|
||||
]),
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, ['↩']),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
const repo = repoInput.value.trim();
|
||||
const tag = tagInput.value.trim() || 'latest';
|
||||
|
||||
if (!repo) {
|
||||
ui.addTimeLimitedNotification(null, [_('Repository cannot be empty')], 3000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
ui.hideModal();
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.image_tag,
|
||||
{ id: imageId, query: { repo: repo, tag: tag } },
|
||||
dm2.Types['image'].sub['tag'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
successMessage: _('Tag added successfully'),
|
||||
successDuration: 4000,
|
||||
onSuccess: () => refresh(),
|
||||
}
|
||||
);
|
||||
})
|
||||
}, [dm2.Types['image'].sub['tag'].e])
|
||||
])
|
||||
]);
|
||||
}, fullId)
|
||||
}, shortId);
|
||||
};
|
||||
|
||||
this.insertOutputFrame(s, m);
|
||||
|
||||
poll.add(L.bind(() => { refresh(); }, this), 10);
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleFileUpload() {
|
||||
// const uploadUrl = `?quiet=${encodeURIComponent('false')}`;
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { query: { quiet: 'false' } },
|
||||
commandCPath: `/images/load`,
|
||||
commandDPath: `/images/load`,
|
||||
commandTitle: _('Uploading…'),
|
||||
commandMessage: _('Uploading image…'),
|
||||
successMessage: _('Image loaded successfully'),
|
||||
defaultPath: '/tmp'
|
||||
}]);
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
getImagesTable(images) {
|
||||
const data = [];
|
||||
|
||||
for (const image of images) {
|
||||
// Just push plain data objects without UCI metadata
|
||||
data.push({
|
||||
...image,
|
||||
_disable_delete: null,
|
||||
_id: (image.Id || '').substring(7, 20),
|
||||
Created: this.buildTimeString(image.Created) || '',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
const requestPath = L.env.requestpath;
|
||||
const netId = requestPath[requestPath.length-1] || '';
|
||||
this.networkId = netId;
|
||||
|
||||
return Promise.all([
|
||||
dm2.network_inspect({ id: netId }),
|
||||
dm2.container_list({query: {all: true}}),
|
||||
]);
|
||||
},
|
||||
|
||||
render([network, containers]) {
|
||||
if (network?.code !== 200) {
|
||||
window.location.href = `${this.dockerman_url}/networks`;
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this;
|
||||
const this_network = network.body || {};
|
||||
const container_list = Array.isArray(containers.body) ? containers.body : [];
|
||||
|
||||
const m = new form.JSONMap({
|
||||
network: this_network,
|
||||
Driver: this_network?.IPAM?.Driver,
|
||||
Config: this_network?.IPAM?.Config,
|
||||
Containers: Object.entries(this_network?.Containers || {}).map(([id, info]) => ({ id, ...info })),
|
||||
_inspect: {},
|
||||
},
|
||||
_('Docker - Networks'),
|
||||
_('This page displays all docker networks that have been created on the connected docker host.'));
|
||||
m.submit = false;
|
||||
m.reset = false;
|
||||
|
||||
let s = m.section(form.NamedSection, 'network', _('Networks overview'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
s.nodescriptions = true;
|
||||
|
||||
let o, ss;
|
||||
|
||||
// INFO TAB
|
||||
s.tab('info', _('Info'));
|
||||
|
||||
o = s.taboption('info', form.DummyValue, 'Name', _('Network Name'));
|
||||
o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
|
||||
o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
|
||||
o = s.taboption('info', form.DummyValue, 'Scope', _('Scope'));
|
||||
o = s.taboption('info', form.DummyValue, 'Driver', _('Driver'));
|
||||
o = s.taboption('info', form.Flag, 'EnableIPv6', _('IPv6'));
|
||||
o.readonly = true;
|
||||
|
||||
o = s.taboption('info', form.Flag, 'Internal', _('Internal'));
|
||||
o.readonly = true;
|
||||
|
||||
o = s.taboption('info', form.Flag, 'Attachable', _('Attachable'));
|
||||
o.readonly = true;
|
||||
|
||||
o = s.taboption('info', form.Flag, 'Ingress', _('Ingress'));
|
||||
o.readonly = true;
|
||||
|
||||
o = s.taboption('info', form.DummyValue, 'ConfigFrom', _('ConfigFrom'));
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
|
||||
o = s.taboption('info', form.Flag, 'ConfigOnly', _('Config Only'));
|
||||
o.readonly = true;
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
o = s.taboption('info', form.DummyValue, 'Containers', _('Containers'));
|
||||
o.load = function(sid) {
|
||||
return view.parseContainerLinksForNetwork(this_network, container_list);
|
||||
};
|
||||
|
||||
o = s.taboption('info', form.DummyValue, 'Options', _('Options'));
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
o = s.taboption('info', form.DummyValue, 'Labels', _('Labels'));
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
// CONFIGS TAB
|
||||
s.tab('detail', _('Detail'));
|
||||
|
||||
o = s.taboption('detail', form.DummyValue, 'Driver', _('IPAM Driver'));
|
||||
|
||||
o = s.taboption('detail', form.SectionValue, '_conf_', form.TableSection, 'Config', _('Network Configurations'));
|
||||
ss = o.subsection;
|
||||
ss.anonymous = true;
|
||||
|
||||
ss.option(form.DummyValue, 'Subnet', _('Subnet'));
|
||||
ss.option(form.DummyValue, 'Gateway', _('Gateway'));
|
||||
|
||||
o = s.taboption('detail', form.SectionValue, '_cont_', form.TableSection, 'Containers', _('Containers'));
|
||||
ss = o.subsection;
|
||||
ss.anonymous = true;
|
||||
|
||||
o = ss.option(form.DummyValue, 'Name', _('Name'));
|
||||
o.cfgvalue = function(sid) {
|
||||
const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option);
|
||||
const containerId = container_list.find(c => c.Names.find(e => e.substring(1) === val)).Id;
|
||||
return E('a', {
|
||||
href: `${view.dockerman_url}/container/${containerId}`,
|
||||
title: containerId,
|
||||
style: 'white-space: nowrap;'
|
||||
}, [val]);
|
||||
};
|
||||
|
||||
ss.option(form.DummyValue, 'MacAddress', _('Mac Address'));
|
||||
ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
|
||||
|
||||
// Show IPv6 column when at least one entry contains a non-empty IPv6Address
|
||||
const _networkContainers = Object.values(this_network?.Containers || {});
|
||||
const _hasIPv6 = _networkContainers.some(c => c?.IPv6Address && String(c.IPv6Address).trim() !== '');
|
||||
if (_hasIPv6) {
|
||||
ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
|
||||
}
|
||||
|
||||
// INSPECT TAB
|
||||
|
||||
s.tab('inspect', _('Inspect'));
|
||||
|
||||
o = s.taboption('inspect', form.SectionValue, '__ins__', form.NamedSection, '_inspect', null);
|
||||
ss = o.subsection;
|
||||
ss.anonymous = true;
|
||||
ss.nodescriptions = true;
|
||||
|
||||
o = ss.option(form.Button, '_inspect_button', null);
|
||||
o.inputtitle = `${dm2.ActionTypes['inspect'].i18n} ${dm2.ActionTypes['inspect'].e}`;
|
||||
o.inputstyle = 'neutral';
|
||||
o.onclick = L.bind(function(section_id, ev) {
|
||||
return dm2.network_inspect({ id: this_network.Id }).then((response) => {
|
||||
const inspectField = document.getElementById('inspect-output-text');
|
||||
if (inspectField && response?.body) {
|
||||
inspectField.textContent = JSON.stringify(response.body, null, 2);
|
||||
}
|
||||
});
|
||||
}, this);
|
||||
|
||||
o = s.taboption('inspect', form.SectionValue, '__insoutput__', form.NamedSection, null, null);
|
||||
o.render = L.bind(() => {
|
||||
return this.insertOutputFrame(null, null);
|
||||
}, this);
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require tools.widgets as widgets';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
|
||||
]);
|
||||
},
|
||||
|
||||
render() {
|
||||
|
||||
// stuff JSONMap with {network: {}} to prime it with a new empty entry
|
||||
const m = new form.JSONMap({network: {}}, _('Docker - New Network'));
|
||||
m.submit = true;
|
||||
m.reset = true;
|
||||
|
||||
let s = m.section(form.NamedSection, 'network', _('Create new docker network'));
|
||||
s.anonymous = true;
|
||||
s.nodescriptions = true;
|
||||
s.addremove = false;
|
||||
|
||||
let o;
|
||||
|
||||
o = s.option(form.Value, 'name', _('Network Name'),
|
||||
_('Name of the network that can be selected during container creation'));
|
||||
o.rmempty = true;
|
||||
|
||||
o = s.option(form.ListValue, 'driver', _('Driver'));
|
||||
o.rmempty = true;
|
||||
o.value('bridge', _('Bridge device'));
|
||||
o.value('macvlan', _('MAC VLAN'));
|
||||
o.value('ipvlan', _('IP VLAN'));
|
||||
o.value('overlay', _('Overlay network'));
|
||||
|
||||
o = s.option(widgets.DeviceSelect, 'parent', _('Base device'));
|
||||
o.rmempty = true;
|
||||
o.create = false
|
||||
o.noaliases = true;
|
||||
o.nocreate = true;
|
||||
o.depends('driver', 'macvlan');
|
||||
|
||||
o = s.option(form.ListValue, 'macvlan_mode', _('Mode'));
|
||||
o.rmempty = true;
|
||||
o.depends('driver', 'macvlan');
|
||||
o.default = 'bridge';
|
||||
o.value('bridge', _('Bridge (Support direct communication between MAC VLANs)'));
|
||||
o.value('private', _('Private (Prevent communication between MAC VLANs)'));
|
||||
o.value('vepa', _('VEPA (Virtual Ethernet Port Aggregator)'));
|
||||
o.value('passthru', _('Pass-through (Mirror physical device to single MAC VLAN)'));
|
||||
|
||||
o = s.option(form.ListValue, 'ipvlan_mode', _('Ipvlan Mode'));
|
||||
o.rmempty = true;
|
||||
o.depends('driver', 'ipvlan');
|
||||
o.default='l3';
|
||||
o.value('l2', _('L2 bridge'));
|
||||
o.value('l3', _('L3 bridge'));
|
||||
|
||||
o = s.option(form.Flag, 'ingress',
|
||||
_('Ingress'),
|
||||
_('Ingress network is the network which provides the routing-mesh in swarm mode'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = 0;
|
||||
o.depends('driver', 'overlay');
|
||||
|
||||
o = s.option(form.DynamicList, 'options', _('Options'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='com.docker.network.driver.mtu=1500';
|
||||
|
||||
o = s.option(form.DynamicList, 'labels', _('Labels'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='foo=bar';
|
||||
|
||||
o = s.option(form.Flag, 'internal', _('Internal'), _('Restrict external access to the network'));
|
||||
o.rmempty = true;
|
||||
o.depends('driver', 'overlay');
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = o.disabled;
|
||||
|
||||
// if nixio.fs.access('/etc/config/network') and nixio.fs.access('/etc/config/firewall')then
|
||||
// o = s.option(form.Flag, 'op_macvlan', _('Create macvlan interface'), _('Auto create macvlan interface in Openwrt'))
|
||||
// o.depends('driver', 'macvlan')
|
||||
// o.disabled = 0
|
||||
// o.enabled = 1
|
||||
// o.default = 1
|
||||
// end
|
||||
|
||||
o = s.option(form.Value, 'subnet', _('Subnet'));
|
||||
o.rmempty = true;
|
||||
o.placeholder = '10.1.0.0/16';
|
||||
o.datatype = 'ip4addr';
|
||||
|
||||
o = s.option(form.Value, 'gateway', _('Gateway'));
|
||||
o.rmempty = true;
|
||||
o.placeholder = '10.1.1.1';
|
||||
o.datatype = 'ip4addr';
|
||||
|
||||
o = s.option(form.Value, 'ip_range', _('IP range'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='10.1.1.0/24';
|
||||
o.datatype = 'ip4addr';
|
||||
|
||||
o = s.option(form.DynamicList, 'aux_address', _('Exclude IPs'));
|
||||
o.rmempty = true;
|
||||
o.placeholder = 'my-route=10.1.1.1';
|
||||
|
||||
o = s.option(form.Flag, 'ipv6', _('Enable IPv6'));
|
||||
o.rmempty = true;
|
||||
o.disabled = 0;
|
||||
o.enabled = 1;
|
||||
o.default = o.disabled;
|
||||
|
||||
o = s.option(form.Value, 'subnet6', _('IPv6 Subnet'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='fe80::/10'
|
||||
o.datatype = 'ip6addr';
|
||||
o.depends('ipv6', 1);
|
||||
|
||||
o = s.option(form.Value, 'gateway6', _('IPv6 Gateway'));
|
||||
o.rmempty = true;
|
||||
o.placeholder='fe80::1';
|
||||
o.datatype = 'ip6addr';
|
||||
o.depends('ipv6', 1);
|
||||
|
||||
this.map = m;
|
||||
|
||||
return m.render();
|
||||
|
||||
},
|
||||
|
||||
handleSave(ev) {
|
||||
ev?.preventDefault();
|
||||
|
||||
const view = this;
|
||||
|
||||
const map = this.map;
|
||||
if (!map)
|
||||
return Promise.reject(new Error(_('Form is not ready yet.')));
|
||||
|
||||
const listToKv = view.listToKv;
|
||||
|
||||
const toBool = (val) => (val === 1 || val === '1' || val === true);
|
||||
|
||||
return map.parse()
|
||||
.then(() => {
|
||||
const get = (opt) => map.data.get('json', 'network', opt);
|
||||
const name = get('name');
|
||||
const driver = get('driver');
|
||||
const internal = toBool(get('internal'));
|
||||
const ingress = toBool(get('ingress'));
|
||||
const ipv6 = toBool(get('ipv6'));
|
||||
const subnet = get('subnet');
|
||||
const gateway = get('gateway');
|
||||
const ipRange = get('ip_range');
|
||||
const auxAddress = listToKv(get('aux_address'));
|
||||
const optionsList = listToKv(get('options'));
|
||||
const labelsList = listToKv(get('labels'));
|
||||
const subnet6 = get('subnet6');
|
||||
const gateway6 = get('gateway6');
|
||||
|
||||
const createBody = {
|
||||
Name: name,
|
||||
Driver: driver,
|
||||
EnableIPv6: ipv6,
|
||||
IPAM: {
|
||||
Driver: 'default'
|
||||
},
|
||||
Internal: internal,
|
||||
Labels: labelsList,
|
||||
};
|
||||
|
||||
if (subnet || gateway || ipRange
|
||||
|| (auxAddress && typeof auxAddress === 'object' && Object.keys(auxAddress).length)) {
|
||||
createBody.IPAM.Config = [{
|
||||
Subnet: subnet,
|
||||
Gateway: gateway,
|
||||
IPRange: ipRange,
|
||||
AuxAddress: auxAddress,
|
||||
AuxiliaryAddresses: auxAddress,
|
||||
}];
|
||||
}
|
||||
|
||||
if (driver === 'macvlan') {
|
||||
createBody.Options = {
|
||||
macvlan_mode: get('macvlan_mode'),
|
||||
parent: get('parent'),
|
||||
};
|
||||
}
|
||||
else if (driver === 'ipvlan') {
|
||||
createBody.Options = {
|
||||
ipvlan_mode: get('ipvlan_mode'),
|
||||
};
|
||||
}
|
||||
else if (driver === 'overlay') {
|
||||
createBody.Ingress = ingress;
|
||||
}
|
||||
|
||||
if (ipv6 && (subnet6 || gateway6)) {
|
||||
createBody.IPAM.Config = createBody.IPAM.Config || [];
|
||||
createBody.IPAM.Config.push({
|
||||
Subnet: subnet6,
|
||||
Gateway: gateway6,
|
||||
});
|
||||
}
|
||||
|
||||
if (optionsList && typeof optionsList === 'object' && Object.keys(optionsList).length) {
|
||||
createBody.Options = Object.assign(createBody.Options || {}, optionsList);
|
||||
}
|
||||
|
||||
if (labelsList && typeof labelsList === 'object' && Object.keys(labelsList).length) {
|
||||
createBody.Labels = Object.assign(createBody.Labels || {}, labelsList);
|
||||
}
|
||||
|
||||
return createBody;
|
||||
})
|
||||
.then((createBody) => view.executeDockerAction(
|
||||
dm2.network_create,
|
||||
{ body: createBody },
|
||||
_('Create network'),
|
||||
{
|
||||
showOutput: false,
|
||||
showSuccess: false,
|
||||
onSuccess: (response) => {
|
||||
if (response?.body?.Warning) {
|
||||
view.showNotification(_('Network created with warning'), response.body.Warning, 5000, 'warning');
|
||||
} else {
|
||||
view.showNotification(_('Network created'), _('OK'), 4000, 'success');
|
||||
}
|
||||
window.location.href = `${this.dockerman_url}/networks`;
|
||||
}
|
||||
}
|
||||
))
|
||||
.catch((err) => {
|
||||
view.showNotification(_('Create network failed'), err?.message || String(err), 7000, 'error');
|
||||
return false;
|
||||
});
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
dm2.network_list(),
|
||||
dm2.container_list({query: {all: true}}),
|
||||
]);
|
||||
},
|
||||
|
||||
render([networks, containers]) {
|
||||
if (networks?.code !== 200) {
|
||||
return E('div', {}, [ networks?.body?.message ]);
|
||||
}
|
||||
|
||||
let network_list = this.getNetworksTable(networks.body, containers.body);
|
||||
// let container_list = containers.body;
|
||||
const view = this; // Capture the view context
|
||||
|
||||
|
||||
let pollPending = null;
|
||||
let netSec = null;
|
||||
|
||||
const refresh = () => {
|
||||
if (pollPending) return pollPending;
|
||||
pollPending = view.load().then(([networks2, containers2]) => {
|
||||
network_list = view.getNetworksTable(networks2.body, containers2.body);
|
||||
// container_list = containers2.body;
|
||||
m.data = new m.data.constructor({network: network_list, prune: {}});
|
||||
|
||||
if (netSec) {
|
||||
netSec.footer = [
|
||||
`${_('Total')} ${network_list.length}`,
|
||||
];
|
||||
}
|
||||
|
||||
return m.render();
|
||||
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
|
||||
return pollPending;
|
||||
};
|
||||
|
||||
|
||||
let s, o;
|
||||
const m = new form.JSONMap({network: network_list, prune: {}},
|
||||
_('Docker - Networks'),
|
||||
_('This page displays all docker networks that have been created on the connected docker host.'));
|
||||
m.submit = false;
|
||||
m.reset = false;
|
||||
|
||||
s = m.section(form.TableSection, 'prune', _('Networks overview'), null);
|
||||
s.addremove = false;
|
||||
s.anonymous = true;
|
||||
|
||||
const prune = s.option(form.Button, '_prune', null);
|
||||
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
|
||||
prune.inputstyle = 'negative';
|
||||
prune.onclick = L.bind(function(section_id, ev) {
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { },
|
||||
commandCPath: '/networks/prune',
|
||||
commandDPath: '/networks/prune',
|
||||
commandTitle: dm2.ActionTypes['prune'].i18n,
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.network_prune,
|
||||
// { query: { filters: '' } },
|
||||
// dm2.ActionTypes['prune'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('started/completed'),
|
||||
// onSuccess: () => {
|
||||
// setTimeout(() => window.location.href = `${this.dockerman_url}/networks`, 1000);
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
netSec = m.section(form.TableSection, 'network');
|
||||
netSec.anonymous = true;
|
||||
netSec.nodescriptions = true;
|
||||
netSec.addremove = true;
|
||||
netSec.sortable = true;
|
||||
netSec.filterrow = true;
|
||||
netSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
|
||||
netSec.footer = [
|
||||
`${_('Total')} ${network_list.length}`,
|
||||
];
|
||||
|
||||
netSec.handleAdd = function(section_id, ev) {
|
||||
window.location.href = `${view.dockerman_url}/network_new`;
|
||||
};
|
||||
|
||||
netSec.handleRemove = function(section_id, force, ev) {
|
||||
const network = network_list.find(net => net['.name'] === section_id);
|
||||
if (!network?.Id) return false;
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.network_remove,
|
||||
{ id: network.Id },
|
||||
dm2.ActionTypes['remove'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
onSuccess: () => {
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
netSec.handleInspect = function(section_id, ev) {
|
||||
const network = network_list.find(net => net['.name'] === section_id);
|
||||
if (!network?.Id) return false;
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.network_inspect,
|
||||
{ id: network.Id },
|
||||
dm2.ActionTypes['inspect'].i18n,
|
||||
{ showOutput: true, showSuccess: false }
|
||||
);
|
||||
};
|
||||
|
||||
netSec.renderRowActions = function (section_id) {
|
||||
const network = network_list.find(net => net['.name'] === section_id);
|
||||
const btns = [
|
||||
E('button', {
|
||||
'class': 'cbi-button view',
|
||||
'title': dm2.ActionTypes['inspect'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleInspect, section_id),
|
||||
}, [dm2.ActionTypes['inspect'].e]),
|
||||
|
||||
E('div', {
|
||||
'style': 'width: 20px',
|
||||
// Some safety margin for mis-clicks
|
||||
}, [' ']),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative remove',
|
||||
'title': dm2.ActionTypes['remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, section_id, false),
|
||||
'disabled': network?._disable_delete,
|
||||
}, dm2.ActionTypes['remove'].e),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative important remove',
|
||||
'title': dm2.ActionTypes['force_remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, section_id, true),
|
||||
'disabled': network?._disable_delete,
|
||||
}, dm2.ActionTypes['force_remove'].e),
|
||||
];
|
||||
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
|
||||
};
|
||||
|
||||
o = netSec.option(form.DummyValue, '_shortId', _('ID'));
|
||||
|
||||
o = netSec.option(form.DummyValue, 'Name', _('Name'));
|
||||
|
||||
o = netSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
o = netSec.option(form.DummyValue, '_container', _('Containers'));
|
||||
|
||||
o = netSec.option(form.DummyValue, 'Driver', _('Driver'));
|
||||
|
||||
o = netSec.option(form.DummyValue, '_interface', _('Parent Interface'));
|
||||
|
||||
o = netSec.option(form.DummyValue, '_subnet', _('Subnet'));
|
||||
|
||||
o = netSec.option(form.DummyValue, '_gateway', _('Gateway'));
|
||||
|
||||
this.insertOutputFrame(s, m);
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
getNetworksTable(networks, containers) {
|
||||
const data = [];
|
||||
|
||||
for (const [ , net] of (networks || []).entries()) {
|
||||
const n = net.Name;
|
||||
const _shortId = (net.Id || '').substring(0, 12);
|
||||
const shortLink = E('a', {
|
||||
'href': `${view.dockerman_url}/network/${net.Id}`,
|
||||
'style': 'font-family: monospace;',
|
||||
'title': _('Click to view this network'),
|
||||
}, [_shortId]);
|
||||
|
||||
// Just push plain data objects without UCI metadata
|
||||
const configs = Array.isArray(net?.IPAM?.Config) ? net.IPAM.Config : [];
|
||||
data.push({
|
||||
...net,
|
||||
_gateway: configs.map(o => o.Gateway).filter(o => o).join(', ') || '',
|
||||
_subnet: configs.map(o => o.Subnet).filter(o => o).join(', ') || '',
|
||||
_disable_delete: ( n === 'bridge' || n === 'none' || n === 'host' ) ? true : null,
|
||||
_shortId: shortLink,
|
||||
_container: this.parseContainerLinksForNetwork(net, containers),
|
||||
_interface: (net.Driver === 'bridge')
|
||||
? net.Options?.['com.docker.network.bridge.name'] || ''
|
||||
: (net.Driver === 'macvlan')
|
||||
? net?.Options?.parent
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require uci';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns a Set of image IDs in use by containers
|
||||
* @param {Array} containers - Array of container objects
|
||||
* @returns {Set<string>} Set of image IDs
|
||||
*/
|
||||
function getImagesInUseByContainers(containers) {
|
||||
const inUse = new Set();
|
||||
for (const c of containers || []) {
|
||||
if (c.ImageID) inUse.add(c.ImageID);
|
||||
else if (c.Image) inUse.add(c.Image);
|
||||
}
|
||||
return inUse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Set of network IDs in use by containers
|
||||
* @param {Array} containers - Array of container objects
|
||||
* @returns {Set<string>} Set of network IDs
|
||||
*/
|
||||
function getNetworksInUseByContainers(containers) {
|
||||
const inUse = new Set();
|
||||
for (const c of containers || []) {
|
||||
const networks = c.NetworkSettings?.Networks;
|
||||
if (networks && typeof networks === 'object') {
|
||||
for (const netName in networks) {
|
||||
const net = networks[netName];
|
||||
if (net.NetworkID) inUse.add(net.NetworkID);
|
||||
else if (netName) inUse.add(netName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return inUse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Set of volume mountpoints in use by containers
|
||||
* @param {Array} containers - Array of container objects
|
||||
* @returns {Set<string>} Set of volume names or mountpoints
|
||||
*/
|
||||
function getVolumesInUseByContainers(containers) {
|
||||
const inUse = new Set();
|
||||
for (const c of containers || []) {
|
||||
const mounts = c.Mounts;
|
||||
if (Array.isArray(mounts)) {
|
||||
for (const m of mounts) {
|
||||
if (m.Type === 'volume' && m.Name) inUse.add(m.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return inUse;
|
||||
}
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
// const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return Promise.all([
|
||||
dm2.docker_version(),
|
||||
dm2.docker_info(),
|
||||
// dm2.docker_df(), // takes > 20 seconds on large docker environments
|
||||
dm2.container_list().then(r => r.body || []),
|
||||
dm2.image_list().then(r => r.body || []),
|
||||
dm2.network_list().then(r => r.body || []),
|
||||
dm2.volume_list().then(r => r.body || []),
|
||||
dm2.callMountPoints(),
|
||||
]);
|
||||
},
|
||||
|
||||
handleAction(name, action, ev) {
|
||||
return dm2.callRcInit(name, action).then(function(ret) {
|
||||
if (ret)
|
||||
throw _('Command failed');
|
||||
|
||||
return true;
|
||||
}).catch(function(e) {
|
||||
L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning');
|
||||
});
|
||||
},
|
||||
|
||||
render([version_response,
|
||||
info_response,
|
||||
// df_response,
|
||||
container_list,
|
||||
image_list,
|
||||
network_list,
|
||||
volume_list,
|
||||
mounts,
|
||||
]) {
|
||||
const version_headers = [];
|
||||
const version_body = [];
|
||||
const info_body = [];
|
||||
// const df_body = [];
|
||||
const docker_ep = uci.get('dockerd', 'globals', 'hosts');
|
||||
let isLocal = false;
|
||||
if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1)
|
||||
isLocal = true;
|
||||
|
||||
if (info_response?.code !== 200) {
|
||||
return E('div', {}, [ info_response?.body?.message ]);
|
||||
}
|
||||
|
||||
this.parseHeaders(version_response.headers, version_headers);
|
||||
this.parseBody(version_response.body, version_body);
|
||||
this.parseBody(info_response.body, info_body);
|
||||
// this.parseBody(df_response.body, df_body);
|
||||
const view = this;
|
||||
const info = info_response.body;
|
||||
|
||||
this.concount = info?.Containers || 0;
|
||||
this.conactivecount = info?.ContainersRunning || 0;
|
||||
|
||||
/* Because the df function that reconciles Volumes, Networks and Containers
|
||||
is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */
|
||||
this.imgcount = image_list.length;
|
||||
this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0;
|
||||
|
||||
this.netcount = network_list.length;
|
||||
this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0;
|
||||
|
||||
this.volcount = volume_list?.Volumes?.length;
|
||||
this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0;
|
||||
|
||||
this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0;
|
||||
if (isLocal && this.freespace !== 0)
|
||||
this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')';
|
||||
|
||||
const mainContainer = E('div', { 'class': 'cbi-map' });
|
||||
|
||||
// Add heading and description first
|
||||
mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
|
||||
mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
|
||||
_('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
|
||||
E('br'),
|
||||
E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README'])
|
||||
]));
|
||||
|
||||
if (isLocal)
|
||||
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
|
||||
E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')),
|
||||
E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')),
|
||||
])
|
||||
]));
|
||||
|
||||
// Create the info table
|
||||
const summaryTable = new L.ui.Table(
|
||||
[_('Info'), ''],
|
||||
{ id: 'containers-table', style: 'width: 100%; table-layout: auto;' },
|
||||
[]
|
||||
);
|
||||
|
||||
summaryTable.update([
|
||||
[ _('Docker Version'), version_response.body.Version ],
|
||||
[ _('Api Version'), version_response.body.ApiVersion ],
|
||||
[ _('CPUs'), info_response.body.NCPU ],
|
||||
[ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ],
|
||||
[ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ],
|
||||
[ _('Index Server Address'), info_response.body.IndexServerAddress ],
|
||||
[ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ],
|
||||
]);
|
||||
|
||||
// Wrap the table in a cbi-section
|
||||
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
|
||||
summaryTable.render()
|
||||
]));
|
||||
|
||||
|
||||
// Create a container div with grid layout for the status badges
|
||||
let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [
|
||||
this.overviewBadge(`${this.dockerman_url}/containers`,
|
||||
E('img', {
|
||||
src: L.resource('dockerman/containers.svg'),
|
||||
style: 'width: 80px; height: 80px;'
|
||||
}, []),
|
||||
_('Containers'),
|
||||
_('Total: '),
|
||||
view.concount,
|
||||
_('Running: '),
|
||||
view.conactivecount),
|
||||
this.overviewBadge(`${this.dockerman_url}/images`,
|
||||
E('img', {
|
||||
src: L.resource('dockerman/images.svg'),
|
||||
style: 'width: 80px; height: 80px;'
|
||||
}, []),
|
||||
_('Images'),
|
||||
_('Total: '),
|
||||
view.imgcount,
|
||||
view.imgactivecount ? _('In Use: ') : '',
|
||||
view.imgactivecount ? view.imgactivecount : ''),
|
||||
this.overviewBadge(`${this.dockerman_url}/networks`,
|
||||
E('img', {
|
||||
src: L.resource('dockerman/networks.svg'),
|
||||
style: 'width: 80px; height: 80px;'
|
||||
}, []),
|
||||
_('Networks'),
|
||||
_('Total: '),
|
||||
view.netcount,
|
||||
view.netactivecount ? _('In Use: ') : '',
|
||||
view.netactivecount ? view.netactivecount : ''),
|
||||
this.overviewBadge(`${this.dockerman_url}/volumes`,
|
||||
E('img', {
|
||||
src: L.resource('dockerman/volumes.svg'),
|
||||
style: 'width: 80px; height: 80px;'
|
||||
}, []),
|
||||
_('Volumes'),
|
||||
_('Total: '),
|
||||
view.volcount,
|
||||
view.volactivecount ? _('In Use: ') : '',
|
||||
view.volactivecount ? view.volactivecount : ''),
|
||||
]);
|
||||
|
||||
// Add badges section
|
||||
mainContainer.appendChild(statusContainer);
|
||||
|
||||
const m = new form.JSONMap({
|
||||
// df: df_body,
|
||||
vb: version_body,
|
||||
ib: info_body
|
||||
});
|
||||
m.readonly = true;
|
||||
m.tabbed = false;
|
||||
|
||||
let s;
|
||||
|
||||
// Add Version and Environment tables
|
||||
s = m.section(form.TableSection, 'vb', _('Version'));
|
||||
s.anonymous = true;
|
||||
|
||||
s.option(form.DummyValue, 'entry', _('Name'));
|
||||
s.option(form.DummyValue, 'value', _('Value'));
|
||||
|
||||
s = m.section(form.TableSection, 'ib', _('Environment'));
|
||||
s.anonymous = true;
|
||||
s.filterrow = true;
|
||||
|
||||
s.option(form.DummyValue, 'entry', _('Entry'));
|
||||
s.option(form.DummyValue, 'value', _('Value'));
|
||||
|
||||
// Render the form sections and append them
|
||||
return m.render()
|
||||
.then(fe => {
|
||||
mainContainer.appendChild(fe);
|
||||
return mainContainer;
|
||||
});
|
||||
},
|
||||
|
||||
overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) {
|
||||
return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [
|
||||
E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [
|
||||
E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [
|
||||
resource_div,
|
||||
]),
|
||||
E('div', { style: 'flex: 1;' }, [
|
||||
E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption),
|
||||
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
|
||||
E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)])
|
||||
]),
|
||||
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
|
||||
E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)])
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
'use strict';
|
||||
'require form';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require dockerman.common as dm2';
|
||||
|
||||
/*
|
||||
Copyright 2026
|
||||
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
||||
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
||||
LICENSE: GPLv2.0
|
||||
*/
|
||||
|
||||
|
||||
return dm2.dv.extend({
|
||||
load() {
|
||||
return Promise.all([
|
||||
dm2.volume_list(),
|
||||
dm2.container_list({query: {all: true}}),
|
||||
]);
|
||||
},
|
||||
|
||||
render([volumes, containers]) {
|
||||
if (volumes?.code !== 200) {
|
||||
return E('div', {}, [ volumes.body.message ]);
|
||||
}
|
||||
|
||||
// this.volumes = volumes || {};
|
||||
let container_list = containers.body || [];
|
||||
let volume_list = this.getVolumesTable(volumes.body);
|
||||
const view = this; // Capture the view context
|
||||
|
||||
let pollPending = null;
|
||||
let volSec = null;
|
||||
|
||||
const refresh = () => {
|
||||
if (pollPending) return pollPending;
|
||||
pollPending = view.load().then(([volumes2, containers2]) => {
|
||||
volume_list = view.getVolumesTable(volumes2.body);
|
||||
container_list = containers2.body;
|
||||
m.data = new m.data.constructor({volume: volume_list, prune: {}});
|
||||
|
||||
if (volSec) {
|
||||
volSec.footer = [
|
||||
`${_('Total')} ${volume_list.length}`,
|
||||
];
|
||||
}
|
||||
|
||||
return m.render();
|
||||
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
|
||||
return pollPending;
|
||||
};
|
||||
|
||||
let s, o;
|
||||
const m = new form.JSONMap({volume: volume_list, prune: {}},
|
||||
_('Docker - Volumes'),
|
||||
_('This page displays all docker volumes that have been created on the connected docker host.'));
|
||||
m.submit = false;
|
||||
m.reset = false;
|
||||
|
||||
s = m.section(form.TableSection, 'prune', null, _('Volumes overview'));
|
||||
s.addremove = false;
|
||||
s.anonymous = true;
|
||||
const prune = s.option(form.Button, '_prune', null);
|
||||
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
|
||||
prune.inputstyle = 'negative';
|
||||
prune.onclick = L.bind(function(sid, ev) {
|
||||
|
||||
return this.super('handleXHRTransfer', [{
|
||||
q_params: { },
|
||||
commandCPath: '/volumes/prune',
|
||||
commandDPath: '/volumes/prune',
|
||||
commandTitle: dm2.ActionTypes['prune'].i18n,
|
||||
onUpdate: (msg) => {
|
||||
try {
|
||||
if(msg.error)
|
||||
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
||||
|
||||
const output = JSON.stringify(msg, null, 2) + '\n';
|
||||
view.insertOutput(output);
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
noFileUpload: true,
|
||||
}]);
|
||||
|
||||
// return view.executeDockerAction(
|
||||
// dm2.volume_prune,
|
||||
// { query: { filters: '' } },
|
||||
// dm2.ActionTypes['prune'].i18n,
|
||||
// {
|
||||
// showOutput: true,
|
||||
// successMessage: _('started/completed'),
|
||||
// onSuccess: () => {
|
||||
// setTimeout(() => window.location.href = `${this.dockerman_url}/volumes`, 1000);
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
}, this);
|
||||
|
||||
|
||||
volSec = m.section(form.TableSection, 'volume');
|
||||
volSec.anonymous = true;
|
||||
volSec.nodescriptions = true;
|
||||
volSec.addremove = true;
|
||||
volSec.sortable = true;
|
||||
volSec.filterrow = true;
|
||||
volSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
|
||||
volSec.footer = [
|
||||
`${_('Total')} ${volume_list.length}`,
|
||||
];
|
||||
|
||||
volSec.handleAdd = function(ev) {
|
||||
|
||||
ev.preventDefault();
|
||||
let nameInput, labelsInput;
|
||||
return ui.showModal(_('New volume'), [
|
||||
E('p', {}, _('Enter an optional name and labels for the new volume')),
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Name')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
nameInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': _('volume name'),
|
||||
})
|
||||
])
|
||||
]),
|
||||
|
||||
E('div', { 'class': 'cbi-value' }, [
|
||||
E('label', { 'class': 'cbi-value-title' }, _('Labels')),
|
||||
E('div', { 'class': 'cbi-value-field' }, [
|
||||
labelsInput = E('input', {
|
||||
'type': 'text',
|
||||
'class': 'cbi-input-text',
|
||||
'placeholder': 'key=value, key2=value2, ...',
|
||||
})
|
||||
// labelsInput = new ui.DynamicList([], [], {}).render(),
|
||||
])
|
||||
]),
|
||||
|
||||
|
||||
E('div', { 'class': 'right' }, [
|
||||
E('button', {
|
||||
'class': 'cbi-button',
|
||||
'click': ui.hideModal
|
||||
}, ['↩']),
|
||||
' ',
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'click': ui.createHandlerFn(view, () => {
|
||||
const name = nameInput.value.trim();
|
||||
const labels = Object.fromEntries(
|
||||
(labelsInput.value.trim()?.split(',') || [])
|
||||
.map(e => e.trim())
|
||||
.filter(Boolean)
|
||||
.map(e => e.split('='))
|
||||
.filter(pair => pair.length === 2)
|
||||
);
|
||||
|
||||
ui.hideModal();
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.volume_create,
|
||||
{ opts: { Name: name, Labels: labels } },
|
||||
dm2.Types['volume'].sub['create'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
onSuccess: () => {
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
}, [dm2.Types['volume'].sub['create'].e])
|
||||
])
|
||||
]);
|
||||
};
|
||||
|
||||
volSec.handleRemove = function(sid, force, ev) {
|
||||
const volume = volume_list.find(net => net['.name'] === sid);
|
||||
|
||||
if (!volume?.Name) return false;
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.volume_remove,
|
||||
{ id: volume.Name, query: { force: force } },
|
||||
dm2.ActionTypes['remove'].i18n,
|
||||
{
|
||||
showOutput: true,
|
||||
onSuccess: () => {
|
||||
return refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
volSec.handleInspect = function(sid, ev) {
|
||||
const volume = volume_list.find(net => net['.name'] === sid);
|
||||
|
||||
if (!volume?.Name) return false;
|
||||
|
||||
return view.executeDockerAction(
|
||||
dm2.volume_inspect,
|
||||
{ id: volume.Name },
|
||||
dm2.ActionTypes['inspect'].i18n,
|
||||
{ showOutput: true, showSuccess: false }
|
||||
);
|
||||
};
|
||||
|
||||
volSec.renderRowActions = function (sid) {
|
||||
const volume = volume_list.find(net => net['.name'] === sid);
|
||||
const btns = [
|
||||
E('button', {
|
||||
'class': 'cbi-button view',
|
||||
'title': dm2.ActionTypes['inspect'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleInspect, sid),
|
||||
}, [dm2.ActionTypes['inspect'].e]),
|
||||
|
||||
E('div', {
|
||||
'style': 'width: 20px',
|
||||
// Some safety margin for mis-clicks
|
||||
}, [' ']),
|
||||
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative remove',
|
||||
'title': dm2.ActionTypes['remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, sid, false),
|
||||
'disabled': volume?._disable_delete,
|
||||
}, [dm2.ActionTypes['remove'].e]),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative important remove',
|
||||
'title': dm2.ActionTypes['force_remove'].i18n,
|
||||
'click': ui.createHandlerFn(this, this.handleRemove, sid, true),
|
||||
}, [dm2.ActionTypes['force_remove'].e]),
|
||||
];
|
||||
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
|
||||
};
|
||||
|
||||
volSec.option(form.DummyValue, '_name', _('Name'));
|
||||
|
||||
o = volSec.option(form.DummyValue, 'Labels', _('Labels') + ' 🏷️');
|
||||
o.cfgvalue = view.objectCfgValueTT;
|
||||
|
||||
volSec.option(form.DummyValue, 'Driver', _('Driver'));
|
||||
|
||||
o = volSec.option(form.DummyValue, 'Containers', _('Containers'));
|
||||
o.cfgvalue = function(sid) {
|
||||
const vol = this.map.data.data[sid] || {};
|
||||
return view.parseContainerLinksForVolume(vol, container_list);
|
||||
};
|
||||
|
||||
o = volSec.option(form.DummyValue, 'Mountpoint', _('Mount Point'));
|
||||
o.cfgvalue = function(sid) {
|
||||
const mp = this.map.data.get(this.map.config, sid, this.option);
|
||||
if (!mp) return;
|
||||
// Try to match Docker volume mountpoint pattern: /var/lib/docker/volumes/<id>/_data
|
||||
const match = mp.match(/^(.*\/volumes\/)([^/]+)(\/.*)?$/);
|
||||
if (match && match[2].length > 36) {
|
||||
// Show the first 12 characters of the ID portion
|
||||
return match[1] + match[2].substring(0, 12) + '...' + (match[3] || '');
|
||||
}
|
||||
return mp;
|
||||
};
|
||||
|
||||
o = volSec.option(form.DummyValue, 'CreatedAt', _('Created'));
|
||||
|
||||
this.insertOutputFrame(s, m);
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSave: null,
|
||||
handleSaveApply: null,
|
||||
handleReset: null,
|
||||
|
||||
getVolumesTable(volumes) {
|
||||
const data = [];
|
||||
|
||||
for (const [ , vol] of (volumes?.Volumes || []).entries()) {
|
||||
const labels = vol?.Labels || {};
|
||||
|
||||
// Just push plain data objects without UCI metadata
|
||||
data.push({
|
||||
...vol,
|
||||
Labels: labels,
|
||||
_name: (vol.Name || '').substring(0, 12),
|
||||
Containers: vol.Containers || '',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
parseContainerLinksForVolume(volume, containers) {
|
||||
const links = [];
|
||||
for (const cont of containers || []) {
|
||||
const mounts = cont?.Mounts || [];
|
||||
const usesVolume = mounts.some(m => {
|
||||
if (m?.Type !== 'volume' && m?.Type !== 'bind') return false;
|
||||
const byName = !!volume?.Name && m?.Name === volume.Name;
|
||||
const bySource = !!volume?.Mountpoint && (m?.Source === volume.Mountpoint || (m?.Source || '').startsWith(volume.Mountpoint));
|
||||
return byName || bySource;
|
||||
});
|
||||
|
||||
if (usesVolume) {
|
||||
const containerName = cont?.Names?.[0]?.replace(/^\//, '') || (cont?.Id || '').substring(0, 12);
|
||||
const containerId = cont?.Id;
|
||||
links.push(E('a', {
|
||||
href: `${this.dockerman_url}/container/${containerId}`,
|
||||
title: containerId,
|
||||
style: 'white-space: nowrap;'
|
||||
}, [containerName]));
|
||||
}
|
||||
}
|
||||
|
||||
if (!links.length)
|
||||
return '-';
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
out.push(links[i]);
|
||||
if (i < links.length - 1)
|
||||
out.push(' | ');
|
||||
}
|
||||
|
||||
return E('div', {}, out);
|
||||
},
|
||||
|
||||
});
|
||||
Reference in new issue
Block a user