luci-app-dockerman: JS API
authorPaul Donald <redacted>
Thu, 22 Jan 2026 03:25:49 +0000 (04:25 +0100)
committerPaul Donald <redacted>
Wed, 4 Feb 2026 05:46:30 +0000 (06:46 +0100)
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 <redacted>
applications/luci-app-dockerman/README.md
applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/api.js [new file with mode: 0644]
applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js
applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js
applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json

index 2b7d59ab7963d184091c6f43b999b20d0170a208..6a53eb2cc4e70a9272b93e98dcc29c8bea51eff1 100644 (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
diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/api.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/api.js
new file mode 100644 (file)
index 0000000..794d8b7
--- /dev/null
@@ -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,
+
+});
+
index b5bee3c449dc652f013e957fd26266e8f565e910..688212bb7fb9f469c3863c5e0a8456715fc62af4 100644 (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),
 });
index d131aa48efc938a8840a5615d3b784e90f3c392f..e2e30c5ace0ad21ed3383fa34673e3c43d0e9a34 100644 (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'));
 
index 879a0fb9ad179aec13ba9e1ad382b5e856efc094..c15ab2047c0f7da9febb827a611ed47a4145e65d 100644 (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 || '/';
 
index 198cb8a29a31cd0be193e1f43a977dbf1a79f6d5..22f7fe79f1a204fa937c3571b8b432b57be6e036 100644 (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 = '';
index 3e277d3f8235b9ebbed62aa14697649ee2977322..210336b9344d3cfb52643b6619f496728655f39b 100644 (file)
@@ -11,6 +11,7 @@
                                "docker.*": [ "*" ],
                                "file": [ "*" ],
                                "luci": [ "getMountPoints" ],
+                               "network.interface": [ "dump" ],
                                "rc": [ "init" ]
                        },
                        "uci": [ "dockerd" ]
git clone https://git.99rst.org/PROJECT