mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 10:51:51 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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 || '/';
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"docker.*": [ "*" ],
|
||||
"file": [ "*" ],
|
||||
"luci": [ "getMountPoints" ],
|
||||
"network.interface": [ "dump" ],
|
||||
"rc": [ "init" ]
|
||||
},
|
||||
"uci": [ "dockerd" ]
|
||||
|
||||
Reference in New Issue
Block a user