Files
2026-02-22 15:45:12 +08:00

1959 lines
64 KiB
JavaScript

'use strict';
'require form';
'require fs';
'require poll';
'require uci';
'require ui';
'require dockerman.common as dm2';
/*
Copyright 2026
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
LICENSE: GPLv2.0
*/
const dummy_stats = {"read":"2026-01-08T22:57:31.547920715Z","pids_stats":{"current":3},"networks":{"eth0":{"rx_bytes":5338,"rx_dropped":0,"rx_errors":0,"rx_packets":36,"tx_bytes":648,"tx_dropped":0,"tx_errors":0,"tx_packets":8},"eth5":{"rx_bytes":4641,"rx_dropped":0,"rx_errors":0,"rx_packets":26,"tx_bytes":690,"tx_dropped":0,"tx_errors":0,"tx_packets":9}},"memory_stats":{"stats":{"total_pgmajfault":0,"cache":0,"mapped_file":0,"total_inactive_file":0,"pgpgout":414,"rss":6537216,"total_mapped_file":0,"writeback":0,"unevictable":0,"pgpgin":477,"total_unevictable":0,"pgmajfault":0,"total_rss":6537216,"total_rss_huge":6291456,"total_writeback":0,"total_inactive_anon":0,"rss_huge":6291456,"hierarchical_memory_limit":67108864,"total_pgfault":964,"total_active_file":0,"active_anon":6537216,"total_active_anon":6537216,"total_pgpgout":414,"total_cache":0,"inactive_anon":0,"active_file":0,"pgfault":964,"inactive_file":0,"total_pgpgin":477},"max_usage":6651904,"usage":6537216,"failcnt":0,"limit":67108864},"blkio_stats":{},"cpu_stats":{"cpu_usage":{"percpu_usage":[8646879,24472255,36438778,30657443],"usage_in_usermode":50000000,"total_usage":100215355,"usage_in_kernelmode":30000000},"system_cpu_usage":739306590000000,"online_cpus":4,"throttling_data":{"periods":0,"throttled_periods":0,"throttled_time":0}},"precpu_stats":{"cpu_usage":{"percpu_usage":[8646879,24350896,36438778,30657443],"usage_in_usermode":50000000,"total_usage":100093996,"usage_in_kernelmode":30000000},"system_cpu_usage":9492140000000,"online_cpus":4,"throttling_data":{"periods":0,"throttled_periods":0,"throttled_time":0}}};
const dummy_ps = {"Titles":["UID","PID","PPID","C","STIME","TTY","TIME","CMD"],"Processes":[["root","13642","882","0","17:03","pts/0","00:00:00","/bin/bash"],["root","13735","13642","0","17:06","pts/0","00:00:00","sleep 10"]]};
const dummy_changes = [{"Path":"/dev","Kind":0},{"Path":"/dev/kmsg","Kind":1},{"Path":"/test","Kind":1}];
// https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Container/operation/ContainerStats
// Helper function to calculate memory usage percentage
function calculateMemoryUsage(stats) {
if (!stats || !stats.memory_stats) return null;
const mem = stats.memory_stats;
if (!mem.usage || !mem.limit) return null;
// used_memory = memory_stats.usage - memory_stats.stats.cache
const cache = mem.stats?.cache || 0;
const used_memory = mem.usage - cache;
const available_memory = mem.limit;
// Memory usage % = (used_memory / available_memory) * 100.0
const percentage = (used_memory / available_memory) * 100.0;
return {
percentage: percentage,
used: used_memory,
limit: available_memory
};
}
// Helper function to calculate CPU usage percentage
// Pass previousStats if Docker API doesn't provide complete precpu_stats
function calculateCPUUsage(stats, previousStats) {
if (!stats || !stats.cpu_stats) return null;
const cpu = stats.cpu_stats;
// Try to use precpu_stats from API first, fall back to our stored previous stats
let precpu = stats.precpu_stats;
// If precpu_stats is incomplete, use our manually stored previous stats
if (!precpu || !precpu.system_cpu_usage) {
if (previousStats && previousStats.cpu_stats) {
// console.log('Using manually stored previous CPU stats');
precpu = previousStats.cpu_stats;
} else {
// console.log('No previous CPU stats available yet - waiting for next cycle');
return null;
}
}
// If we don't have both cpu_stats and precpu_stats, return null
if (!cpu.cpu_usage || !precpu || !precpu.cpu_usage) {
// console.log('CPU stats incomplete:', {
// hasCpu: !!cpu.cpu_usage,
// hasPrecpu: !!precpu,
// hasPrecpuUsage: !!(precpu && precpu.cpu_usage)
// });
return null;
}
// Validate we have the required fields
const validationChecks = {
'cpu.cpu_usage.total_usage': typeof cpu.cpu_usage.total_usage,
'precpu.cpu_usage.total_usage': typeof precpu.cpu_usage.total_usage,
'cpu.system_cpu_usage': typeof cpu.system_cpu_usage,
'precpu.system_cpu_usage': typeof precpu.system_cpu_usage,
'cpu_values': {
cpu_total: cpu.cpu_usage.total_usage,
precpu_total: precpu.cpu_usage.total_usage,
cpu_system: cpu.system_cpu_usage,
precpu_system: precpu.system_cpu_usage
}
};
// Check if we have valid numeric values for all required fields
// Note: precpu_stats may be empty/undefined on first stats call
if (typeof cpu.cpu_usage.total_usage !== 'number' ||
typeof precpu.cpu_usage.total_usage !== 'number' ||
typeof cpu.system_cpu_usage !== 'number' ||
typeof precpu.system_cpu_usage !== 'number') {
// console.log('CPU stats incomplete - waiting for valid precpu data:', validationChecks);
return null;
}
// Also check if precpu data is essentially zero (first call scenario)
if (precpu.cpu_usage.total_usage === 0 || precpu.system_cpu_usage === 0) {
// console.log('CPU precpu stats are zero - waiting for next stats cycle');
return null;
}
// cpu_delta = cpu_stats.cpu_usage.total_usage - precpu_stats.cpu_usage.total_usage
const cpu_delta = cpu.cpu_usage.total_usage - precpu.cpu_usage.total_usage;
// system_cpu_delta = cpu_stats.system_cpu_usage - precpu_stats.system_cpu_usage
const system_cpu_delta = cpu.system_cpu_usage - precpu.system_cpu_usage;
// Validate deltas
if (system_cpu_delta <= 0 || cpu_delta < 0) {
// console.warn('Invalid CPU deltas:', {
// cpu_delta,
// system_cpu_delta,
// cpu_total: cpu.cpu_usage.total_usage,
// precpu_total: precpu.cpu_usage.total_usage,
// system: cpu.system_cpu_usage,
// presystem: precpu.system_cpu_usage
// });
return null;
}
// number_cpus = length(cpu_stats.cpu_usage.percpu_usage) or cpu_stats.online_cpus
const number_cpus = cpu.online_cpus || (cpu.cpu_usage.percpu_usage?.length || 1);
// CPU usage % = (cpu_delta / system_cpu_delta) * number_cpus * 100.0
const percentage = (cpu_delta / system_cpu_delta) * number_cpus * 100.0;
// console.log('CPU calculation:', {
// cpu_delta,
// system_cpu_delta,
// number_cpus,
// percentage: percentage.toFixed(2) + '%'
// });
return {
percentage: percentage,
number_cpus: number_cpus
};
}
// Helper function to create a progress bar
function createProgressBar(label, percentage, used, total) {
const clampedPercentage = Math.min(Math.max(percentage || 0, 0), 100);
const color = clampedPercentage > 90 ? '#d9534f' : (clampedPercentage > 70 ? '#f0ad4e' : '#5cb85c');
return E('div', { 'style': 'margin: 10px 0;' }, [
E('div', { 'style': 'display: flex; justify-content: space-between; margin-bottom: 5px;' }, [
E('span', { 'style': 'font-weight: bold;' }, label),
E('span', {}, used && total ? `${used} / ${total}` : `${clampedPercentage.toFixed(2)}%`)
]),
E('div', {
'style': 'width: 100%; height: 20px; background-color: #e9ecef; border-radius: 4px; overflow: hidden;'
}, [
E('div', {
'style': `width: ${clampedPercentage}%; height: 100%; background-color: ${color}; transition: width 0.3s ease;`
})
])
]);
}
const ChangeTypes = Object.freeze({
0: 'Modified',
1: 'Added',
2: 'Deleted',
});
return dm2.dv.extend({
load() {
const requestPath = L.env.requestpath;
const containerId = requestPath[requestPath.length-1] || '';
this.psArgs = uci.get('dockerd', 'globals', 'ps_flags') || '-ww';
// First load container info to check state
return dm2.container_inspect({id: containerId})
.then(container => {
if (container.code !== 200) window.location.href = `${this.dockerman_url}/containers`;
const this_container = container.body || {};
// Now load other resources, conditionally calling stats/ps/changes only if running
const isRunning = this_container.State?.Status === 'running';
return Promise.all([
this_container,
dm2.image_list().then(images => {
return Array.isArray(images.body) ? images.body : [];
}),
dm2.network_list().then(networks => {
return Array.isArray(networks.body) ? networks.body : [];
}),
dm2.docker_info().then(info => {
const numcpus = info.body?.NCPU || 1.0;
const memory = info.body?.MemTotal || 2**10;
return {numcpus: numcpus, memory: memory};
}),
isRunning ? dm2.container_top({ id: containerId, query: { 'ps_args': this.psArgs || '-ww' } })
.then(res => {
if (res?.code < 300 && res.body) return res.body;
else return dummy_ps;
})
.catch(() => {}) : Promise.resolve(),
isRunning ? dm2.container_stats({ id: containerId, query: { 'stream': false, 'one-shot': true } })
.then(res => {
if (res?.code < 300 && res.body) return res.body;
else return dummy_stats;
})
.catch(() => {}) : Promise.resolve(),
dm2.container_changes({ id: containerId })
.then(res => {
if (res?.code < 300 && Array.isArray(res.body)) return res.body;
else return dummy_changes;
})
.catch(() => {}),
]);
});
},
buildList(array, mapper) {
if (!Array.isArray(array)) return [];
const out = [];
for (const item of array) {
const mapped = mapper(item);
if (mapped || mapped === 0)
out.push(mapped);
}
return out;
},
buildListFromObject(obj, mapper) {
if (!obj || typeof obj !== 'object') return [];
const out = [];
for (const [k, v] of Object.entries(obj)) {
const mapped = mapper(k, v);
if (mapped || mapped === 0)
out.push(mapped);
}
return out;
},
getMountsList(this_container) {
return this.buildList(this_container?.Mounts, (mount) => {
if (!mount?.Type || !mount?.Destination) return null;
let entry = `${mount.Type}:${mount.Source}:${mount.Destination}`;
if (mount.Mode) entry += `:${mount.Mode}`;
return entry;
});
},
getPortsList(this_container) {
const portBindings = this_container?.HostConfig?.PortBindings;
if (!portBindings || typeof portBindings !== 'object') return [];
const ports = [];
for (const [containerPort, bindings] of Object.entries(portBindings)) {
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
ports.push(`${bindings[0].HostPort}:${containerPort}`);
}
}
return ports;
},
getEnvList(this_container) {
return this_container?.Config?.Env || [];
},
getDevicesList(this_container) {
return this.buildList(this_container?.HostConfig?.Devices, (device) => {
if (!device?.PathOnHost || !device?.PathInContainer) return null;
let entry = `${device.PathOnHost}:${device.PathInContainer}`;
if (device.CgroupPermissions) entry += `:${device.CgroupPermissions}`;
return entry;
});
},
getTmpfsList(this_container) {
return this.buildListFromObject(this_container?.HostConfig?.Tmpfs, (path, opts) => `${path}${opts ? ':' + opts : ''}`);
},
getDnsList(this_container) {
return this_container?.HostConfig?.Dns || [];
},
getSysctlList(this_container) {
return this.buildListFromObject(this_container?.HostConfig?.Sysctls, (key, value) => `${key}:${value}`);
},
getCapAddList(this_container) {
return this_container?.HostConfig?.CapAdd || [];
},
getLogOptList(this_container) {
return this.buildListFromObject(this_container?.HostConfig?.LogConfig?.Config, (key, value) => `${key}=${value}`);
},
getCNetworksArray(c_networks, networks) {
if (!c_networks || typeof c_networks !== 'object') return [];
const data = [];
for (const [name, net] of Object.entries(c_networks)) {
const network = networks.find(n => n.Name === name || n.Id === name);
const netid = !net?.NetworkID ? network?.Id : net?.NetworkID;
/* Even if netid is null, proceed: perhaps the network was deleted. If we
display it, the user can disconnect it. */
data.push({
...net,
_shortId: netid?.substring(0,12) || '',
Name: name,
NetworkID: netid,
DNSNames: net?.DNSNames || '',
IPv4Address: net?.IPAMConfig?.IPv4Address || '',
IPv6Address: net?.IPAMConfig?.IPv6Address || '',
});
}
return data;
},
render([this_container, images, networks, cpus_mem, ps_top, stats_data, changes_data]) {
const view = this;
const containerName = this_container.Name?.substring(1) || this_container.Id || '';
const containerIdShort = (this_container.Id || '').substring(0, 12);
const c_networks = this_container.NetworkSettings?.Networks || {};
// Create main container with action buttons
const mainContainer = E('div', {});
const containerStatus = this.getContainerStatus(this_container);
// Add title and description
const header = E('div', { 'class': 'cbi-page' }, [
E('h2', {}, _('Docker - Container')),
E('p', { 'style': 'margin: 10px 0; display: flex; gap: 6px; align-items: center;' }, [
this.wrapStatusText(containerName, containerStatus, 'font-weight:600;'),
E('span', { 'style': 'color:#666;' }, `(${containerIdShort})`)
]),
E('p', { 'style': 'color: #666;' }, _('Manage and view container configuration'))
]);
mainContainer.appendChild(header);
// Add action buttons section
const buttonSection = E('div', { 'class': 'cbi-section', 'style': 'margin-bottom: 20px;' });
const buttonContainer = E('div', { 'style': 'display: flex; gap: 10px; flex-wrap: wrap;' });
// Start button
if (containerStatus !== 'running') {
const startBtn = E('button', {
'class': 'cbi-button cbi-button-apply',
'click': (ev) => this.executeAction(ev, 'start', this_container.Id)
}, [_('Start')]);
buttonContainer.appendChild(startBtn);
}
// Restart button
if (containerStatus === 'running') {
const restartBtn = E('button', {
'class': 'cbi-button cbi-button-reload',
'click': (ev) => this.executeAction(ev, 'restart', this_container.Id)
}, [_('Restart')]);
buttonContainer.appendChild(restartBtn);
}
// Stop button
if (containerStatus === 'running' || containerStatus === 'paused') {
const stopBtn = E('button', {
'class': 'cbi-button cbi-button-reset',
'click': (ev) => this.executeAction(ev, 'stop', this_container.Id)
}, [_('Stop')]);
buttonContainer.appendChild(stopBtn);
}
// Kill button
if (containerStatus === 'running') {
const killBtn = E('button', {
'class': 'cbi-button',
'style': 'background-color: #dc3545;',
'click': (ev) => this.executeAction(ev, 'kill', this_container.Id)
}, [_('Kill')]);
buttonContainer.appendChild(killBtn);
}
// Pause/Unpause button
if (containerStatus === 'running' || containerStatus === 'paused') {
const isPausedNow = this.container?.State?.Paused === true;
const pauseBtn = E('button', {
'class': 'cbi-button',
'id': 'pause-button',
'click': (ev) => {
const currentStatus = this.getContainerStatus(this_container);
this.executeAction(ev, (currentStatus === 'paused' ? 'unpause' : 'pause'), this_container.Id);
}
}, [isPausedNow ? _('Unpause') : _('Pause')]);
buttonContainer.appendChild(pauseBtn);
}
// Duplicate button
const duplicateBtn = E('button', {
'class': 'cbi-button cbi-button-add',
'click': (ev) => {
ev.preventDefault();
window.location.href = `${this.dockerman_url}/container_new/duplicate/${this_container.Id}`;
}
}, [_('Duplicate/Edit')]);
buttonContainer.appendChild(duplicateBtn);
// Export button
const exportBtn = E('button', {
'class': 'cbi-button cbi-button-reload',
'click': (ev) => {
ev.preventDefault();
window.location.href = `${this.dockerman_url}/container/export/${this_container.Id}`;
}
}, [_('Export')]);
buttonContainer.appendChild(exportBtn);
// Remove button
const removeBtn = E('button', {
'class': 'cbi-button cbi-button-remove',
'click': (ev) => this.executeAction(ev, 'remove', this_container.Id),
}, [_('Remove')]);
buttonContainer.appendChild(removeBtn);
// Back button
const backBtn = E('button', {
'class': 'cbi-button',
'click': () => window.location.href = `${this.dockerman_url}/containers`,
}, [_('Back to Containers')]);
buttonContainer.appendChild(backBtn);
buttonSection.appendChild(buttonContainer);
mainContainer.appendChild(buttonSection);
const m = new form.JSONMap({
cont: this_container,
nets: this.getCNetworksArray(c_networks, networks),
hostcfg: this_container.HostConfig || {},
}, null);
m.submit = false;
m.reset = false;
let s = m.section(form.NamedSection, 'cont', null, _('Container detail'));
s.anonymous = true;
s.nodescriptions = true;
s.addremove = false;
let o, ss;
s.tab('info', _('Info'));
o = s.taboption('info', form.Value, 'Name', _('Name'));
o = s.taboption('info', form.DummyValue, 'Id', _('ID'));
o = s.taboption('info', form.DummyValue, 'Image', _('Image'));
o.cfgvalue = (sid) => this.getImageFirstTag(images, this.map.data.data[sid].Image);
o = s.taboption('info', form.DummyValue, 'Image', _('Image ID'));
o = s.taboption('info', form.DummyValue, 'status', _('Status'));
o.cfgvalue = (sid) => this.map.data.data[sid].State?.Status || '';
o = s.taboption('info', form.DummyValue, 'Created', _('Created'));
o = s.taboption('info', form.DummyValue, 'started', _('Finish Time'));
o.cfgvalue = () => {
if (this_container.State?.Running)
return this_container.State?.StartedAt || '-';
return this_container.State?.FinishedAt || '-';
};
o = s.taboption('info', form.DummyValue, 'healthy', _('Health Status'));
o.cfgvalue = () => this_container.State?.Health?.Status || '-';
o = s.taboption('info', form.DummyValue, 'user', _('User'));
o.cfgvalue = () => this_container.Config?.User || '-';
o = s.taboption('info', form.ListValue, 'restart_policy', _('Restart Policy'));
o.cfgvalue = () => this_container.HostConfig?.RestartPolicy?.Name || '-';
o.value('no', _('No'));
o.value('unless-stopped', _('Unless stopped'));
o.value('always', _('Always'));
o.value('on-failure', _('On failure'));
o = s.taboption('info', form.DummyValue, 'hostname', _('Host Name'));
o.cfgvalue = () => this_container.Config?.Hostname || '-';
o = s.taboption('info', form.DummyValue, 'command', _('Command'));
o.cfgvalue = () => {
const cmd = this_container.Config?.Cmd;
if (Array.isArray(cmd))
return cmd.join(' ');
return cmd || '-';
};
o = s.taboption('info', form.DummyValue, 'env', _('Env'));
o.rawhtml = true;
o.cfgvalue = () => {
const env = this.getEnvList(this_container);
return env.length > 0 ? env.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'ports', _('Ports'));
o.rawhtml = true;
o.cfgvalue = () => {
const ports = view.getPortsList(this_container);
return ports.length > 0 ? ports.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'links', _('Links'));
o.rawhtml = true;
o.cfgvalue = () => {
const links = this_container.HostConfig?.Links;
return Array.isArray(links) && links.length > 0 ? links.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'devices', _('Devices'));
o.rawhtml = true;
o.cfgvalue = () => {
const devices = this.getDevicesList(this_container);
return devices.length > 0 ? devices.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'tmpfs', _('Tmpfs Directories'));
o.rawhtml = true;
o.cfgvalue = () => {
const tmpfs = this.getTmpfsList(this_container);
return tmpfs.length > 0 ? tmpfs.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'dns', _('DNS'));
o.rawhtml = true;
o.cfgvalue = () => {
const dns = view.getDnsList(this_container);
return dns.length > 0 ? dns.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'sysctl', _('Sysctl Settings'));
o.rawhtml = true;
o.cfgvalue = () => {
const sysctl = this.getSysctlList(this_container);
return sysctl.length > 0 ? sysctl.join('<br />') : '-';
};
o = s.taboption('info', form.DummyValue, 'mounts', _('Mounts/Binds'));
o.rawhtml = true;
o.cfgvalue = () => {
const mounts = view.getMountsList(this_container);
return mounts.length > 0 ? mounts.join('<br />') : '-';
};
// NETWORKS TAB
s.tab('network', _('Networks'));
o = s.taboption('network', form.SectionValue, '__net__', form.TableSection, 'nets', null);
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = true;
ss.addremove = true;
ss.addbtntitle = _('Connect');
ss.delbtntitle = _('Disconnect');
o = ss.option(form.DummyValue, 'Name', _('Name'));
o = ss.option(form.DummyValue, '_shortId', _('ID'));
o.cfgvalue = function(section_id, value) {
const name_links = false;
const nets = this.map.data.data[section_id] || {};
return view.parseNetworkLinksForContainer(networks, (Array.isArray(nets) ? nets : [nets]), name_links);
};
o = ss.option(form.DummyValue, 'IPv4Address', _('IPv4 Address'));
o = ss.option(form.DummyValue, 'IPv6Address', _('IPv6 Address'));
o = ss.option(form.DummyValue, 'GlobalIPv6Address', _('Global IPv6 Address'));
o = ss.option(form.DummyValue, 'MacAddress', _('MAC Address'));
o = ss.option(form.DummyValue, 'Gateway', _('Gateway'));
o = ss.option(form.DummyValue, 'IPv6Gateway', _('IPv6 Gateway'));
o = ss.option(form.DummyValue, 'DNSNames', _('DNS Names'));
ss.handleAdd = function(ev) {
ev.preventDefault();
view.executeNetworkAction('connect', null, null, this_container);
};
ss.handleRemove = function(section_id, ev) {
const network = this.map.data.data[section_id];
ev.preventDefault();
delete this.map.data.data[section_id];
this.super('handleRemove', [ev]);
view.executeNetworkAction('disconnect', (network.NetworkID || network.Name), network.Name, this_container);
};
s.tab('resources', _('Resources'));
o = s.taboption('resources', form.SectionValue, '__hcfg__', form.TypedSection, 'hostcfg', null);
ss = o.subsection;
ss.anonymous = true;
ss.nodescriptions = false;
ss.addremove = false;
o = ss.option(form.Value, 'NanoCpus', _('CPUs'));
o.cfgvalue = (sid) => view.map.data.data[sid].NanoCpus / (10**9);
o.placeholder='1.5';
o.datatype = 'ufloat';
o.validate = function(section_id, value) {
if (!value) return true;
if (value > cpus_mem.numcpus) return _(`Only ${cpus_mem.numcpus} CPUs available`);
return true;
};
o = ss.option(form.Value, 'CpuPeriod', _('CPU Period (microseconds)'));
o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
o = ss.option(form.Value, 'CpuQuota', _('CPU Quota (microseconds)'));
o.datatype = 'uinteger';
o = ss.option(form.Value, 'CpuShares', _('CPU Shares Weight'));
o.placeholder='1024';
o.datatype = 'uinteger';
o = ss.option(form.Value, 'Memory', _('Memory Limit'));
o.cfgvalue = (sid, val) => {
const mem = view.map.data.data[sid].Memory;
return mem ? '%1024.2m'.format(mem) : 0;
};
o.write = function(sid, val) {
if (!val || val == 0) return 0;
this.map.data.data[sid].Memory = view.parseMemory(val);
return view.parseMemory(val) || 0;
};
o.validate = function(sid, value) {
if (!value) return true;
if (value > view.memory) return _(`Only ${view.memory} bytes available`);
return true;
};
o = ss.option(form.Value, 'MemorySwap', _('Memory + Swap'));
o.cfgvalue = (sid, val) => {
const swap = this.map.data.data[sid].MemorySwap;
return swap ? '%1024.2m'.format(swap) : 0;
};
o.write = function(sid, val) {
if (!val || val == 0) return 0;
this.map.data.data[sid].MemorySwap = view.parseMemory(val);
return view.parseMemory(val) || 0;
};
o = ss.option(form.Value, 'MemoryReservation', _('Memory Reservation'));
o.cfgvalue = (sid, val) => {
const res = this.map.data.data[sid].MemoryReservation;
return res ? '%1024.2m'.format(res) : 0;
};
o.write = function(sid, val) {
if (!val || val == 0) return 0;
this.map.data.data[sid].MemoryReservation = view.parseMemory(val);
return view.parseMemory(val) || 0;
};
o = ss.option(form.Flag, 'OomKillDisable', _('OOM Kill Disable'));
o = ss.option(form.Value, 'BlkioWeight', _('Block IO Weight'));
o.datatype = 'and(uinteger,min(0),max(1000)';
o = ss.option(form.DummyValue, 'Privileged', _('Privileged Mode'));
o.cfgvalue = (sid, val) => this.map.data.data[sid]?.Privileged ? _('Yes') : _('No');
o = ss.option(form.DummyValue, 'CapAdd', _('Added Capabilities'));
o.cfgvalue = (sid, val) => {
const caps = this.map.data.data[sid]?.CapAdd;
return Array.isArray(caps) && caps.length > 0 ? caps.join(', ') : '-';
};
o = ss.option(form.DummyValue, 'CapDrop', _('Dropped Capabilities'));
o.cfgvalue = (sid, val) => {
const caps = this.map.data.data[sid]?.CapDrop;
return Array.isArray(caps) && caps.length > 0 ? caps.join(', ') : '-';
};
o = ss.option(form.DummyValue, 'LogDriver', _('Log Driver'));
o.cfgvalue = (sid) => this.map.data.data[sid].LogConfig?.Type || '-';
o = ss.option(form.DummyValue, 'log_opt', _('Log Options'));
o.cfgvalue = () => {
const opts = this.getLogOptList(this_container);
return opts.length > 0 ? opts.join('<br />') : '-';
};
// STATS TAB
s.tab('stats', _('Stats'));
function updateStats(stats_data) {
const status = view.getContainerStatus(this_container);
if (status !== 'running') {
// If we already have UI elements, clear/update them
if (view.statsTable) {
const progressBarsSection = document.getElementById('stats-progress-bars');
if (progressBarsSection) {
progressBarsSection.innerHTML = '';
progressBarsSection.appendChild(E('p', {}, _('Container is not running') + ' (' + _('Status') + ': ' + status + ')'));
}
try { view.statsTable.update([]); } catch (e) {}
}
return E('div', { 'class': 'cbi-section' }, [
E('p', {}, [
_('Container is not running') + ' (' + _('Status') + ': ' + status + ')'
])
]);
}
const stats = stats_data || dummy_stats;
// Calculate usage percentages
const memUsage = calculateMemoryUsage(stats);
const cpuUsage = calculateCPUUsage(stats, view.previousCpuStats);
// Store current stats for next calculation
view.previousCpuStats = stats;
// Prepare rows
const rows = [
[_('PID Stats'), view.objectToText(stats.pids_stats)],
[_('Net Stats'), view.objectToText(stats.networks)],
[_('Mem Stats'), view.objectToText(stats.memory_stats)],
[_('BlkIO Stats'), view.objectToText(stats.blkio_stats)],
[_('CPU Stats'), view.objectToText(stats.cpu_stats)],
[_('Per CPU Stats'), view.objectToText(stats.precpu_stats)]
];
// If table already exists (polling update), update in-place
if (view.statsTable) {
try {
view.statsTable.update(rows);
} catch (e) { console.error('Failed to update stats table', e); }
// Update progress bars
const progressBarsSection = document.getElementById('stats-progress-bars');
if (progressBarsSection) {
progressBarsSection.innerHTML = '';
progressBarsSection.appendChild(E('h3', {}, _('Resource Usage')));
progressBarsSection.appendChild(
memUsage ? createProgressBar(
_('Memory Usage'),
memUsage.percentage,
'%1024.2mB'.format(memUsage.used),
'%1024.2mB'.format(memUsage.limit)
) : E('div', {}, _('Memory usage data unavailable'))
);
progressBarsSection.appendChild(
cpuUsage ? createProgressBar(
_('CPU Usage') + ` (${cpuUsage.number_cpus} CPUs)`,
cpuUsage.percentage,
null,
null
) : E('div', {}, _('CPU usage data unavailable'))
);
}
// Update raw JSON field
const statsField = document.getElementById('raw-stats-field');
if (statsField) statsField.textContent = JSON.stringify(stats, null, 2);
return true;
}
// Create progress bars section (initial render)
const progressBarsSection = E('div', {
'class': 'cbi-section',
'id': 'stats-progress-bars',
'style': 'margin-bottom: 20px;'
}, [
E('h3', {}, _('Resource Usage')),
memUsage ? createProgressBar(
_('Memory Usage'),
memUsage.percentage,
'%1024.2mB'.format(memUsage.used),
'%1024.2mB'.format(memUsage.limit)
) : E('div', {}, _('Memory usage data unavailable')),
cpuUsage ? createProgressBar(
_('CPU Usage') + ` (${cpuUsage.number_cpus} CPUs)`,
cpuUsage.percentage,
null,
null
) : E('div', {}, _('CPU usage data unavailable'))
]);
const statsTable = new L.ui.Table(
[_('Metric'), _('Value')],
{ id: 'stats-table' },
E('em', [_('No statistics available')])
);
// Store table reference for poll updates
view.statsTable = statsTable;
// Initial data
statsTable.update(rows);
return E('div', { 'class': 'cbi-section' }, [
progressBarsSection,
statsTable.render(),
E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
E('pre', {
style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;',
id: 'raw-stats-field'
}, JSON.stringify(stats, null, 2))
]);
};
// Create custom table for stats using L.ui.Table
o = s.taboption('stats', form.DummyValue, '_stats_table', _('Container Statistics'));
o.render = L.bind(() => { return updateStats(stats_data)}, this);
// PROCESS TAB
s.tab('ps', _('Processes'));
// Create custom table for processes using L.ui.Table
o = s.taboption('ps', form.DummyValue, '_ps_table', _('Running Processes'));
o.render = L.bind(() => {
const status = this.getContainerStatus(this_container);
if (status !== 'running') {
return E('div', { 'class': 'cbi-section' }, [
E('p', {}, [
_('Container is not running') + ' (' + _('Status') + ': ' + status + ')'
])
]);
}
// Use titles from the loaded data, or fallback to default
const titles = (ps_top && ps_top.Titles) ? ps_top.Titles :
[_('PID'), _('USER'), _('VSZ'), _('STAT'), _('COMMAND')];
// Store raw titles (without translation) for comparison in poll
this.psTitles = titles;
const psTable = new L.ui.Table(
titles.map(t => _(t)),
{ id: 'ps-table' },
E('em', [_('No processes running')])
);
// Store table reference and titles for poll updates
this.psTable = psTable;
this.psTitles = titles;
// Initial data from dummy_ps
if (ps_top && ps_top.Processes) {
psTable.update(ps_top.Processes);
}
return E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('label', { 'for': 'ps-flags-input', 'style': 'margin-right: 8px;' }, _('ps flags:')),
E('input', {
id: 'ps-flags-input',
'class': 'cbi-input-text',
'type': 'text',
'value': this.psArgs || '-ww',
'placeholder': '-ww',
'style': 'width: 200px;',
'input': (ev) => { this.psArgs = ev.target.value || '-ww'; }
})
]),
psTable.render(),
E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
E('pre', {
style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;',
id: 'raw-ps-field'
}, JSON.stringify(ps_top || dummy_ps, null, 2))
]);
}, this);
// CHANGES TAB
s.tab('changes', _('Changes'));
// Create custom table for changes using L.ui.Table
o = s.taboption('changes', form.DummyValue, '_changes_table', _('Filesystem Changes'));
o.render = L.bind(() => {
const changesTable = new L.ui.Table(
[_('Kind'), _('Path')],
{ id: 'changes-table' },
E('em', [_('No filesystem changes detected')])
);
// Store table reference for poll updates
this.changesTable = changesTable;
// Initial data
const rows = (changes_data || dummy_changes).map(change => [
ChangeTypes[change?.Kind] || change?.Kind,
change?.Path
]);
changesTable.update(rows);
return E('div', { 'class': 'cbi-section' }, [
changesTable.render(),
E('h3', { 'style': 'margin-top: 20px;' }, _('Raw JSON')),
E('pre', {
style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;',
id: 'raw-changes-field'
}, JSON.stringify(changes_data || dummy_changes, null, 2))
]);
}, this);
// FILE TAB
s.tab('file', _('File'));
let fileDiv = null;
o = s.taboption('file', form.DummyValue, 'json', '_file');
o.cfgvalue = (sid, val) => '/';
o.render = L.bind(() => {
if (fileDiv) {
return fileDiv;
}
fileDiv = E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('label', { 'style': 'margin-right: 10px;' }, _('Path:')),
E('input', {
'type': 'text',
'id': 'file-path',
'class': 'cbi-input-text',
'value': '/',
'style': 'width: 200px;'
}),
E('button', {
'class': 'cbi-button cbi-button-positive',
'style': 'margin-left: 10px;',
'click': () => this.handleFileUpload(this_container.Id),
}, _('Upload')),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 5px;',
'click': () => this.handleFileDownload(this_container.Id),
}, _('Download')),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 5px;',
'click': () => this.handleInfoArchive(this_container.Id),
}, _('Inspect')),
]),
E('textarea', {
'id': 'container-file-text',
'readonly': true,
'rows': '5',
'style': 'width: 100%; font-family: monospace; font-size: 12px; padding: 10px; border: 1px solid #ccc;'
}, '')
]);
return fileDiv;
}, this);
// INSPECT TAB
s.tab('inspect', _('Inspect'));
let inspectDiv = null;
o = s.taboption('inspect', form.Button, 'json', _('Container Inspect'));
o.render = L.bind(() => {
if (inspectDiv) {
return inspectDiv;
}
inspectDiv = E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 5px;',
'click': () => dm2.container_inspect({ id: this_container.Id }).then(response => {
const output = document.getElementById('container-inspect-output');
output.textContent = JSON.stringify(response.body, null, 2);
return;
}),
}, _('Inspect')),
]),
]);
return inspectDiv;
}, this);
o = s.taboption('inspect', form.DummyValue, 'json');
o.cfgvalue = () => E('pre', { style: 'overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;',
id: 'container-inspect-output' },
JSON.stringify(this_container, null, 2));
// TERMINAL TAB
s.tab('console', _('Console'));
o = s.taboption('console', form.DummyValue, 'console_controls', _('Console Connection'));
o.render = L.bind(() => {
const status = this.getContainerStatus(this_container);
const isRunning = status === 'running';
if (!isRunning) {
return E('div', { 'class': 'alert-message warning' },
_('Container is not running. Cannot connect to console.'));
}
const consoleDiv = E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 15px;' }, [
E('label', { 'style': 'margin-right: 10px;' }, _('Command:')),
E('span', { 'id': 'console-command-wrapper' }, [
new ui.Combobox('/bin/sh', [
'/bin/ash',
'/bin/bash',
], {id: 'console-command' }).render()
]),
E('label', { 'style': 'margin-right: 10px; margin-left: 20px;' }, _('User(-u)')),
E('input', {
'type': 'text',
'id': 'console-uid',
'class': 'cbi-input-text',
'placeholder': 'e.g., root or user id',
'style': 'width: 150px; margin-right: 10px;'
}),
E('label', { 'style': 'margin-right: 10px; margin-left: 20px;' }, _('Port:')),
E('input', {
'type': 'number',
'id': 'console-port',
'class': 'cbi-input-text',
'value': '7682',
'min': '1024',
'max': '65535',
'style': 'width: 100px; margin-right: 10px;'
}),
E('button', {
'class': 'cbi-button cbi-button-positive',
'id': 'console-connect-btn',
'click': () => this.connectConsole(this_container.Id)
}, _('Connect')),
]),
E('div', {
'id': 'console-frame-container',
'style': 'display: none; margin-top: 15px;'
}, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': () => this.disconnectConsole()
}, _('Disconnect')),
E('span', {
'id': 'console-status',
'style': 'margin-left: 10px; font-style: italic;'
}, _('Connected to container console'))
]),
E('iframe', {
'id': 'ttyd-frame',
'class': 'xterm',
'src': '',
'style': 'width: 100%; height: 600px; border: 1px solid #ccc; border-radius: 3px;'
})
])
]);
return consoleDiv;
}, this);
// WEBSOCKET TAB
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
s.tab('logs', _('Logs'));
let logsDiv = null;
let logsLoaded = false;
o = s.taboption('logs', form.DummyValue, 'log_controls', _('Log Controls'));
o.render = L.bind(() => {
if (logsDiv) {
return logsDiv;
}
logsDiv = E('div', { 'class': 'cbi-section' }, [
E('div', { 'style': 'margin-bottom: 10px;' }, [
E('label', { 'style': 'margin-right: 10px;' }, _('Lines to show:')),
E('input', {
'type': 'number',
'id': 'log-lines',
'class': 'cbi-input-text',
'value': '100',
'min': '1',
'style': 'width: 80px;'
}),
E('button', {
'class': 'cbi-button cbi-button-positive',
'style': 'margin-left: 10px;',
'click': () => this.loadLogs(this_container.Id)
}, _('Load Logs')),
E('button', {
'class': 'cbi-button cbi-button-neutral',
'style': 'margin-left: 5px;',
'click': () => this.clearLogs()
}, _('Clear')),
]),
E('div', {
'id': 'container-logs-text',
'style': 'width: 100%; font-family: monospace; padding: 10px; border: 1px solid #ccc; overflow: auto;',
'innerHTML': ''
})
]);
return logsDiv;
}, this);
o = s.taboption('logs', form.DummyValue, 'log_display', _('Container Logs'));
o.render = L.bind(() => {
// Auto-load logs when tab is first accessed
if (!logsLoaded) {
logsLoaded = true;
this.loadLogs();
}
return E('div');
}, this);
this.map = m;
// Render the form and add buttons above it
return m.render()
.then(fe => {
mainContainer.appendChild(fe);
poll.add(L.bind(() => {
if (this.getContainerStatus(this_container) !== 'running')
return Promise.resolve();
return dm2.container_changes({ id: this_container.Id })
.then(L.bind(function(res) {
if (res.code < 300 && Array.isArray(res.body)) {
// Update changes table using L.ui.Table.update()
if (this.changesTable) {
const rows = res.body.map(change => change ? [
ChangeTypes[change?.Kind] || change?.Kind,
change?.Path
] : []);
this.changesTable.update(rows);
}
// Update the raw JSON field
const changesField = document.getElementById('raw-changes-field');
if (changesField) {
changesField.textContent = JSON.stringify(res.body, null, 2);
}
}
}, this))
.catch(err => {
console.error('Failed to poll container changes', err);
return null;
});
}, this), 5);
// Auto-refresh Stats table every 5 seconds (if container is running)
poll.add(L.bind(() => {
if (this.getContainerStatus(this_container) !== 'running')
return Promise.resolve();
return dm2.container_stats({ id: this_container.Id, query: { 'stream': false, 'one-shot': true } })
.then(L.bind(function(res) {
if (res.code < 300 && res.body) {
return updateStats(res.body);
}
}, this))
.catch(err => {
console.error('Failed to poll container stats', err);
return null;
});
}, this), 5);
// Auto-refresh PS table every 5 seconds (if container is running)
poll.add(L.bind(() => {
if (this.getContainerStatus(this_container) !== 'running')
return Promise.resolve();
return dm2.container_top({ id: this_container.Id, query: { 'ps_args': this.psArgs || '-ww' } })
.then(L.bind(function(res) {
if (res.code < 300 && res.body && res.body.Processes) {
// Check if titles changed - if so, rebuild the table
if (res.body.Titles && JSON.stringify(res.body.Titles) !== JSON.stringify(this.psTitles)) {
// Titles changed, need to recreate table
this.psTitles = res.body.Titles;
const psTableEl = document.getElementById('ps-table');
if (psTableEl && psTableEl.parentNode) {
const newTable = new L.ui.Table(
res.body.Titles.map(t => _(t)),
{ id: 'ps-table' },
E('em', [_('No processes running')])
);
newTable.update(res.body.Processes);
this.psTable = newTable;
psTableEl.parentNode.replaceChild(newTable.render(), psTableEl);
}
} else if (this.psTable) {
// Titles same, just update data
this.psTable.update(res.body.Processes);
}
// Update raw JSON field
const psField = document.getElementById('raw-ps-field');
if (psField) {
psField.textContent = JSON.stringify(res.body, null, 2);
}
}
}, this))
.catch(err => {
console.error('Failed to poll container processes', err);
return null;
});
}, this), 5);
return mainContainer;
});
},
handleSave(ev) {
ev?.preventDefault();
const map = this.map;
if (!map)
return Promise.reject(new Error(_('Form is not ready yet.')));
// const listToKv = view.listToKv;
const get = (opt) => map.data.get('json', 'cont', opt);
// const getn = (opt) => map.data.get('json', 'nets', opt);
const gethc = (opt) => map.data.get('json', 'hostcfg', opt);
const toBool = (val) => (val === 1 || val === '1' || val === true);
const toInt = (val) => val ? Number.parseInt(val) : undefined;
// const toFloat = (val) => val ? Number.parseFloat(val) : undefined;
// First: update properties
map.parse()
.then(() => {
const this_container = map.data.get('json', 'cont');
const id = this_container?.Id;
/* In the container edit context, there are not many items we
can change - duplicate the container */
const createBody = {
CpuShares: toInt(gethc('CpuShares')),
Memory: toInt(gethc('Memory')),
MemorySwap: toInt(gethc('MemorySwap')),
MemoryReservation: toInt(gethc('MemoryReservation')),
BlkioWeight: toInt(gethc('blkio_weight')),
CpuPeriod: toInt(gethc('CpuPeriod')),
CpuQuota: toInt(gethc('CpuQuota')),
NanoCPUs: toInt(gethc('NanoCpus') * (10 ** 9)), // unit: 10^-9, input: float
OomKillDisable: toBool(gethc('OomKillDisable')),
RestartPolicy: { Name: get('restart_policy') || this_container.HostConfig?.RestartPolicy?.Name },
};
return { id, createBody };
})
.then(({ id, createBody }) => dm2.container_update({ id: id, body: createBody}))
.then((response) => {
if (response?.code >= 300) {
ui.addTimeLimitedNotification(_('Container update failed'), [response?.body?.message || _('Unknown error')], 7000, 'warning');
return false;
}
const msgTitle = _('Updated');
if (response?.body?.Warnings)
ui.addTimeLimitedNotification(msgTitle + _(' with warnings'), [response?.body?.Warnings], 5000);
else
ui.addTimeLimitedNotification(msgTitle, [ _('OK') ], 4000, 'info');
if (get('Name') === null)
setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
return true;
})
.catch((err) => {
ui.addTimeLimitedNotification(_('Container update failed'), [err?.message || err], 7000, 'warning');
return false;
});
// Then: update name (separate operation)
return map.parse()
.then(() => {
const this_container = map.data.get('json', 'cont');
const name = this_container.Name || get('Name');
const id = this_container.Id || get('Id');
return { id, name };
})
.then(({ id, name }) => dm2.container_rename({ id: id, query: { name: name } }))
.then((response) => {
this.handleDockerResponse(response, _('Container rename'), {
showOutput: false,
showSuccess: false
});
return setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
})
.catch((err) => {
this.showNotification(_('Container rename failed'), err?.message || String(err), 7000, 'error');
return false;
});
},
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 || '/';
const q_params = { path: encodeURIComponent(path) };
return this.super('handleXHRTransfer', [{
q_params: { query: q_params },
method: 'PUT',
commandCPath: `/container/archive/put/${container_id}/`,
commandDPath: `/containers/${container_id}/archive`,
commandTitle: _('Uploading…'),
commandMessage: _('Uploading file to container…'),
successMessage: _('File uploaded to') + ': ' + path,
pathElementId: 'file-path',
defaultPath: '/'
}]);
},
handleFileDownload(container_id) {
const path = document.getElementById('file-path')?.value || '/';
const view = this;
if (!path || path === '') {
this.showNotification(_('Error'), _('Please specify a path'), 5000, 'error');
return;
}
// Direct HTTP download bypassing RPC buffering
window.location.href = `${this.dockerman_url}/container/archive/get/${container_id}` + `/?path=${encodeURIComponent(path)}`;
return;
},
handleInfoArchive(container_id) {
const path = document.getElementById('file-path')?.value || '/';
const fileTextarea = document.getElementById('container-file-text');
if (!fileTextarea) return;
return dm2.container_info_archive({ id: container_id, query: { path: path } })
.then((response) => {
if (response?.code >= 300) {
fileTextarea.value = _('Path error') + '\n' + (response?.body?.message || _('Unknown error'));
this.showNotification(_('Error'), [response?.body?.message || _('Path error')], 7000, 'error');
return false;
}
// check response?.headers?.entries?.length in case fetch API is used
if (!response.headers || response?.headers?.entries?.length == 0) return true;
let fileInfo;
try {
fileInfo = JSON.parse(atob(response?.headers?.get?.('x-docker-container-path-stat') || response?.headers?.['x-docker-container-path-stat']));
fileTextarea.value =
`name: ${fileInfo?.name}\n` +
`size: ${fileInfo?.size}\n` +
`mode: ${this.modeToRwx(fileInfo?.mode)}\n` +
`mtime: ${fileInfo?.mtime}\n` +
`linkTarget: ${fileInfo?.linkTarget}\n`;
} catch {
this.showNotification(_('Missing header or CORS interfering'), ['X-Docker-Container-Path-Stat'], 5000, 'notice');
}
return true;
})
.catch((err) => {
const errorMsg = err?.message || String(err) || _('Path error');
fileTextarea.value = _('Path error') + '\n' + errorMsg;
this.showNotification(_('Error'), [errorMsg], 7000, 'error');
return false;
});
},
loadLogs(container_id) {
const lines = parseInt(document.getElementById('log-lines')?.value || '100');
const logsDiv = document.getElementById('container-logs-text');
if (!logsDiv) return;
logsDiv.innerHTML = '<em style="color: #999;">' + _('Loading logs…') + '</em>';
return dm2.container_logs({ id: container_id, query: { tail: lines, stdout: true, stderr: true } })
.then((response) => {
if (response?.code >= 300) {
logsDiv.innerHTML = '<span style="color: #ff5555;">' + _('Error loading logs:') + '</span><br/>' +
(response?.body?.message || _('Unknown error'));
this.showNotification(_('Error'), response?.body?.message || _('Failed to load logs'), 7000, 'error');
return false;
}
const logText = response?.body || _('No logs available');
// Convert ANSI codes to HTML and set innerHTML
logsDiv.innerHTML = dm2.ansiToHtml(logText);
logsDiv.scrollTop = logsDiv.scrollHeight;
return true;
})
.catch((err) => {
const errorMsg = err?.message || String(err) || _('Failed to load logs');
logsDiv.innerHTML = '<span style="color: #ff5555;">' + _('Error loading logs:') + '</span><br/>' + errorMsg;
this.showNotification(_('Error'), errorMsg, 7000, 'error');
return false;
});
},
clearLogs() {
const logsDiv = document.getElementById('container-logs-text');
if (logsDiv) {
logsDiv.innerHTML = '';
}
},
connectConsole(container_id) {
const commandWrapper = document.getElementById('console-command');
const selectedItem = commandWrapper?.querySelector('li[selected]');
const command = selectedItem?.textContent?.trim() || '/bin/sh';
const uid = document.getElementById('console-uid')?.value || '';
const port = parseInt(document.getElementById('console-port')?.value || '7682');
const view = this;
const connectBtn = document.getElementById('console-connect-btn');
if (connectBtn) connectBtn.disabled = true;
// Call RPC to start ttyd
return dm2.container_ttyd_start({
id: container_id,
cmd: command,
port: port,
uid: uid
})
.then((response) => {
if (connectBtn) connectBtn.disabled = false;
if (response?.code >= 300) {
const errorMsg = response?.body?.error || response?.body?.message || _('Failed to start console');
view.showNotification(_('Error'), errorMsg, 7000, 'error');
return false;
}
// Show iframe and set source
const frameContainer = document.getElementById('console-frame-container');
if (frameContainer) {
frameContainer.style.display = 'block';
const ttydFrame = document.getElementById('ttyd-frame');
if (ttydFrame) {
// Wait for ttyd to fully start and be ready for connections
// Use a retry pattern to handle timing variations
const waitForTtydReady = (attempt = 0, maxAttempts = 5, initialDelay = 500) => {
const delay = initialDelay + (attempt * 200); // Increase delay on retries
setTimeout(() => {
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';
const ttydUrl = `${protocol}://${window.location.hostname}:${port}`;
// Test connection with a simple HEAD request
fetch(ttydUrl, { method: 'HEAD', mode: 'no-cors' })
.then(() => {
// Connection successful, load the iframe
ttydFrame.src = ttydUrl;
})
.catch(() => {
// Connection failed, retry if we haven't exceeded max attempts
if (attempt < maxAttempts - 1) {
waitForTtydReady(attempt + 1, maxAttempts, initialDelay);
} else {
// Max retries exceeded, load iframe anyway
ttydFrame.src = ttydUrl;
view.showNotification(_('Warning'), _('TTYd may still be starting up'), 5000, 'warning');
}
});
}, delay);
};
waitForTtydReady();
}
}
view.showNotification(_('Success'), _('Console connected'), 3000, 'info');
return true;
})
.catch((err) => {
if (connectBtn) connectBtn.disabled = false;
const errorMsg = err?.message || String(err) || _('Failed to connect to console');
view.showNotification(_('Error'), errorMsg, 7000, 'error');
return false;
});
},
disconnectConsole() {
const frameContainer = document.getElementById('console-frame-container');
if (frameContainer) {
frameContainer.style.display = 'none';
const ttydFrame = document.getElementById('ttyd-frame');
if (ttydFrame) {
ttydFrame.src = '';
}
}
this.showNotification(_('Info'), _('Console disconnected'), 3000, 'info');
},
executeAction(ev, action, container_id) {
ev?.preventDefault();
const actionMap = Object.freeze({
'start': _('Start'),
'restart': _('Restart'),
'stop': _('Stop'),
'kill': _('Kill'),
'pause': _('Pause'),
'unpause': _('Unpause'),
'remove': _('Remove'),
});
const actionLabel = actionMap[action] || action;
// Confirm removal
if (action === 'remove') {
if (!confirm(_('Remove container?'))) {
return;
}
}
const view = this;
const methodName = 'container_' + action;
const method = dm2[methodName];
if (!method) {
view.showNotification(_('Error'), _('Action unavailable: ') + action, 7000, 'error');
return;
}
view.executeDockerAction(
method,
{ id: container_id, query: {} },
actionLabel,
{
showOutput: false,
showSuccess: true,
successMessage: actionLabel + _(' completed'),
successDuration: 5000,
onSuccess: () => {
if (action === 'remove') {
setTimeout(() => window.location.href = `${this.dockerman_url}/containers`, 1000);
} else {
setTimeout(() => location.reload(), 1000);
}
}
}
);
},
executeNetworkAction(action, networkID, networkName, this_container) {
const view = this;
if (action === 'disconnect') {
if (!confirm(_('Disconnect network "%s" from container?').format(networkName))) {
return;
}
view.executeDockerAction(
dm2.network_disconnect,
{
id: networkID,
body: { Container: view.containerId, Force: false }
},
_('Disconnect network'),
{
showOutput: false,
showSuccess: true,
successMessage: _('Network disconnected'),
successDuration: 5000,
onSuccess: () => {
setTimeout(() => location.reload(), 1000);
}
}
);
} else if (action === 'connect') {
const newNetworks = this.networks.filter(n => !Object.keys(this_container.NetworkSettings?.Networks || {}).includes(n.Name));
if (newNetworks.length === 0) {
view.showNotification(_('Info'), _('No additional networks available to connect'), 5000, 'info');
return;
}
// Create modal dialog for selecting network
const networkSelect = E('select', {
'id': 'network-select',
'class': 'cbi-input-select',
'style': 'width:100%; margin-top:10px;'
}, newNetworks.map(n => {
const subnet0 = n?.IPAM?.Config?.[0]?.Subnet;
const subnet1 = n?.IPAM?.Config?.[1]?.Subnet;
return E('option', { 'value': n.Id }, [`${n.Name}${n?.Driver ? ' | ' + n.Driver : ''}${subnet0 ? ' | ' + subnet0 : ''}${subnet1 ? ' | ' + subnet1 : ''}`]);
}));
const ip4Input = E('input', {
'type': 'text',
'id': 'network-ip',
'class': 'cbi-input-text',
'placeholder': 'e.g., 172.18.0.5',
'style': 'width:100%; margin-top:5px;'
});
const ip6Input = E('input', {
'type': 'text',
'id': 'network-ip',
'class': 'cbi-input-text',
'placeholder': 'e.g., 2001:db8:1::1',
'style': 'width:100%; margin-top:5px;'
});
const modalBody = E('div', { 'class': 'cbi-section' }, [
E('p', {}, _('Select network to connect:')),
networkSelect,
E('label', { 'style': 'display:block; margin-top:10px;' }, _('IP Address (optional):')),
ip4Input,
ip6Input,
]);
ui.showModal(_('Connect Network'), [
modalBody,
E('div', { 'class': 'right' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
' ',
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': () => {
const selectedNetwork = networkSelect.value;
const ip4Address = ip4Input.value || '';
// const ip6Address = ip6Input.value || '';
if (!selectedNetwork) {
view.showNotification(_('Error'), [_('No network selected')], 5000, 'error');
return;
}
ui.hideModal();
const body = { Container: view.containerId };
body.EndpointConfig = { IPAMConfig: { IPv4Address: ip4Address } }; //, IPv6Address: ip6Address || null
view.executeDockerAction(
dm2.network_connect,
{ id: selectedNetwork, body: body },
_('Connect network'),
{
showOutput: false,
showSuccess: true,
successMessage: _('Network connected'),
successDuration: 5000,
onSuccess: () => {
setTimeout(() => location.reload(), 1000);
}
}
);
}
}, _('Connect'))
])
]);
}
},
// handleSave: null,
handleSaveApply: null,
handleReset: null,
});