Files
luci-app-dockerman/htdocs/luci-static/resources/dockerman/api.js
T
2026-02-21 22:33:23 +08:00

535 lines
21 KiB
JavaScript

'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 ? `/${api_ver}` : '';
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,
});