luci-app-dockerman: JS API

requires either a reverse proxy which injects a suitable header

Access-Control-Allow-Origin: ...

or the local browser runs an extension like:

https://addons.mozilla.org/en-US/firefox/addon/cors-everywhere/
https://addons.mozilla.org/en-US/firefox/addon/access-control-allow-origin/
https://addons.mozilla.org/en-US/firefox/addon/cors-unblock/
https://addons.mozilla.org/en-US/firefox/addon/cross-domain-cors/

Then the local JS API can make calls to:

http :// x.x.x.x:2375 or https :// x.x.x.x:2376

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2026-01-22 04:25:49 +01:00
parent baa0f16bb3
commit 9043114b73
7 changed files with 976 additions and 48 deletions

View File

@@ -24,11 +24,12 @@ This implementation includes three methods to connect to the API.
# API Availability
| | rpcd/CGI | Reverse Proxy | Controller |
| | rpcd/CGI | (Proxy+)JS API | Controller |
|------------------|----------|----------------|------------|
| API | ✅ | ✅ | ✅ |
| File Stream | ❌ | ✅ | ✅ |
| Console Start | ✅ | ❌ | ❌ |
| WS Console | ❌ | ✅ | ❌ |
| Stream endpoints | ❌ | ✅ | ✅ |
* Stream endpoints are docker API paths that continue to stream data, like logs
@@ -42,7 +43,7 @@ It is possible to configure dockerd to listen on e.g.:
`['unix:///var/run/docker.sock', 'tcp://0.0.0.0:2375']`
when you have a Reverse Proxy configured.
when you have a Reverse Proxy configured and to open up the JS API.
## Reverse Proxy
@@ -75,6 +76,43 @@ to reach the controller API are defined in the menu JSON file. The controller
API interface only exposes a limited subset of API methods.
## JS API
A JS API is included in the front-end to connect to API endpoints, and it
will detect how dockerd is configured. If dockerd is configured with any
`xxx://x.x.x.x:2375` or `xxx://x.x.x.x:2376` (or `xxx://[2001:db8::1]:2375`)
the front end will attempt to connect using the JS API. More features are
available with a more direct connection to the API (via Proxy or using
[browser plugin](#browser-plug-in)), like WebSockets to connect to container
terminals. WebSocket connections are not currently available in LuCI, or the
LuCI CGI proxy.
CGI's job is to parse the request, send the response and disconnect.
## Browser plug-in
To avoid setting up a Proxy, and attempt to communicate directly with the API
endpoint, whether or not configured with `-tls*` options, you can use a plug-in.
One which overrides (the absence of) `Access-Control-Allow-Origin` CORS headers
(dockerd does not add these headers).
For example:
https://addons.mozilla.org/en-US/firefox/addon/cors-everywhere/
https://addons.mozilla.org/en-US/firefox/addon/access-control-allow-origin/
https://addons.mozilla.org/en-US/firefox/addon/cors-unblock/
https://addons.mozilla.org/en-US/firefox/addon/cross-domain-cors/
The browser plug-in does not magically fix TLS problems when you have mTLS
configured on dockerd (mutual CA based certificate authentication).
# Architecture
## High-Level Architecture

View File

@@ -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 ? `/${version}` : '';
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,
});

View File

