--- /dev/null
+'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,
+
+});
+
'require ui';
'require rpc';
'require view';
+'require dockerman.api as jsapi';
/*
Copyright 2026
/**
* 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
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);
// 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', {
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,
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),
});
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'));
});
},
+ 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 || '/';