diff --git a/applications/luci-app-dockerman/README.md b/applications/luci-app-dockerman/README.md index 2b7d59ab79..6a53eb2cc4 100644 --- a/applications/luci-app-dockerman/README.md +++ b/applications/luci-app-dockerman/README.md @@ -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 index 0000000000..794d8b72ff --- /dev/null +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/api.js @@ -0,0 +1,534 @@ +'use strict'; +'require rpc'; +'require uci'; + +/* +Copyright 2026 +Docker manager JS for Luci by Paul Donald +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, + +}); + diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js index b5bee3c449..688212bb7f 100644 --- a/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/dockerman/common.js @@ -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), }); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js index d131aa48ef..e2e30c5ace 100644 --- a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/configuration.js @@ -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')); diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js index 879a0fb9ad..c15ab2047c 100644 --- a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/container.js @@ -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 || '/'; diff --git a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js index 198cb8a29a..22f7fe79f1 100644 --- a/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js +++ b/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/events.js @@ -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 = ''; diff --git a/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json b/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json index 3e277d3f82..210336b934 100644 --- a/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json +++ b/applications/luci-app-dockerman/root/usr/share/rpcd/acl.d/luci-app-dockerman.json @@ -11,6 +11,7 @@ "docker.*": [ "*" ], "file": [ "*" ], "luci": [ "getMountPoints" ], + "network.interface": [ "dump" ], "rc": [ "init" ] }, "uci": [ "dockerd" ]