@@ -5,6 +5,7 @@
'require ui';
'require rpc';
'require view';
'require dockerman.api as jsapi';
/*
Copyright 2026
@@ -943,6 +944,7 @@ const dv = view.extend({
/**
* Execute a Docker API action with consistent error handling and user feedback
* Automatically adds X-Registry-Auth header for push/pull operations if credentials exist
* Uses streaming for pull/push operations via onChunk callback
* @param {Function} apiMethod - The Docker API method to call
* @param {Object} params - Parameters to pass to the API method
* @param {string} actionName - Display name for the action
@@ -953,6 +955,18 @@ const dv = view.extend({
try {
params = await this.getRegistryAuth(params, actionName);
// Detect if this is a streaming operation and add callback if needed
const isPull = params?.query?.fromImage;
const isPush = params?.name;
const useStreaming = (isPull || isPush) && options.showOutput !== false;
if (useStreaming) {
params.onChunk = (chunk) => {
const output = chunk.raw || JSON.stringify(chunk, null, 2);
this.insertOutput(output + '\n');
};
}
// Execute the API call
const response = await apiMethod(params);
return this.handleDockerResponse(response, actionName, options);
@@ -1034,6 +1048,13 @@ const dv = view.extend({
// Prefer JS API if available, else fallback to controller
let destUrl = `${this.dockerman_url}${commandCPath}${query_str}`;
let useRawFile = false;
try {
const [ok, host] = await apiReady;
if (ok && host) {
destUrl = host + commandDPath + query_str;
useRawFile = true;
}
} catch { }
// Show progress dialog with progress bar element
let progressBar = E('div', {
@@ -1353,6 +1374,22 @@ const ansiToHtml = function(text) {
return html;
};
// Decide at call time whether to use JS API or RPC. Keep constructor synchronous.
let js_api_available = false;
// Store the JS API availability state
const apiReady = jsapi.js_api_available().then(([ok, host]) => {
js_api_available = ok;
return [ok, host];
}).catch(() => {
js_api_available = false;
return [false, null];
});
const preferApi = (apiMethod, rpcMethod) => (...args) => {
return apiReady.then(([ok, host]) => ok ? apiMethod(...args) : rpcMethod(...args));
};
return L.Class.extend({
Types: Types,
ActionTypes: ActionTypes,
@@ -1360,50 +1397,52 @@ return L.Class.extend({
callMountPoints: callMountPoints,
callRcInit: callRcInit,
dv: dv,
container_changes: container_changes,
container_create: container_create,
js_api_ready: apiReady,
container_attach_ws: preferApi(jsapi.container_attach_ws, () => Promise.reject(new Error('Docker JS API not available'))),
container_changes: preferApi(jsapi.container_changes, container_changes),
container_create: preferApi(jsapi.container_create, container_create),
// container_export: container_export, // use controller instead
container_info_archive: container_info_archive,
container_inspect: container_inspect,
container_kill: container_kill,
container_list: container_list,
container_logs: container_logs,
container_pause: container_pause,
container_prune: container_prune,
container_remove: container_remove,
container_rename: container_rename,
container_restart: container_restart,
container_start: container_start,
container_stats: container_stats,
container_stop: container_stop,
container_top: container_top,
container_info_archive: preferApi(jsapi.container_info_archive, container_info_archive),
container_inspect: preferApi(jsapi.container_inspect, container_inspect),
container_kill: preferApi(jsapi.container_kill, container_kill),
container_list: preferApi(jsapi.container_list, container_list),
container_logs: preferApi(jsapi.container_logs, container_logs),
container_pause: preferApi(jsapi.container_pause, container_pause),
container_prune: preferApi(jsapi.container_prune, container_prune),
container_remove: preferApi(jsapi.container_remove, container_remove),
container_rename: preferApi(jsapi.container_rename, container_rename),
container_restart: preferApi(jsapi.container_restart, container_restart),
container_start: preferApi(jsapi.container_start, container_start),
container_stats: preferApi(jsapi.container_stats, container_stats),
container_stop: preferApi(jsapi.container_stop, container_stop),
container_top: preferApi(jsapi.container_top, container_top),
container_ttyd_start: container_ttyd_start,
container_unpause: container_unpause,
container_update: container_update,
docker_df: docker_df,
docker_events: docker_events,
docker_info: docker_info,
docker_version: docker_version,
// image_build: image_build, // use controller instead
image_create: image_create,
container_unpause: preferApi(jsapi.container_unpause, container_unpause),
container_update: preferApi(jsapi.container_update, container_update),
docker_df: preferApi(jsapi.docker_df, docker_df),
docker_events: preferApi(jsapi.docker_events, docker_events),
docker_info: preferApi(jsapi.docker_info, docker_info),
docker_version: preferApi(jsapi.docker_version, docker_version),
image_build: preferApi(jsapi.image_build, () => Promise.reject(new Error('Docker JS API not available'))),
image_create: preferApi(jsapi.image_create, image_create),
// image_get: image_get, // use controller instead
image_history: image_history,
image_inspect: image_inspect,
image_list: image_list,
image_prune: image_prune,
image_push: image_push,
image_remove: image_remove,
image_tag: image_tag,
network_connect: network_connect,
network_create: network_create,
network_disconnect: network_disconnect,
network_inspect: network_inspect,
network_list: network_list,
network_prune: network_prune,
network_remove: network_remove,
volume_create: volume_create,
volume_inspect: volume_inspect,
volume_list: volume_list,
volume_prune: volume_prune,
volume_remove: volume_remove,
image_history: preferApi(jsapi.image_history, image_history),
image_inspect: preferApi(jsapi.image_inspect, image_inspect),
image_list: preferApi(jsapi.image_list, image_list),
image_prune: preferApi(jsapi.image_prune, image_prune),
image_push: preferApi(jsapi.image_push, image_push),
image_remove: preferApi(jsapi.image_remove, image_remove),
image_tag: preferApi(jsapi.image_tag, image_tag),
network_connect: preferApi(jsapi.network_connect, network_connect),
network_create: preferApi(jsapi.network_create, network_create),
network_disconnect: preferApi(jsapi.network_disconnect, network_disconnect),
network_inspect: preferApi(jsapi.network_inspect, network_inspect),
network_list: preferApi(jsapi.network_list, network_list),
network_prune: preferApi(jsapi.network_prune, network_prune),
network_remove: preferApi(jsapi.network_remove, network_remove),
volume_create: preferApi(jsapi.volume_create, volume_create),
volume_inspect: preferApi(jsapi.volume_inspect, volume_inspect),
volume_list: preferApi(jsapi.volume_list, volume_list),
volume_prune: preferApi(jsapi.volume_prune, volume_prune),
volume_remove: preferApi(jsapi.volume_remove, volume_remove),
});

View File

@@ -1,6 +1,7 @@
'use strict';
'require form';
'require fs';
'require tools.widgets as widgets';
/*
Copyright 2026
@@ -89,6 +90,12 @@ return L.view.extend({
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'));

View File

@@ -1066,6 +1066,86 @@ return dm2.dv.extend({
return consoleDiv;
}, this);
// WEBSOCKET TAB
t = s.tab('wsconsole', _('WebSocket'));
dm2.js_api_ready.then(([apiAvailable, host]) => {
// Wait for JS API availability check to complete
// Check if JS API is available
if (!apiAvailable) {
return;
}
o = s.taboption('wsconsole', form.DummyValue, 'wsconsole_controls', _('WebSocket Console'));
o.render = L.bind(function() {
const status = this.getContainerStatus();
const isRunning = status === 'running';
if (!isRunning) {
return E('div', { 'class': 'alert-message warning' },
_('Container is not running. Cannot connect to WebSocket console.'));
}
const wsDiv = E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('label', { 'style': 'margin-right: 10px;' }, _('Streams:')),
E('label', { 'style': 'margin-right: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'ws-stdin', 'checked': 'checked', 'style': 'margin-right: 4px;' }),
_('Stdin')
]),
E('label', { 'style': 'margin-right: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'ws-stdout', 'checked': 'checked', 'style': 'margin-right: 4px;' }),
_('Stdout')
]),
E('label', { 'style': 'margin-right: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'ws-stderr', 'style': 'margin-right: 4px;' }),
_('Stderr')
]),
E('label', { 'style': 'margin-right: 6px;' }, [
E('input', { 'type': 'checkbox', 'id': 'ws-logs', 'style': 'margin-right: 4px;' }),
_('Include logs')
]),
E('button', {
'class': 'cbi-button cbi-button-positive',
'id': 'ws-connect-btn',
'click': () => this.connectWebsocketConsole()
}, _('Connect')),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': () => this.disconnectWebsocketConsole(),
'style': 'margin-left: 6px;'
}, _('Disconnect')),
E('span', { 'id': 'ws-console-status', 'style': 'margin-left: 10px; color: #666;' }, _('Disconnected')),
]),
E('div', {
'id': 'ws-console-output',
'style': 'height: 320px; border: 1px solid #ccc; border-radius: 3px; padding: 8px; background:#111; color:#0f0; font-family: monospace; overflow: auto; white-space: pre-wrap;'
}, ''),
E('div', { 'style': 'margin-top: 10px; display: flex; gap: 6px;' }, [
E('textarea', {
'id': 'ws-console-input',
'rows': '3',
'placeholder': _('Type command here... (Ctrl+D to detach)'),
'style': 'flex: 1; padding: 6px; font-family: monospace; resize: vertical;',
'keydown': (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
this.sendWebsocketInput();
} else if (ev.key === 'd' && ev.ctrlKey) {
ev.preventDefault();
this.sendWebsocketDetach();
}
}
}),
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': () => this.sendWebsocketInput()
}, _('Send'))
])
]);
return wsDiv;
}, this);
});
// LOGS TAB
t = s.tab('logs', _('Logs'));
@@ -1307,6 +1387,206 @@ return dm2.dv.extend({
});
},
connectWebsocketConsole() {
const connectBtn = document.getElementById('ws-connect-btn');
const statusEl = document.getElementById('ws-console-status');
const outputEl = document.getElementById('ws-console-output');
const view = this;
if (connectBtn) connectBtn.disabled = true;
if (statusEl) statusEl.textContent = _('Connecting…');
// Clear the output buffer when connecting anew
if (outputEl) outputEl.innerHTML = '';
// Initialize input buffer
this.consoleInputBuffer = '';
// Tear down any previous hijack or websocket without user-facing noise
if (this.hijackController) {
try { this.hijackController.abort(); } catch (e) {}
this.hijackController = null;
}
if (this.consoleWs) {
try {
this.consoleWs.onclose = null;
this.consoleWs.onerror = null;
this.consoleWs.onmessage = null;
this.consoleWs.close();
} catch (e) {}
this.consoleWs = null;
}
const stdin = document.getElementById('ws-stdin')?.checked ? '1' : '0';
const stdout = document.getElementById('ws-stdout')?.checked ? '1' : '0';
const stderr = document.getElementById('ws-stderr')?.checked ? '1' : '0';
const logs = document.getElementById('ws-logs')?.checked ? '1' : '0';
const stream = '1';
const params = {
stdin: stdin,
stdout: stdout,
stderr: stderr,
logs: logs,
stream: stream,
detachKeys: 'ctrl-d',
}
dm2.container_attach_ws({ id: this.container.Id, query: params })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Get the WebSocket connection
const ws = response.ws || response.body;
let opened = false;
if (!ws || ws.readyState === undefined) {
throw new Error('No WebSocket connection');
}
// Expect binary frames from Docker hijack; decode as UTF-8 text
ws.binaryType = 'arraybuffer';
// Set up WebSocket message handler
ws.onmessage = (event) => {
try {
const renderAndAppend = (t) => {
if (outputEl && t) {
outputEl.innerHTML += dm2.ansiToHtml(t);
outputEl.scrollTop = outputEl.scrollHeight;
}
};
let text = '';
const data = event.data;
if (typeof data === 'string') {
text = data;
} else if (data instanceof ArrayBuffer) {
text = new TextDecoder('utf-8').decode(new Uint8Array(data));
} else if (data instanceof Blob) {
// Fallback for Blob frames
const reader = new FileReader();
reader.onload = () => {
const buf = reader.result;
const t = new TextDecoder('utf-8').decode(new Uint8Array(buf));
renderAndAppend(t);
};
reader.readAsArrayBuffer(data);
return;
}
renderAndAppend(text);
} catch (e) {
console.error('Error processing message:', e);
}
};
// Set up WebSocket error handler
ws.onerror = (error) => {
console.error('WebSocket error:', error);
if (statusEl) statusEl.textContent = _('Error');
view.showNotification(_('Error'), _('WebSocket error'), 7000, 'error');
if (ws === view.consoleWs) {
view.consoleWs = null;
}
};
// Set up WebSocket close handler
ws.onclose = (evt) => {
if (!opened) return; // Suppress close noise from previous/failed sockets
if (statusEl) statusEl.textContent = _('Disconnected');
if (connectBtn) connectBtn.disabled = false;
if (ws === view.consoleWs) {
view.consoleWs = null;
}
const code = evt?.code;
const reason = evt?.reason;
view.showNotification(_('Info'), _('Console connection closed') + (code ? ` (code: ${code}${reason ? ', ' + reason : ''})` : ''), 3000, 'info');
};
ws.onopen = () => {
opened = true;
if (statusEl) statusEl.textContent = _('Connected');
if (connectBtn) connectBtn.disabled = false;
view.showNotification(_('Success'), _('Console connected'), 3000, 'info');
// Store WebSocket reference so it doesn't get garbage collected
view.consoleWs = ws;
};
// If already open (promise resolved after onopen), set state immediately
if (ws.readyState === WebSocket.OPEN) {
opened = true;
view.consoleWs = ws;
if (statusEl) statusEl.textContent = _('Connected');
if (connectBtn) connectBtn.disabled = false;
}
})
.catch(err => {
if (err.name === 'AbortError') {
if (statusEl) statusEl.textContent = _('Disconnected');
} else {
if (statusEl) statusEl.textContent = _('Error');
view.showNotification(_('Error'), err?.message || String(err), 7000, 'error');
}
if (connectBtn) connectBtn.disabled = false;
view.hijackController = null;
});
},
disconnectWebsocketConsole() {
const statusEl = document.getElementById('ws-console-status');
const connectBtn = document.getElementById('ws-connect-btn');
if (this.hijackController) {
this.hijackController.abort();
this.hijackController = null;
}
if (statusEl) statusEl.textContent = _('Disconnected');
if (connectBtn) connectBtn.disabled = false;
this.showNotification(_('Info'), _('Console disconnected'), 3000, 'info');
},
sendWebsocketInput() {
const inputEl = document.getElementById('ws-console-input');
if (!inputEl) return;
const text = inputEl.value || '';
// Check if WebSocket is actually connected
if (this.consoleWs && this.consoleWs.readyState === WebSocket.OPEN) {
try {
const payload = text.endsWith('\n') ? text : `${text}\n`;
this.consoleWs.send(payload);
inputEl.value = '';
} catch (e) {
console.error('Error sending:', e);
this.showNotification(_('Error'), _('Failed to send data'), 5000, 'error');
}
} else {
this.showNotification(_('Error'), _('Console is not connected'), 5000, 'error');
}
},
sendWebsocketDetach() {
// Send ctrl-d (ASCII 4, EOT) to detach
if (this.consoleWs && this.consoleWs.readyState === WebSocket.OPEN) {
try {
this.consoleWs.send('\x04');
this.showNotification(_('Info'), _('Detach signal sent (Ctrl+D)'), 3000, 'info');
} catch (e) {
console.error('Error sending detach:', e);
this.showNotification(_('Error'), _('Failed to send detach signal'), 5000, 'error');
}
} else {
this.showNotification(_('Error'), _('Console is not connected'), 5000, 'error');
}
},
handleFileUpload(container_id) {
const path = document.getElementById('file-path')?.value || '/';

View File

@@ -33,13 +33,15 @@ application/json-seq: ␊ = \n | ^J | 0xa, ␞ = ␞ | ^^ | 0x1e
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, host]) => this.js_api = ok),
]);
},
render([events]) {
render([events, js_api_available]) {
if (events?.code !== 200) {
return E('div', {}, [ events?.body?.message ]);
}
@@ -199,7 +201,7 @@ return dm2.dv.extend({
if (!isNaN(toDate.getTime())) {
const now = Date.now() / 1000;
until = Math.floor(toDate.getTime() / 1000).toString();
until = until > now ? now : until;
until = !this.js_api ? until > now ? now : until : until;
}
}
const queryParams = { since, until };
@@ -212,6 +214,11 @@ return dm2.dv.extend({
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());
@@ -251,6 +258,27 @@ return dm2.dv.extend({
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 },
@@ -277,7 +305,7 @@ return dm2.dv.extend({
view.executeDockerAction(
dm2.docker_events,
{ query: queryParams },
{ query: queryParams, onChunk: handleEventChunk },
_('Load Events'),
{
showOutput: false,
@@ -286,6 +314,7 @@ return dm2.dv.extend({
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 File

@@ -11,6 +11,7 @@
"docker.*": [ "*" ],
"file": [ "*" ],
"luci": [ "getMountPoints" ],
"network.interface": [ "dump" ],
"rc": [ "init" ]
},
"uci": [ "dockerd" ]