'use strict'; 'require form'; 'require fs'; 'require uci'; 'require ui'; 'require rpc'; 'require view'; 'require dockerman.api as jsapi'; /* Copyright 2026 Docker manager JS for Luci by Paul Donald LICENSE: GPLv2.0 */ // docker df endpoint can take a while to resolve everything on *big* docker setups // L.env.timeout = 40; // Start docker API RPC methods // If you define new declarations here, remember to export them at the bottom. const container_changes = rpc.declare({ object: 'docker.container', method: 'changes', params: { id: '' }, }); const container_create = rpc.declare({ object: 'docker.container', method: 'create', params: { query: { }, body: { } }, }); /* We don't use rpcd for export functions, use controller instead const container_export = rpc.declare({ object: 'docker.container', method: 'export', params: { id: '' }, }); */ const container_info_archive = rpc.declare({ object: 'docker.container', method: 'info_archive', params: { id: '', query: { path: '/' } }, }); const container_inspect = rpc.declare({ object: 'docker.container', method: 'inspect', params: { id: '', query: { } }, }); const container_kill = rpc.declare({ object: 'docker.container', method: 'kill', params: { id: '', query: { } }, }); const container_list = rpc.declare({ object: 'docker.container', method: 'list', params: { query: { } }, }); const container_logs = rpc.declare({ object: 'docker.container', method: 'logs', params: { id: '', query: { } }, }); const container_pause = rpc.declare({ object: 'docker.container', method: 'pause', params: { id: '' }, }); const container_prune = rpc.declare({ object: 'docker.container', method: 'prune', params: { query: { } }, }); const container_remove = rpc.declare({ object: 'docker.container', method: 'remove', params: { id: '', query: { } }, }); const container_rename = rpc.declare({ object: 'docker.container', method: 'rename', params: { id: '', query: { } }, }); const container_restart = rpc.declare({ object: 'docker.container', method: 'restart', params: { id: '', query: { } }, }); const container_start = rpc.declare({ object: 'docker.container', method: 'start', params: { id: '', query: { } }, }); const container_stats = rpc.declare({ object: 'docker.container', method: 'stats', params: { id: '', query: { 'stream': false, 'one-shot': true } }, }); const container_stop = rpc.declare({ object: 'docker.container', method: 'stop', params: { id: '', query: { } }, }); const container_top = rpc.declare({ object: 'docker.container', method: 'top', params: { id: '', query: { 'ps_args': '' } }, }); const container_unpause = rpc.declare({ object: 'docker.container', method: 'unpause', params: { id: '' }, }); const container_update = rpc.declare({ object: 'docker.container', method: 'update', params: { id: '', body: { } }, }); const container_ttyd_start = rpc.declare({ object: 'docker.container', method: 'ttyd_start', params: { id: '', cmd: '/bin/sh', port: 7682, uid: '' }, }); // Data Usage const docker_df = rpc.declare({ object: 'docker', method: 'df', }); const docker_events = rpc.declare({ object: 'docker', method: 'events', params: { query: { since: '', until: '', filters: '' } } }); const docker_info = rpc.declare({ object: 'docker', method: 'info', }); const docker_version = rpc.declare({ object: 'docker', method: 'version', }); /* We don't use rpcd for import/build functions, use controller instead const image_build = rpc.declare({ object: 'docker.image', method: 'build', params: { query: { }, headers: { } }, }); */ const image_create = rpc.declare({ object: 'docker.image', method: 'create', params: { query: { }, headers: { } }, }); /* We don't use rpcd for export functions, use controller instead const image_get = rpc.declare({ object: 'docker.image', method: 'get', params: { id: '', query: { } }, }); */ const image_history = rpc.declare({ object: 'docker.image', method: 'history', params: { id: '' }, }); const image_inspect = rpc.declare({ object: 'docker.image', method: 'inspect', params: { id: '' }, }); const image_list = rpc.declare({ object: 'docker.image', method: 'list', }); const image_prune = rpc.declare({ object: 'docker.image', method: 'prune', params: { query: { } }, }); const image_push = rpc.declare({ object: 'docker.image', method: 'push', params: { name: '', query: { }, headers: { } }, }); const image_remove = rpc.declare({ object: 'docker.image', method: 'remove', params: { id: '', query: { } }, }); const image_tag = rpc.declare({ object: 'docker.image', method: 'tag', params: { id: '', query: { } }, }); const network_connect = rpc.declare({ object: 'docker.network', method: 'connect', params: { id: '', body: {} }, }); const network_create = rpc.declare({ object: 'docker.network', method: 'create', params: { body: {} }, }); const network_disconnect = rpc.declare({ object: 'docker.network', method: 'disconnect', params: { id: '', body: {} }, }); const network_inspect = rpc.declare({ object: 'docker.network', method: 'inspect', params: { id: '' }, }); const network_list = rpc.declare({ object: 'docker.network', method: 'list', }); const network_prune = rpc.declare({ object: 'docker.network', method: 'prune', params: { query: { } }, }); const network_remove = rpc.declare({ object: 'docker.network', method: 'remove', params: { id: '' }, }); const volume_create = rpc.declare({ object: 'docker.volume', method: 'create', params: { opts: {} }, }); const volume_inspect = rpc.declare({ object: 'docker.volume', method: 'inspect', params: { id: '' }, }); const volume_list = rpc.declare({ object: 'docker.volume', method: 'list', }); const volume_prune = rpc.declare({ object: 'docker.volume', method: 'prune', params: { query: { } }, }); const volume_remove = rpc.declare({ object: 'docker.volume', method: 'remove', params: { id: '', query: { } }, }); // End docker API RPC methods const callMountPoints = rpc.declare({ object: 'luci', method: 'getMountPoints', expect: { result: [] } }); const callRcInit = rpc.declare({ object: 'rc', method: 'init', params: [ 'name', 'action' ], }); // End generic API methods const builder = Object.freeze({ prune: {e: 'βœ‚οΈ', i18n: _('prune')}, }); const container = Object.freeze({ attach: {e: 'πŸ”Œ', i18n: _('attach')}, commit: {e: '🎯', i18n: _('commit')}, copy: {e: 'πŸ“ƒβž‘οΈπŸ“ƒ', i18n: _('copy')}, create: {e: 'βž•', i18n: _('create')}, destroy: {e: 'πŸ’₯', i18n: _('destroy')}, detach: {e: 'βŒπŸ”Œ', i18n: _('detach')}, die: {e: 'πŸͺ¦', i18n: _('die')}, exec_create: {e: 'βž•', i18n: _('exec_create')}, exec_detach: {e: 'βŒπŸ”Œ', i18n: _('exec_detach')}, exec_start: {e: '▢️', i18n: _('exec_start')}, exec_die: {e: 'πŸͺ¦', i18n: _('exec_die')}, export: {e: 'πŸ“€β¬‡οΈ', i18n: _('export')}, health_status: {e: 'πŸ©Ίβš•οΈ', i18n: _('health_status')}, kill: {e: '☠️', i18n: _('kill')}, oom: {e: '0οΈβƒ£πŸ§ ', i18n: _('oom')}, pause: {e: '⏸️', i18n: _('pause')}, rename: {e: '✍️', i18n: _('rename')}, resize: {e: '↔️', i18n: _('resize')}, restart: {e: 'πŸ”„', i18n: _('restart')}, start: {e: '▢️', i18n: _('start')}, stop: {e: '⏹️', i18n: _('stop')}, top: {e: 'πŸ”', i18n: _('top')}, unpause: {e: '⏯️', i18n: _('unpause')}, update: {e: '✏️', i18n: _('update')}, prune: {e: 'βœ‚οΈ', i18n: _('prune')}, }); const daemon = Object.freeze({ reload: {e: 'πŸ”„', i18n: _('reload')}, }); const image = Object.freeze({ create: {e: 'βž•', i18n: _('create')}, delete: {e: '❌', i18n: _('delete')}, import: {e: '➑️', i18n: _('Import')}, load: {e: '⬆️', i18n: _('load')}, pull: {e: 'β˜οΈβ¬‡οΈ', i18n: _('Pull')}, push: {e: 'β˜οΈβ¬†οΈ', i18n: _('Push')}, save: {e: 'πŸ’Ύ', i18n: _('save')}, tag: {e: '🏷️', i18n: _('tag')}, untag: {e: '❌🏷️', i18n: _('untag')}, prune: {e: 'βœ‚οΈ', i18n: _('prune')}, }); const network = Object.freeze({ create: {e: 'βž•', i18n: _('create')}, connect: {e: 'πŸ”—', i18n: _('connect')}, disconnect: {e: '⛓️‍πŸ’₯', i18n: _('disconnect')}, destroy: {e: 'πŸ’₯', i18n: _('destroy')}, update: {e: '✏️', i18n: _('update')}, remove: {e: '❌', i18n: _('remove')}, prune: {e: 'βœ‚οΈ', i18n: _('prune')}, }); const volume = Object.freeze({ create: {e: 'βž•', i18n: _('create')}, mount: {e: '⬆️', i18n: _('mount')}, unmount: {e: '⬇️', i18n: _('unmount')}, destroy: {e: 'πŸ’₯', i18n: _('destroy')}, prune: {e: 'βœ‚οΈ', i18n: _('prune')}, }); const CURTypes = Object.freeze({ create: {e: 'βž•', i18n: _('create')}, update: {e: '✏️', i18n: _('update')}, remove: {e: '❌', i18n: _('remove')}, }); const config = CURTypes; const node = CURTypes; const secret = CURTypes; const service = CURTypes; const Types = Object.freeze({ builder: {e: 'πŸ› οΈ', i18n: _('builder'), sub: builder}, config: {e: 'βš™οΈ', i18n: _('config'), sub: config}, container: {e: '🐳', i18n: _('container'), sub: container}, daemon: {e: 'πŸ”', i18n: _('daemon'), sub: daemon}, image: {e: 'πŸŒ„', i18n: _('image'), sub: image}, network: {e: 'λͺ¨', i18n: _('network'), sub: network }, node: {e: '✳️', i18n: _('node'), sub: node }, plugin: {e: 'πŸ”Œ', i18n: _('plugin') }, secret: {e: 'πŸ”', i18n: _('secret'), sub: secret }, service: {e: 'πŸ›ŽοΈ', i18n: _('service'), sub: service }, volume: {e: 'πŸ’Ώ', i18n: _('volume'), sub: volume}, }); const ActionTypes = Object.freeze({ build: {e: 'πŸ—οΈ', i18n: _('Build')}, clean: {e: '🧹', i18n: _('Clean')}, create: {e: 'πŸͺ„βž•', i18n: _('Create')}, edit: {e: '✏️', i18n: _('Edit')}, force_remove: {e: '❌', i18n: _('Force remove')}, history: {e: 'πŸͺΆπŸ“œ', i18n: _('History')}, inspect: {e: 'πŸ”Ž', i18n: _('Inspect') }, remove: {e: '❌', i18n: _('Remove')}, save: {e: '⬇️', i18n: _('Save locally')}, upload: {e: '⬆️', i18n: _('Upload')}, prune: {e: 'βœ‚οΈ', i18n: _('Prune')}, }); const ignored_headers = ['cache-control', 'connection', 'content-length', 'content-type', 'pragma', 'Components', 'Platform']; const dv = view.extend({ outputText: '', // Initialize output text get dockerman_url() { return L.url('admin/services/dockerman'); }, parseHeaders(headers, array) { for(const [k, v] of Object.entries(headers)) { if (ignored_headers.includes(k)) continue; array.push({entry: k, value: v}); } }, parseBody(body, array) { for(const [k, v] of Object.entries(body)) { if (ignored_headers.includes(k)) continue; if (!v) continue; array.push({entry: k, value: (typeof v !== 'string') ? JSON.stringify(v) : v}); } }, rwxToMode(val) { if (!val) return undefined; const raw = String(val).trim(); if (/^[0-7]+$/.test(raw)) return parseInt(raw, 8); const normalized = raw.replace(/[^rwx-]/gi, '').padEnd(9, '-').slice(0, 9); const chunkToNum = (chunk) => ( (chunk[0] === 'r' ? 4 : 0) + (chunk[1] === 'w' ? 2 : 0) + (chunk[2] === 'x' ? 1 : 0) ); const owner = chunkToNum(normalized.slice(0, 3)); const group = chunkToNum(normalized.slice(3, 6)); const other = chunkToNum(normalized.slice(6, 9)); return (owner << 6) + (group << 3) + other; }, modeToRwx(mode) { const perms = mode & 0o777; // extract permission bits const toRwx = n => ((n & 4) ? 'r' : '-') + ((n & 2) ? 'w' : '-') + ((n & 1) ? 'x' : '-'); const owner = toRwx((perms >> 6) & 0b111); const group = toRwx((perms >> 3) & 0b111); const world = toRwx(perms & 0b111); return `${owner}${group}${world}`; }, parseMemory(value) { if (!value) return 0; const rex = /^([0-9.]+) *([bkmgt])?i? *[Bb]?/i; let [, amount, unit] = rex.exec(value.toLowerCase()); amount = amount ? Number.parseFloat(amount) : 0; switch (unit) { default: break; case 'k': amount *= (2 ** 10); break; case 'm': amount *= (2 ** 20); break; case 'g': amount *= (2 ** 30); break; case 't': amount *= (2 ** 40); break; case 'p': amount *= (2 ** 50); break; } return amount; }, listToKv: (list) => { const kv = {}; const items = Array.isArray(list) ? list : (list != null ? [list] : []); items.forEach((entry) => { if (typeof entry !== 'string') return; const pos = entry.indexOf('='); if (pos <= 0) return; const key = entry.slice(0, pos); const val = entry.slice(pos + 1); if (key) kv[key] = val; }); return kv; }, objectToText(object) { let result = ''; if (!object || typeof object !== 'object') return result; if (Object.keys(object).length === 0) return result; for (const [k, v] of Object.entries(object)) result += `${!result ? '' : ', '}${k}: ${typeof v === 'object' ? this.objectToText(v) : v}` return result; }, objectCfgValueTT(sid) { const val = this.data?.[sid] ?? this.map.data.get(this.map.config, sid, this.option); return (val != null && typeof val === 'object') ? dv.prototype.objectToText.call(dv.prototype, val) : val; }, insertOutputFrame(s, m) { const frame = E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('Operational Output')), E('textarea', { 'readonly': true, 'rows': 30, 'style': 'width: 100%; font-family: monospace;', 'id': 'inspect-output-text' }, this.outputText) ]); if (!m) return frame; // Output section, for inspect results s = m.section(form.NamedSection, null, 'inspect'); s.anonymous = true; s.render = L.bind(() => { return frame; }, this); }, insertOutput(text) { // send text to the output text-area and scroll to bottom this.outputText = text; const textarea = document.getElementById('inspect-output-text'); if (textarea) { textarea.value = this.outputText; textarea.scrollTop = textarea.scrollHeight; } }, buildTimeString(unixtime) { return new Date(unixtime * 1000).toLocaleDateString([], { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); }, buildNetworkListValues(networks, option) { for (const network of networks) { let name = `${network?.Name}`; name += network?.Driver ? ` | ${network?.Driver}` : ''; name += network?.IPAM?.Config?.[0] ? ` | ${network?.IPAM?.Config?.[0]?.Subnet}` : ''; name += network?.IPAM?.Config?.[1] ? ` | ${network?.IPAM?.Config?.[1]?.Subnet}` : ''; option.value(network?.Name, name); } }, getContainerStatus(this_container) { if (!this_container?.State) return 'unknown'; const state = this_container.State; if (state.Status === 'paused') return 'paused'; else if (state.Running && !state.Restarting) return 'running'; else if (state.Running && state.Restarting) return 'restarting'; return 'stopped'; }, getImageFirstTag(image_list, image_id) { const imageArray = Array.isArray(image_list) ? image_list : []; const imageInfo = imageArray.find(img => img?.Id === image_id); const imageName = imageInfo && Array.isArray(imageInfo.RepoTags) ? imageInfo.RepoTags[0] : 'unknown'; return imageName; }, parseNetworkLinksForContainer(networks, containerNetworks, name_links) { const links = []; if (!Array.isArray(containerNetworks)) { if (containerNetworks && typeof containerNetworks === 'object') containerNetworks = Object.values(containerNetworks); } for (const cNet of containerNetworks) { const network = networks.find(n => n.Name === cNet.Name || n.Id === cNet?.NetworkID || n.Id === cNet.Name ); if (network) { links.push(E('a', { href: `${this.dockerman_url}/network/${network.Id}`, title: network.Id, style: 'white-space: nowrap;' }, [name_links ? network.Name : network.Id.slice(0,12)])); } } if (!links.length) return '-'; // Join with pipes const out = []; for (let i = 0; i < links.length; i++) { out.push(links[i]); if (i < links.length - 1) out.push(' | '); } return E('div', {}, out); }, parseContainerLinksForNetwork(network, containers) { // Find all containers connected to this network const containerLinks = []; for (const cont of containers) { let isConnected = false; if (cont.NetworkSettings?.Networks?.[network?.Name]) { isConnected = true; } else if (cont.NetworkSettings?.Networks?.[network?.Id]) { isConnected = true; } if (isConnected) { const containerName = cont.Names?.[0]?.replace(/^\//, '') || cont.Id?.substring(0, 12); const containerId = cont.Id; containerLinks.push(E('a', { href: `${this.dockerman_url}/container/${containerId}`, title: containerId, style: 'white-space: nowrap;' }, [containerName])); } } if (!containerLinks.length) return '-'; // Join with pipes const out = []; for (let i = 0; i < containerLinks.length; i++) { out.push(containerLinks[i]); if (i < containerLinks.length - 1) out.push(' | '); } return E('div', {}, out); }, statusColor(status) { const s = (status || '').toLowerCase(); if (s === 'running') return '#2ecc71'; // green if (s === 'paused') return '#f39c12'; // orange if (s === 'restarting') return '#f39c12'; // orange return '#d9534f'; // red for stopped/other }, wrapStatusText(text, status, extraStyle = '') { const color = this.statusColor(status); return E('span', { style: `color:${color};${extraStyle || ''}` }, [text]); }, /** * Show a notification to the user with standardized formatting * @param {string} title - The title of the notification (will be translated if needed) * @param {string|Array} message - Message(s) to display * @param {number} [duration=5000] - Duration in milliseconds * @param {string} [type='info'] - Type: 'success', 'info', 'warning', 'error' */ showNotification(title, message, duration = 5000, type = 'info') { const messages = Array.isArray(message) ? message : [message]; ui.addTimeLimitedNotification(title, messages, duration, type); }, /** * Normalize a registry host address by stripping scheme and path * @param {string} address - The registry address to normalize * @returns {string|null} - Normalized hostname or null */ normalizeRegistryHost(address) { if (!address) return null; let addr = String(address).trim(); // make exception for legacy Docker Hub registry https://index.docker.io/v1/ if (addr.includes('index.docker.io')) return addr.toLowerCase(); else { addr = addr.replace(/^[a-z]+:\/\//i, ''); addr = addr.split('/')[0]; addr = addr.replace(/\/$/, ''); } if (!addr) return null; return addr.toLowerCase(); }, /** * Ensure registry address has https:// scheme * @param {string} address - The registry address * @param {string} hostFallback - Fallback host if address is empty * @returns {string|null} - Address with scheme or null */ ensureRegistryScheme(address, hostFallback) { const addr = String(address || '').trim() || hostFallback; if (!addr) return null; /* jsmin cannot handle /^https?:\/\//i.test(addr) - wrap in parentheses: OK https = new RegExp('https?:\/\/', 'i'); // also OK */ return (/^https?:\/\//i.test(addr)) ? addr : `https://${addr}`; }, /** * Encode auth object to base64 * @param {Object} obj - Object with username, password, serveraddress * @returns {string|null} - Base64 encoded JSON or null on failure */ encodeBase64Json(obj) { const json = JSON.stringify(obj); try { return btoa(json); } catch (err) { try { return btoa(unescape(encodeURIComponent(json))); } catch (err2) { console.warn('Failed to encode registry auth', err2?.message || err2); return null; } } }, /** * Extract registry host from image tag * @param {string} tag - The image tag * @returns {string|null} - Registry hostname or null */ extractRegistryHostFromImage(tag) { if (!tag) return null; let ref = String(tag).trim(); ref = ref.replace(/^[a-z]+:\/\//i, ''); const slashIdx = ref.indexOf('/'); const candidate = slashIdx === -1 ? ref : ref.slice(0, slashIdx); if (!candidate) return null; const hasDot = candidate.includes('.'); const hasPort = /:[0-9]+$/.test(candidate); const isLocal = candidate === 'localhost' || candidate.startsWith('localhost:'); if (!hasDot && !hasPort && !isLocal) return null; return candidate.toLowerCase(); }, /** * Resolve registry credentials and build auth header * @param {string} imageRef - The image reference * @param {Map} registryAuthMap - Map of registry host to credentials * @returns {string|null} - Base64 encoded auth string or null */ resolveRegistryAuth(imageRef, registryAuthMap) { const host = this.extractRegistryHostFromImage(imageRef); if (!host) return null; const creds = registryAuthMap.get(host); if (!creds?.username || !creds?.password) return null; return this.encodeBase64Json({ username: creds.username, password: creds.password, serveraddress: this.ensureRegistryScheme(creds.serveraddress, host) }); }, /** * Load registry auth credentials from UCI config * @returns {Promise} - Promise resolving to Map of registry host to credentials */ loadRegistryAuthMap() { return new Promise((resolve) => { // Load UCI and extract auth sections const authMap = new Map(); L.resolveDefault(uci.load('dockerd'), {}).then(() => { uci.sections('dockerd', 'auth', (section) => { const serverRaw = section?.serveraddress; const host = this.normalizeRegistryHost(serverRaw); if (!host) return; const username = section?.username || section?.user; const password = section?.token || section?.password; if (!username || !password) return; authMap.set(host, { username, password, serveraddress: serverRaw || host, }); }); resolve(authMap); }).catch(() => { // If loading fails, return empty map resolve(authMap); }); }); }, /** * Handle Docker API response with unified error checking and user feedback * @param {Object} response - The Docker API response object * @param {string} actionName - Name of the action (e.g., 'Start', 'Remove') * @param {Object} [options={}] - Optional configuration * @param {boolean} [options.showOutput=true] - Whether to insert JSON output * @param {boolean} [options.showSuccess=true] - Whether to show success notification * @param {string} [options.successMessage] - Custom success message * @param {number} [options.successDuration=4000] - Success notification duration * @param {number} [options.errorDuration=7000] - Error notification duration * @param {Function} [options.onSuccess] - Callback on success * @param {Function} [options.onError] - Callback on error * @param {Object} [options.specialCases] - Map of status codes to handlers {304: {message: '...', type: 'notice'}} * @returns {boolean} - true if successful, false otherwise */ handleDockerResponse(response, actionName, options = {}) { const { showOutput = true, showSuccess = true, successMessage = _('OK'), successDuration = 4000, errorDuration = 7000, onSuccess = null, onError = null, specialCases = {} } = options; // Handle special status codes first (e.g., 304 Not Modified) if (specialCases[response?.code]) { const special = specialCases[response.code]; this.showNotification( actionName, special.message || _('No changes needed'), special.duration || 5000, special.type || 'notice' ); if (onSuccess) onSuccess(response); return true; } // Insert output if requested if (showOutput && response?.body != null) { const outputText = response?.body !== "" ? (Array.isArray(response.body) || typeof response.body === 'object' ? JSON.stringify(response.body, null, 2) + '\n' : String(response.body) + '\n') : `${response?.code} ${_('OK')}\n`; this.insertOutput(outputText); } // Check for errors (HTTP status >= 304) if (response?.code >= 304) { this.showNotification( actionName, response?.body?.message || _('Operation failed'), errorDuration, 'warning' ); if (onError) onError(response); return false; } // Success case if (showSuccess) { this.showNotification(actionName, successMessage, successDuration, 'success'); } if (onSuccess) onSuccess(response); return true; }, async getRegistryAuth(params, actionName) { // Extract registry candidate from params let registryCandidate = null; if (params?.query?.fromImage) { registryCandidate = params.query.fromImage; } else if (params?.query?.tag) { registryCandidate = params.query.tag; } if (params?.name && actionName === Types['image'].sub['push'].i18n) { registryCandidate = params.name; } // Try to load and inject registry auth if we have a registry candidate if (registryCandidate) { try { const authMap = await this.loadRegistryAuthMap(); const auth = this.resolveRegistryAuth(registryCandidate, authMap); if (auth) { if (!params.headers) { params.headers = {}; } params.headers['X-Registry-Auth'] = auth; } } catch (err) { // If auth loading fails, proceed without auth } } return params; }, /** * 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 * @param {Object} [options={}] - Options for handleDockerResponse * @returns {Promise} - Promise that resolves to true/false based on success */ async executeDockerAction(apiMethod, params, actionName, options = {}) { 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); } catch (err) { this.showNotification( actionName, err?.message || String(err) || _('Unexpected error'), options.errorDuration || 7000, 'error' ); if (options.onError) options.onError(err); return false; } }, /** * Flexible file/URI transfer with progress tracking and API preference * @param {Object} options - Upload configuration * @param {string} [options.method] - method to use: POST, PUT, etc * @param {string} [options.commandCPath] - controller API endpoint path (e.g. '/images/load') * @param {string} [options.commandDPath] - docker API endpoint path (e.g. '/images/load') * @param {string} [options.commandTitle] - Title for the command modal * @param {string} [options.commandMessage] - Message shown during command * @param {string} [options.successMessage] - Message on successful command * @param {string} [options.pathElementId] - Optional ID of element containing command path * @param {string} [options.defaultPath='/'] - Default path if pathElementId is not provided * @param {Function} [options.getFormData] - Optional function to customize FormData (receives file, path) * @param {Function} [options.onUpdate] - Optional function to report status progress fed back * @param {boolean} [options.noFileUpload] - If true, only a URI is uploaded (no file) */ async handleXHRTransfer(options = {}) { const { q_params = {}, method = 'POST', commandCPath = null, commandDPath = null, commandTitle = null, //_('Uploading…'), commandMessage = null, //_('Uploading file…'), successMessage = _('Successful'), showProgress = true, pathElementId = null, defaultPath = '/', getFormData = null, onUpdate = null, noFileUpload = false, } = options; const view = this; let commandPath = defaultPath; let params = await this.getRegistryAuth(q_params, commandTitle); // Get path from element if specified if (pathElementId) { commandPath = document.getElementById(pathElementId)?.value || defaultPath; if (!commandPath || commandPath === '') { this.showNotification(_('Error'), _('Please specify a path'), 5000, 'error'); return; } } // Build query string if params provided let query_str = ''; if (params.query) { let parts = []; for (let [key, value] of Object.entries(params.query)) { if (key != null && value != '') { if (Array.isArray(value)) { value.map(e => parts.push(`${key}=${e}`)); continue; } parts.push(`${key}=${value}`); } } if (parts.length) query_str = '?' + parts.join('&'); } // 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', { 'style': 'width:0%; background-color: #0066cc; height: 20px; border-radius: 3px; transition: width 0.3s ease;' }); let msgTxt = E('p', {}, commandMessage); let msgTitle = E('h4', {}, commandTitle); let progressText = E('p', {}, '0%'); if (showProgress) { ui.showModal(msgTitle, [ msgTxt, progressText, E('div', { 'class': 'cbi-progressbar', 'style': 'margin: 10px 0; background-color: #e0e0e0; border-radius: 3px; overflow: hidden;' }, progressBar) ]); } const xhr = new XMLHttpRequest(); xhr.timeout = 0; // Track upload progress xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percentComplete + '%'; progressText.textContent = percentComplete + '%'; } }); // Track progressive response progress let lastIndex = 0; // let title = _('Progress'); xhr.onprogress = (upd) => { const chunk = xhr.responseText.slice(lastIndex); lastIndex = xhr.responseText.length; const lines = chunk.split('\n').filter(Boolean); for (const line of lines) { try { const msg = JSON.parse(line); const percentComplete = Math.round((msg?.progressDetail?.current / msg?.progressDetail?.total) * 100) || 0; if (msg.stream && msg.stream != '\n') msgTxt.innerHTML = ansiToHtml(msg?.stream); if (msg.status) msgTitle.innerHTML = msg?.status progressBar.style.width = percentComplete + '%'; progressText.textContent = percentComplete + '%'; if (onUpdate) onUpdate(msg); } catch (e) {} } }; xhr.addEventListener('load', () => { ui.hideModal(); if (xhr.status >= 200 && xhr.status < 300) { view.showNotification( _('Command successful'), successMessage, 4000, 'success' ); } else { let errorMsg = xhr.responseText || `HTTP ${xhr.status}`; try { const json = JSON.parse(xhr.responseText); errorMsg = json.error || errorMsg; } catch (e) {} view.showNotification( _('Command failed'), errorMsg, 7000, 'error' ); } }); xhr.addEventListener('error', () => { ui.hideModal(); view.showNotification( _('Command failed'), _('Network error'), 7000, 'error' ); }); xhr.addEventListener('abort', () => { ui.hideModal(); view.showNotification( _('Command cancelled'), '', 5000, 'warning' ); }); if (noFileUpload) { this.handleURLOnlyForm(xhr, method, params, destUrl); } else { this.handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile); } }, handleURLOnlyForm(xhr, method, params, destUrl) { const formData = new FormData(); formData.append('token', L.env.token); // if (params.name) // formData.append('name', params.name); xhr.open(method, destUrl); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); if (params.headers) for (let [hdr_name, hdr_value] of Object.entries(params.headers)) { xhr.setRequestHeader(hdr_name, hdr_value); // smuggle in the X-Registry-Auth header in the form data formData.append(hdr_name, hdr_value); } destUrl.includes(L.env.scriptname) ? xhr.send(formData) : xhr.send(); }, handleFileUploadForm(xhr, method, getFormData, destUrl, commandPath, useRawFile) { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.style.display = 'none'; fileInput.onchange = (ev) => { const files = ev.target?.files; if (!files || files.length === 0) { ui.hideModal(); return; } const file = files[0]; // Create FormData with file let formData; if (getFormData) { formData = getFormData(file, commandPath); } else { formData = new FormData(); /* 'token' is necessary when "post": true is defined for image load endpoint */ formData.append('token', L.env.token); formData.append('upload-name', file.name); formData.append('upload-archive', file); } xhr.open(method, destUrl); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); if (useRawFile) { xhr.setRequestHeader('Content-Type', 'application/x-tar'); xhr.send(file); } else { xhr.send(formData); } }; fileInput.oncancel = (ev) => { ui.hideModal(); return; } // Trigger file picker document.body.appendChild(fileInput); fileInput.click(); document.body.removeChild(fileInput); }, }); // ANSI color code converter to HTML const ansiToHtml = function(text) { if (!text) return ''; // First, strip out terminal control sequences that aren't color codes // These include cursor positioning, screen clearing, etc. text = text // Strip CSI sequences (cursor movement, screen clearing, etc.) // eslint-disable-next-line no-control-regex .replace(/\x1B\[[0-9;?]*[A-Za-z]/g, (match) => { // Keep only SGR (Select Graphic Rendition) sequences ending in 'm' if (match.endsWith('m')) { return match; } // Strip everything else (cursor positioning, screen clearing, etc.) return ''; }) // Strip OSC sequences (window title, etc.) // eslint-disable-next-line no-control-regex .replace(/\x1B\][^\x07]*\x07/g, '') // Strip other escape sequences // eslint-disable-next-line no-control-regex .replace(/\x1B[><=]/g, '') // Strip bell character // eslint-disable-next-line no-control-regex .replace(/\x07/g, ''); // ANSI color codes mapping const ansiColorMap = { '30': '#000000', // Black '31': '#FF5555', // Red '32': '#55FF55', // Green '33': '#FFFF55', // Yellow '34': '#5555FF', // Blue '35': '#FF55FF', // Magenta '36': '#55FFFF', // Cyan '37': '#FFFFFF', // White '90': '#555555', // Bright Black '91': '#FF8787', // Bright Red '92': '#87FF87', // Bright Green '93': '#FFFF87', // Bright Yellow '94': '#8787FF', // Bright Blue '95': '#FF87FF', // Bright Magenta '96': '#87FFFF', // Bright Cyan '97': '#FFFFFF', // Bright White }; // Escape HTML special characters const escapeHtml = (str) => { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return str.replace(/[&<>"']/g, m => map[m]); }; // Split by ANSI escape sequences and process // eslint-disable-next-line no-control-regex const ansiRegex = /\x1B\[([\d;]*)m/g; let html = ''; let currentStyle = {}; let lastIndex = 0; let match; let textBuffer = ''; // Helper to flush current text with current style const flushText = () => { if (textBuffer) { const escaped = escapeHtml(textBuffer); if (Object.keys(currentStyle).length > 0) { let styleStr = ''; if (currentStyle.color) { styleStr += `color: ${currentStyle.color};`; } if (currentStyle.bgColor) { styleStr += `background-color: ${currentStyle.bgColor};`; } if (currentStyle.bold) { styleStr += 'font-weight: bold;'; } if (currentStyle.italic) { styleStr += 'font-style: italic;'; } if (currentStyle.underline) { styleStr += 'text-decoration: underline;'; } if (styleStr) { html += `${escaped}`; } else { html += escaped; } } else { html += escaped; } textBuffer = ''; } }; while ((match = ansiRegex.exec(text)) !== null) { // Add text before this escape sequence if (match.index > lastIndex) { textBuffer += text.substring(lastIndex, match.index); } // Flush current text with old style before changing style flushText(); const codes = match[1] ? match[1].split(';').map(Number) : [0]; for (const code of codes) { if (code === 0) { // Reset all styles currentStyle = {}; } else if (code === 1) { // Bold currentStyle.bold = true; } else if (code === 3) { // Italic currentStyle.italic = true; } else if (code === 4) { // Underline currentStyle.underline = true; } else if (code >= 30 && code <= 37) { // Standard foreground color currentStyle.color = ansiColorMap[code]; } else if (code >= 90 && code <= 97) { // Bright foreground color currentStyle.color = ansiColorMap[code]; } else if (code >= 40 && code <= 47) { // Background color currentStyle.bgColor = ansiColorMap[code - 10]; } } lastIndex = match.index + match[0].length; } // Add any remaining text if (lastIndex < text.length) { textBuffer += text.substring(lastIndex); } flushText(); // Convert newlines and carriage returns to
html = html.replace(/\r\n/g, '
').replace(/\r/g, '
').replace(/\n/g, '
'); 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, ansiToHtml: ansiToHtml, callMountPoints: callMountPoints, callRcInit: callRcInit, dv: dv, 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: 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: 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: 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), });