c9e0285618
When all other conditions pass, the function shall return true. Signed-off-by: Paul Donald <newtwen+github@gmail.com> Signed-off-by: sbwml <admin@cooluc.com>
952 lines
34 KiB
JavaScript
952 lines
34 KiB
JavaScript
'use strict';
|
|
'require form';
|
|
'require fs';
|
|
'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
|
|
*/
|
|
|
|
/* API v1.52
|
|
|
|
POST /containers/create no longer supports configuring a container-wide MAC
|
|
address via the container's Config.MacAddress field. A container's MAC address
|
|
can now only be configured via endpoint settings when connecting to a network.
|
|
|
|
*/
|
|
|
|
return dm2.dv.extend({
|
|
load() {
|
|
const requestPath = L.env.requestpath;
|
|
const duplicateId = requestPath[requestPath.length-1];
|
|
const isDuplicate = requestPath[requestPath.length-2] === 'duplicate' && duplicateId;
|
|
|
|
const promises = [
|
|
dm2.image_list().then(images => {
|
|
return images.body || [];
|
|
}),
|
|
dm2.network_list().then(networks => {
|
|
return networks.body || [];
|
|
}),
|
|
dm2.volume_list().then(volumes => {
|
|
return volumes.body?.Volumes || [];
|
|
}),
|
|
dm2.docker_info().then(info => {
|
|
const numcpus = info.body?.NCPU || 1.0;
|
|
const memory = info.body?.MemTotal || 2**10;
|
|
return {numcpus: numcpus, memory: memory};
|
|
}),
|
|
];
|
|
|
|
if (isDuplicate) {
|
|
promises.push(
|
|
dm2.container_inspect({ id: duplicateId }).then(container => {
|
|
this.duplicateContainer = container.body || {};
|
|
this.isDuplicate = true;
|
|
})
|
|
);
|
|
}
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
render([image_list, network_list, volume_list, cpus_mem]) {
|
|
this.volumes = volume_list;
|
|
const view = this; // Capture the view context
|
|
|
|
// Load duplicate container config if available
|
|
let containerData = {container: {}};
|
|
let pageTitle = _('Create new docker container');
|
|
|
|
if (this.isDuplicate && this.duplicateContainer) {
|
|
pageTitle = _('Duplicate/Edit Container: %s').format(this.duplicateContainer.Name?.substring(1) || '');
|
|
const resolveImageId = (imageRef) => {
|
|
if (!imageRef) return null;
|
|
const match = (image_list || []).find(img => img.Id === imageRef || (Array.isArray(img.RepoTags) && img.RepoTags.includes(imageRef)));
|
|
return match?.Id || null;
|
|
};
|
|
const c = this.duplicateContainer;
|
|
const hostConfig = c.HostConfig || {};
|
|
const resolvedImage = resolveImageId(c.Image) || resolveImageId(c.Config?.Image) || c.Image || c.Config?.Image || '';
|
|
const builtInNetworks = new Set(['none', 'bridge', 'host']);
|
|
const [netnames, nets] = Object.entries(c.NetworkSettings?.Networks || {});
|
|
|
|
containerData.container = {
|
|
name: c.Name?.substring(1) || '',
|
|
interactive: c.Config?.AttachStdin ? 1 : 0,
|
|
tty: c.Config?.Tty ? 1 : 0,
|
|
image: resolvedImage,
|
|
privileged: hostConfig.Privileged ? 1 : 0,
|
|
restart_policy: hostConfig.RestartPolicy?.Name || 'unless-stopped',
|
|
network: (() => {
|
|
return (netnames && (netnames.length > 0)) ? netnames[0] : '';
|
|
})(),
|
|
ipv4: (() => {
|
|
if (builtInNetworks.has(netnames[0])) return '';
|
|
return (nets && (nets.length > 0)) ? nets[0]?.IPAddress || '' : '';
|
|
})(),
|
|
ipv6: (() => {
|
|
if (builtInNetworks.has(netnames[0])) return '';
|
|
return (nets && (nets.length > 0)) ? nets[0]?.GlobalIPv6Address || '' : '';
|
|
})(),
|
|
ipv6_lla: (() => {
|
|
if (builtInNetworks.has(netnames[0])) return '';
|
|
return (nets && (nets.length > 0)) ? nets[0]?.LinkLocalIPv6Address || '' : '';
|
|
})(),
|
|
link: hostConfig.Links || [],
|
|
dns: hostConfig.Dns || [],
|
|
user: c.Config?.User || '',
|
|
env: c.Config?.Env || [],
|
|
volume: (hostConfig.Mounts || c.Mounts || []).map(m => {
|
|
let source;
|
|
const destination = m.Destination || m.Target || '';
|
|
let opts = '';
|
|
if (m.Type === 'image') {
|
|
source = '@image';
|
|
if (m.ImageOptions && m.ImageOptions.Subpath)
|
|
opts = 'subpath=' + m.ImageOptions.Subpath;
|
|
} else if (m.Type === 'tmpfs') {
|
|
source = '@tmpfs';
|
|
const tmpOpts = [];
|
|
if (m.TmpfsOptions) {
|
|
if (m.TmpfsOptions.SizeBytes) tmpOpts.push('size=' + m.TmpfsOptions.SizeBytes);
|
|
if (m.TmpfsOptions.Mode) tmpOpts.push('mode=' + m.TmpfsOptions.Mode);
|
|
if (Array.isArray(m.TmpfsOptions.Options)) {
|
|
for (const o of m.TmpfsOptions.Options) {
|
|
if (Array.isArray(o) && o.length === 2) tmpOpts.push(`${o[0]}=${o[1]}`);
|
|
else if (Array.isArray(o) && o.length === 1) tmpOpts.push(o[0]);
|
|
}
|
|
}
|
|
}
|
|
opts = tmpOpts.join(',');
|
|
} else if (m.Type === 'volume') {
|
|
source = m.Source || '';
|
|
// opts = m.Mode || '';
|
|
} else {
|
|
source = m.Source || '';
|
|
opts = m.Mode || '';
|
|
}
|
|
return source + ':' + destination + (opts ? ':' + opts : '');
|
|
}),
|
|
publish: (() => {
|
|
const ports = [];
|
|
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
|
|
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
|
|
const hostPort = bindings[0].HostPort;
|
|
ports.push(hostPort + ':' + containerPort);
|
|
}
|
|
}
|
|
return ports;
|
|
})(),
|
|
command: c.Config?.Cmd ? c.Config?.Cmd.join(' ') : '',
|
|
hostname: c.Config?.Hostname || '',
|
|
publish_all: hostConfig.PublishAllPorts ? 1 : 0,
|
|
device: (hostConfig.Devices || []).map(d => d.PathOnHost + ':' + d.PathInContainer + (d.CgroupPermissions ? ':' + d.CgroupPermissions : '')),
|
|
tmpfs: (() => {
|
|
const list = [];
|
|
if (hostConfig.Tmpfs && typeof hostConfig.Tmpfs === 'object') {
|
|
for (const [path, opts] of Object.entries(hostConfig.Tmpfs)) {
|
|
list.push(path + (opts ? ':' + opts : ''));
|
|
}
|
|
}
|
|
return list;
|
|
})(),
|
|
sysctl: (() => {
|
|
const list = [];
|
|
if (hostConfig.Sysctls && typeof hostConfig.Sysctls === 'object') {
|
|
for (const [key, value] of Object.entries(hostConfig.Sysctls)) {
|
|
list.push(key + ':' + value);
|
|
}
|
|
}
|
|
return list;
|
|
})(),
|
|
cap_add: hostConfig.CapAdd || [],
|
|
cpus: hostConfig.NanoCPUs ? (hostConfig.NanoCPUs / (10 ** 9)).toFixed(3) : '',
|
|
cpu_shares: hostConfig.CpuShares || '',
|
|
cpu_period: hostConfig.CpuPeriod || '',
|
|
cpu_quota: hostConfig.CpuQuota || '',
|
|
memory: hostConfig.Memory || '',
|
|
memory_reservation: hostConfig.MemoryReservation || '',
|
|
blkio_weight: hostConfig.BlkioWeight || '',
|
|
log_opt: (() => {
|
|
const list = [];
|
|
const logConfig = hostConfig.LogConfig?.Config;
|
|
if (logConfig && typeof logConfig === 'object') {
|
|
for (const [key, value] of Object.entries(logConfig)) {
|
|
list.push(key + '=' + value);
|
|
}
|
|
}
|
|
return list;
|
|
})(),
|
|
};
|
|
}
|
|
|
|
// stuff JSONMap with container config
|
|
const m = new form.JSONMap(containerData, _('Docker - Containers'));
|
|
m.submit = true;
|
|
m.reset = true;
|
|
|
|
let s = m.section(form.NamedSection, 'container', pageTitle);
|
|
s.anonymous = true;
|
|
s.nodescriptions = true;
|
|
s.addremove = false;
|
|
|
|
let o;
|
|
|
|
o = s.option(form.Value, 'name', _('Container Name'),
|
|
_('Name of the container that can be selected during container creation'));
|
|
o.rmempty = true;
|
|
|
|
o = s.option(form.Flag, 'interactive', _('Interactive (-i)'));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
|
|
o = s.option(form.Flag, 'tty', _('TTY (-t)'));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
|
|
o = s.option(form.ListValue, 'image', _('Docker Image'));
|
|
o.rmempty = true;
|
|
for (const image of image_list) {
|
|
o.value(image.Id, image?.RepoTags?.[0]);
|
|
}
|
|
|
|
o = s.option(form.Flag, 'pull', _('Always pull image first'));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
|
|
o = s.option(form.Flag, 'privileged', _('Privileged'));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
|
|
o = s.option(form.ListValue, 'restart_policy', _('Restart Policy'));
|
|
o.rmempty = true;
|
|
o.default = 'unless-stopped';
|
|
o.value('no', _('No'));
|
|
o.value('unless-stopped', _('Unless stopped'));
|
|
o.value('always', _('Always'));
|
|
o.value('on-failure', _('On failure'));
|
|
|
|
o = s.option(form.ListValue, 'network', _('Networks'));
|
|
o.rmempty = true;
|
|
this.buildNetworkListValues(network_list, o);
|
|
|
|
function not_with_a_docker_net(section_id, value) {
|
|
if (!value || value === "") return true;
|
|
const builtInNetworks = new Set(['none', 'bridge', 'host']);
|
|
let dnet = this.section.getOption('network').getUIElement(section_id).getValue();
|
|
const disallowed = builtInNetworks.has(dnet);
|
|
if (disallowed) return _('Only for user-defined networks');
|
|
return true;
|
|
};
|
|
|
|
o = s.option(form.Value, 'ipv4', _('IPv4 Address'));
|
|
o.rmempty = true;
|
|
o.datatype = 'ip4addr';
|
|
o.validate = not_with_a_docker_net;
|
|
|
|
o = s.option(form.Value, 'ipv6', _('IPv6 Address'));
|
|
o.rmempty = true;
|
|
o.datatype = 'ip6addr';
|
|
o.validate = not_with_a_docker_net;
|
|
|
|
o = s.option(form.Value, 'ipv6_lla', _('IPv6 Link-Local Address'));
|
|
o.rmempty = true;
|
|
o.datatype = 'ip6ll';
|
|
o.validate = not_with_a_docker_net;
|
|
|
|
o = s.option(form.DynamicList, 'link', _('Links with other containers'));
|
|
o.rmempty = true;
|
|
o.placeholder='container_name:alias';
|
|
|
|
o = s.option(form.DynamicList, 'dns', _('Set custom DNS servers'));
|
|
o.rmempty = true;
|
|
o.placeholder='8.8.8.8';
|
|
|
|
o = s.option(form.Value, 'user', _('User(-u)'),
|
|
_('The user that commands are run as inside the container. (format: name|uid[:group|gid])'));
|
|
o.rmempty = true;
|
|
o.placeholder='1000:1000';
|
|
|
|
o = s.option(form.DynamicList, 'env', _('Environmental Variable(-e)'),
|
|
_('Set environment variables inside the container'));
|
|
o.rmempty = true;
|
|
o.placeholder='TZ=Europe/Paris';
|
|
|
|
o = s.option(form.DummyValue, 'volume', _('Mount(--mount)'),
|
|
_('Bind mount a volume'));
|
|
o.rmempty = true;
|
|
o.cfgvalue = () => {
|
|
const c_volumes = view.map.data.get('json', 'container', 'volume') || [];
|
|
|
|
const showVolumeModal = (index, initialEntry) => {
|
|
let typeSelect, bindPicker, bindSourceField, volumeNameInput, volumeSourceField, pathInput, pathField, optionsDropdown, optionsField, subpathInput;
|
|
let tmpfsSizeInput, tmpfsModeInput, tmpfsOptsInput, tmpfsSizeField, tmpfsModeField, tmpfsOptsField;
|
|
const isEdit = index !== null;
|
|
const modalTitle = isEdit ? _('Edit Mount') : _('Add Mount');
|
|
|
|
// Parse existing entry if editing and infer type from volumes list, image, or tmpfs
|
|
let initialType = 'bind', initialSource = '', initialPath = '', initialOptions = '';
|
|
if (isEdit && initialEntry) {
|
|
const parts = (typeof initialEntry === 'string' ? initialEntry : '').split(':');
|
|
initialSource = parts[0] || '';
|
|
initialPath = parts[1] || '';
|
|
initialOptions = parts[2] || '';
|
|
// Infer type: tmpfs, volume, image, else bind
|
|
const isTmpfs = (initialSource === '@tmpfs');
|
|
const isVolume = (volume_list || []).some(v => v.Name === initialSource || v.Id === initialSource);
|
|
const isImage = (initialSource === '@image');
|
|
initialType = isTmpfs ? 'tmpfs' : (isVolume ? 'volume' : (isImage ? 'image' : 'bind'));
|
|
}
|
|
|
|
const existingOptions = (typeof initialOptions === 'string' ? initialOptions : '').split(',').map(o => o.trim()).filter(Boolean);
|
|
|
|
// Type-specific options for dropdowns
|
|
const bindOptions = {
|
|
'ro': _('Read-only (ro)'),
|
|
'rw': _('Read-write (rw)'),
|
|
'private': _('Propagation: private'),
|
|
'rprivate': _('Propagation: rprivate'),
|
|
'shared': _('Propagation: shared'),
|
|
'rshared': _('Propagation: rshared'),
|
|
'slave': _('Propagation: slave'),
|
|
'rslave': _('Propagation: rslave')
|
|
};
|
|
|
|
const volumeOptions = {
|
|
// 'ro': _('Read-only (ro)'),
|
|
// 'rw': _('Read-write (rw)'),
|
|
'nocopy': _('No copy (nocopy)')
|
|
};
|
|
|
|
const getOptionsForType = (type) => type === 'bind' ? bindOptions : volumeOptions;
|
|
|
|
const namesListId = 'volname-list-' + Math.random().toString(36).substr(2, 9);
|
|
|
|
// Create dropdown for options - updates based on type
|
|
optionsDropdown = new ui.Dropdown(existingOptions, getOptionsForType(initialType), {
|
|
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
|
|
multiple: true,
|
|
optional: true,
|
|
display_items: 2,
|
|
placeholder: _('Select options...')
|
|
});
|
|
|
|
const createField = (label, input) => {
|
|
return E('div', { 'class': 'cbi-value' }, [
|
|
E('label', { 'class': 'cbi-value-title' }, label),
|
|
E('div', { 'class': 'cbi-value-field' }, Array.isArray(input) ? input : [input])
|
|
]);
|
|
};
|
|
|
|
// Type select
|
|
const typeOptions = [
|
|
E('option', { value: 'bind' }, _('Bind (host directory)')),
|
|
E('option', { value: 'volume' }, _('Volume (named)')),
|
|
E('option', { value: 'image' }, _('Image (from image)')),
|
|
E('option', { value: 'tmpfs' }, _('Tmpfs'))
|
|
];
|
|
typeSelect = E('select', { 'class': 'cbi-input-select' }, typeOptions);
|
|
typeSelect.value = initialType;
|
|
|
|
// Bind directory picker using ui.FileUpload
|
|
bindPicker = new ui.FileUpload(initialType === 'bind' ? initialSource : '', {
|
|
browser: false,
|
|
directory_select: true,
|
|
directory_create: false,
|
|
enable_upload: false,
|
|
enable_remove: false,
|
|
enable_download: false,
|
|
root_directory: '/',
|
|
show_hidden: true
|
|
});
|
|
|
|
// Volume name input with datalist
|
|
volumeNameInput = E('input', {
|
|
'type': 'text',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('Enter volume name or pick existing'),
|
|
'list': namesListId,
|
|
'value': initialType === 'volume' ? initialSource : ''
|
|
});
|
|
volumeSourceField = createField(_('Volume Name'), [
|
|
E('div', { 'style': 'position: relative;' }, [
|
|
volumeNameInput,
|
|
E('span', { 'style': 'pointer-events: none;' }, '▼')
|
|
]),
|
|
E('datalist', { 'id': namesListId }, [
|
|
...volume_list.map(vol => E('option', { 'value': vol.Name }, vol.Name))
|
|
])
|
|
]);
|
|
|
|
// Tmpfs inputs - pre-populate if editing
|
|
let tmpfsSizeVal = '', tmpfsModeVal = '', tmpfsOptsVal = '';
|
|
if (initialType === 'tmpfs' && existingOptions.length) {
|
|
const rest = [];
|
|
existingOptions.forEach(o => {
|
|
if (o.startsWith('size=')) tmpfsSizeVal = o.slice('size='.length);
|
|
else if (o.startsWith('mode=')) tmpfsModeVal = view.modeToRwx(o.slice('mode='.length));
|
|
else rest.push(o);
|
|
});
|
|
tmpfsOptsVal = rest.join(',');
|
|
}
|
|
|
|
tmpfsSizeField = createField(_('Size'),
|
|
tmpfsSizeInput = E('input', {
|
|
'class': 'cbi-input-text',
|
|
'placeholder': '128m',
|
|
'value': tmpfsSizeVal
|
|
})
|
|
);
|
|
tmpfsModeField = createField(_('Mode'),
|
|
tmpfsModeInput = E('input', {
|
|
'class': 'cbi-input-text',
|
|
'placeholder': 'rwxr-xr-x or 1770',
|
|
'value': tmpfsModeVal
|
|
})
|
|
);
|
|
tmpfsOptsField = createField(_('tmpfs Options'),
|
|
tmpfsOptsInput = E('input', {
|
|
'type': 'text',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': 'nr_blocks=blocks,...',
|
|
'value': tmpfsOptsVal
|
|
})
|
|
);
|
|
|
|
// Render bindPicker and show modal
|
|
Promise.resolve(bindPicker.render()).then(bindPickerNode => {
|
|
bindSourceField = createField(_('Host Directory'), bindPickerNode);
|
|
|
|
const updateOptions = (selectedType) => {
|
|
optionsField.querySelector('.cbi-value-field').innerHTML = '';
|
|
if (selectedType === 'image') {
|
|
// For image mounts, show a Subpath text input (only option)
|
|
subpathInput = E('input', {
|
|
'type': 'text',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('/path/in/image'),
|
|
'value': (initialType === 'image' && existingOptions.find(o => o.startsWith('subpath='))) ? existingOptions.find(o => o.startsWith('subpath=')).slice('subpath='.length) : ''
|
|
});
|
|
optionsField.querySelector('.cbi-value-title').textContent = _('Subpath');
|
|
optionsField.querySelector('.cbi-value-field').appendChild(subpathInput);
|
|
} else if (selectedType === 'tmpfs') {
|
|
// Tmpfs fields are shown as main fields, hide options field
|
|
optionsField.style.display = 'none';
|
|
} else {
|
|
optionsField.querySelector('.cbi-value-title').textContent = _('Options');
|
|
// Recreate dropdown with new options
|
|
const currentValue = optionsDropdown.getValue();
|
|
optionsDropdown = new ui.Dropdown(currentValue, getOptionsForType(selectedType), {
|
|
id: 'mount-options-' + Math.random().toString(36).substr(2, 9),
|
|
multiple: true,
|
|
optional: true,
|
|
display_items: 2,
|
|
placeholder: _('Select options...')
|
|
});
|
|
optionsField.querySelector('.cbi-value-field').appendChild(optionsDropdown.render());
|
|
optionsField.style.display = '';
|
|
}
|
|
};
|
|
|
|
const toggleSources = () => {
|
|
const isBind = typeSelect.value === 'bind';
|
|
const isVolume = typeSelect.value === 'volume';
|
|
const isImage = typeSelect.value === 'image';
|
|
const isTmpfs = typeSelect.value === 'tmpfs';
|
|
bindSourceField.style.display = isBind ? '' : 'none';
|
|
volumeSourceField.style.display = isVolume ? '' : 'none';
|
|
pathField.style.display = isImage ? 'none' : '';
|
|
tmpfsSizeField.style.display = isTmpfs ? '' : 'none';
|
|
tmpfsModeField.style.display = isTmpfs ? '' : 'none';
|
|
tmpfsOptsField.style.display = isTmpfs ? '' : 'none';
|
|
updateOptions(typeSelect.value);
|
|
};
|
|
|
|
optionsField = createField(_('Options'), optionsDropdown.render());
|
|
|
|
ui.showModal(modalTitle, [
|
|
createField(_('Type'), typeSelect),
|
|
bindSourceField,
|
|
volumeSourceField,
|
|
pathField = createField(_('Mount Path'),
|
|
pathInput = E('input', {
|
|
'type': 'text',
|
|
'class': 'cbi-input-text',
|
|
'placeholder': _('/mnt/path'),
|
|
'value': initialPath
|
|
})
|
|
),
|
|
tmpfsSizeField,
|
|
tmpfsModeField,
|
|
tmpfsOptsField,
|
|
optionsField,
|
|
E('div', { 'class': 'right' }, [
|
|
E('button', {
|
|
'class': 'cbi-button',
|
|
'click': ui.hideModal
|
|
}, [_('Cancel')]),
|
|
' ',
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-positive',
|
|
'click': ui.createHandlerFn(view, () => {
|
|
const selectedType = typeSelect.value;
|
|
const sourcePath = selectedType === 'bind'
|
|
? (bindPicker.getValue() || '').trim()
|
|
: (selectedType === 'volume'
|
|
? (volumeNameInput.value || '').trim()
|
|
: (selectedType === 'tmpfs' ? '@tmpfs' : '@image'));
|
|
const subpathVal = (selectedType === 'image') ? (subpathInput?.value || '').trim() : '';
|
|
const mountPath = (selectedType === 'image') ? subpathVal : pathInput.value.trim();
|
|
let selectedOptions;
|
|
if (selectedType === 'image') {
|
|
selectedOptions = subpathVal ? ('subpath=' + subpathVal) : '';
|
|
} else if (selectedType === 'tmpfs') {
|
|
const opts = [];
|
|
const sizeValRaw = (tmpfsSizeInput?.value || '').trim();
|
|
const modeValRaw = (tmpfsModeInput?.value || '').trim();
|
|
const extraVal = (tmpfsOptsInput?.value || '').trim();
|
|
const parsedSize = sizeValRaw ? view.parseMemory(sizeValRaw) : undefined;
|
|
const parsedMode = view.rwxToMode(modeValRaw);
|
|
if (parsedSize) opts.push('size=' + parsedSize);
|
|
else if (sizeValRaw) opts.push('size=' + sizeValRaw); // fallback if parse fails
|
|
if (parsedMode !== undefined) opts.push('mode=' + parsedMode);
|
|
if (extraVal) opts.push(...extraVal.split(',').map(o => o.trim()).filter(Boolean));
|
|
selectedOptions = opts.join(',');
|
|
} else {
|
|
selectedOptions = optionsDropdown.getValue().join(',');
|
|
}
|
|
|
|
if (!sourcePath) {
|
|
ui.addTimeLimitedNotification(null, [_('Please choose a directory or enter a volume name')], 3000, 'warning');
|
|
return;
|
|
}
|
|
|
|
if (selectedType !== 'image' && !mountPath) {
|
|
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
|
|
return;
|
|
}
|
|
if (selectedType === 'image' && !subpathVal) {
|
|
ui.addTimeLimitedNotification(null, [_('Please enter a subpath')], 3000, 'warning');
|
|
return;
|
|
}
|
|
if (selectedType === 'tmpfs' && !mountPath) {
|
|
ui.addTimeLimitedNotification(null, [_('Please enter a mount path')], 3000, 'warning');
|
|
return;
|
|
}
|
|
|
|
ui.hideModal();
|
|
|
|
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
|
|
const volumeEntry = selectedOptions ? (sourcePath + ':' + mountPath + ':' + selectedOptions) : (sourcePath + ':' + mountPath);
|
|
let updatedVolumes;
|
|
if (isEdit) {
|
|
updatedVolumes = [...currentVolumes];
|
|
updatedVolumes[index] = volumeEntry;
|
|
} else {
|
|
updatedVolumes = Array.isArray(currentVolumes) ? [...currentVolumes, volumeEntry] : [volumeEntry];
|
|
}
|
|
view.map.data.set('json', 'container', 'volume', updatedVolumes);
|
|
|
|
return view.map.render();
|
|
})
|
|
}, [isEdit ? _('Update') : _('Add')])
|
|
])
|
|
]);
|
|
|
|
toggleSources();
|
|
typeSelect.addEventListener('change', toggleSources);
|
|
});
|
|
};
|
|
|
|
|
|
return E('div', { 'class': 'cbi-dynlist' }, [
|
|
...(c_volumes.length > 0 ? c_volumes.map((v, idx) => E('div', {
|
|
'class': 'cbi-dynlist-item',
|
|
'style': 'display: flex; justify-content: space-between; align-items: center; padding: 8px 5px; margin-bottom: 8px; gap: 10px;'
|
|
}, [
|
|
E('span', {
|
|
'style': 'cursor: pointer; flex: 1;',
|
|
'click': ui.createHandlerFn(view, () => {
|
|
showVolumeModal(idx, v);
|
|
})
|
|
}, v),
|
|
E('button', {
|
|
'style': 'padding: 5px; color: #c44;',
|
|
'class': 'cbi-button-negative remove',
|
|
'title': _('Delete this volume mount'),
|
|
'click': ui.createHandlerFn(view, () => {
|
|
const currentVolumes = view.map.data.get('json', 'container', 'volume') || [];
|
|
const updatedVolumes = currentVolumes.filter((_, i) => i !== idx);
|
|
view.map.data.set('json', 'container', 'volume', updatedVolumes);
|
|
return view.map.render();
|
|
})
|
|
}, ['✕'])
|
|
])) : [E('div', { 'style': 'padding: 5px; color: #999;' }, _('No volumes available'))]),
|
|
E('button', {
|
|
'class': 'cbi-button',
|
|
'click': ui.createHandlerFn(view, () => {
|
|
showVolumeModal(null, null);
|
|
})
|
|
}, [_('Add Mount')])
|
|
]);
|
|
};
|
|
o.rmempty = true;
|
|
|
|
o = s.option(form.DynamicList, 'publish', _('Exposed Ports(-p)'),
|
|
_("Publish container's port(s) to the host"));
|
|
o.rmempty = true;
|
|
o.placeholder='2200:22/tcp';
|
|
|
|
o = s.option(form.Value, 'command', _('Run command'));
|
|
o.rmempty = true;
|
|
o.placeholder='/bin/sh init.sh';
|
|
|
|
o = s.option(form.Flag, 'advanced', _('Advanced'));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
|
|
o = s.option(form.Value, 'hostname', _('Host Name'),
|
|
_('The hostname to use for the container'));
|
|
o.rmempty = true;
|
|
o.placeholder='/bin/sh init.sh';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.Flag, 'publish_all', _('Exposed All Ports(-P)'),
|
|
_("Allocates an ephemeral host port for all of a container's exposed ports"));
|
|
o.rmempty = true;
|
|
o.disabled = 0;
|
|
o.enabled = 1;
|
|
o.default = 0;
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.DynamicList, 'device', _('Device(--device)'),
|
|
_('Add host device to the container'));
|
|
o.rmempty = true;
|
|
o.placeholder='/dev/sda:/dev/xvdc:rwm';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.DynamicList, 'tmpfs', _('Tmpfs(--tmpfs)'),
|
|
_('Mount tmpfs directory'));
|
|
o.rmempty = true;
|
|
o.placeholder='/run:rw,noexec,nosuid,size=65536k';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.DynamicList, 'sysctl', _('Sysctl(--sysctl)'),
|
|
_('Sysctls (kernel parameters) options'));
|
|
o.rmempty = true;
|
|
o.placeholder='net.ipv4.ip_forward=1';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.DynamicList, 'cap_add', _('CAP-ADD(--cap-add)'),
|
|
_('A list of kernel capabilities to add to the container'));
|
|
o.rmempty = true;
|
|
o.placeholder='NET_ADMIN';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.Value, 'cpus', _('CPUs'),
|
|
_('Number of CPUs. Number is a fractional number. 0.000 means no limit'));
|
|
o.rmempty = true;
|
|
o.placeholder='1.5';
|
|
o.datatype = 'ufloat';
|
|
o.depends('advanced', 1);
|
|
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 = s.option(form.Value, 'cpu_period', _('CPU Period'),
|
|
_('The length of a CPU period in microseconds'));
|
|
o.rmempty = true;
|
|
o.datatype = 'or(and(uinteger,min(1000),max(1000000)),"0")';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.Value, 'cpu_quota', _('CPU Quota'),
|
|
_('Microseconds of CPU time that the container can get in a CPU period'));
|
|
o.rmempty = true;
|
|
o.datatype = 'uinteger';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.Value, 'cpu_shares', _('CPU Shares Weight'),
|
|
_('CPU shares relative weight, if 0 is set, the system will ignore the value and use the default of 1024'));
|
|
o.rmempty = true;
|
|
o.placeholder='1024';
|
|
o.datatype = 'uinteger';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.Value, 'memory', _('Memory'),
|
|
_('Memory limit (format: <number>[<unit>]). Number is a positive integer. Unit can be one of b, k, m, or g. Minimum is 4M'));
|
|
o.rmempty = true;
|
|
o.placeholder = '128m';
|
|
o.depends('advanced', 1);
|
|
o.write = function(section_id, value) {
|
|
if (!value || value == 0) return 0;
|
|
this.map.data.data[section_id].memory = view.parseMemory(value);;
|
|
return view.parseMemory(value);
|
|
};
|
|
o.validate = function(section_id, value) {
|
|
if (!value) return true;
|
|
if (value > view.memory) return _(`Only ${view.memory} bytes available`);
|
|
return true;
|
|
};
|
|
|
|
o = s.option(form.Value, 'memory_reservation', _('Memory Reservation'));
|
|
o.depends('advanced', 1);
|
|
o.placeholder = '128m';
|
|
o.cfgvalue = (sid, val) => {
|
|
const res = view.map.data.data[sid].memory_reservation;
|
|
return res ? '%1024.2m'.format(res) : 0;
|
|
};
|
|
o.write = function(section_id, value) {
|
|
if (!value || value == 0) return 0;
|
|
this.map.data.data[section_id].memory_reservation = view.parseMemory(value);;
|
|
return view.parseMemory(value);
|
|
};
|
|
|
|
o = s.option(form.Value, 'blkio_weight', _('Block IO Weight'),
|
|
_('Block IO weight (relative weight) accepts a weight value between 10 and 1000.'));
|
|
o.rmempty = true;
|
|
o.placeholder='500';
|
|
o.datatype = 'and(uinteger,min(10),max(1000))';
|
|
o.depends('advanced', 1);
|
|
|
|
o = s.option(form.DynamicList, 'log_opt', _('Log driver options'),
|
|
_('The logging configuration for this container'));
|
|
o.rmempty = true;
|
|
o.placeholder='max-size=1m';
|
|
o.depends('advanced', 1);
|
|
|
|
|
|
this.map = m;
|
|
|
|
return m.render();
|
|
|
|
},
|
|
|
|
handleSave(ev) {
|
|
ev?.preventDefault();
|
|
const view = this; // Capture the view context
|
|
const map = this.map;
|
|
if (!map)
|
|
return Promise.reject(new Error(_('Form is not ready yet.')));
|
|
|
|
const listToKv = view.listToKv;
|
|
|
|
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;
|
|
|
|
return map.parse()
|
|
.then(() => {
|
|
const get = (opt) => map.data.get('json', 'container', opt);
|
|
const name = get('name');
|
|
// const pull = toBool(get('pull'));
|
|
const network = get('network');
|
|
const publish = get('publish');
|
|
const command = get('command');
|
|
// const publish_all = toBool(get('publish_all'));
|
|
const device = get('device');
|
|
const tmpfs = get('tmpfs');
|
|
const sysctl = get('sysctl');
|
|
const log_opt = get('log_opt');
|
|
|
|
const createBody = {
|
|
Hostname: get('hostname'),
|
|
User: get('user'),
|
|
AttachStdin: toBool(get('interactive')),
|
|
Tty: toBool(get('tty')),
|
|
OpenStdin: toBool(get('interactive')),
|
|
Env: get('env'),
|
|
Cmd: command ? command.split(' ') : null,
|
|
Image: get('image'),
|
|
HostConfig: {
|
|
CpuShares: toInt(get('cpu_shares')),
|
|
Memory: toInt(get('memory')),
|
|
MemoryReservation: toInt(get('memory_reservation')),
|
|
BlkioWeight: toInt(get('blkio_weight')),
|
|
CapAdd: get('cap_add'),
|
|
CpuPeriod: toInt(get('cpu_period')),
|
|
CpuQuota: toInt(get('cpu_quota')),
|
|
NanoCPUs: toFloat(get('cpus')) * (10 ** 9),
|
|
Devices: device ? device
|
|
.filter(d => d && typeof d === 'string' && d.trim().length > 0)
|
|
.map(d => {
|
|
const parts = d.split(':');
|
|
return {
|
|
PathOnHost: parts[0],
|
|
PathInContainer: parts[1] || parts[0],
|
|
CgroupPermissions: parts[2] || 'rwm'
|
|
};
|
|
}) : undefined,
|
|
LogConfig: log_opt ? {
|
|
Type: 'json-file',
|
|
Config: listToKv(log_opt)
|
|
} : undefined,
|
|
NetworkMode: network,
|
|
PortBindings: publish ? Object.fromEntries(
|
|
(Array.isArray(publish) ? publish : [publish])
|
|
.filter(p => p && typeof p === 'string' && p.trim().length > 0)
|
|
.map(p => {
|
|
const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
|
|
if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
|
|
return null;
|
|
}).filter(Boolean)
|
|
) : undefined,
|
|
Mounts: undefined,
|
|
Links: get('link'),
|
|
Privileged: toBool(get('privileged')),
|
|
PublishAllPorts: toBool(get('publish_all')),
|
|
RestartPolicy: { Name: get('restart_policy') },
|
|
Dns: get('dns'),
|
|
Tmpfs: tmpfs ? Object.fromEntries(
|
|
(Array.isArray(tmpfs) ? tmpfs : [tmpfs])
|
|
.filter(t => t && typeof t === 'string' && t.trim().length > 0)
|
|
.map(t => {
|
|
const parts = t.split(':');
|
|
return [parts[0], parts[1] || ''];
|
|
})
|
|
) : undefined,
|
|
Sysctls: sysctl ? listToKv(sysctl) : undefined,
|
|
},
|
|
NetworkingConfig: {
|
|
EndpointsConfig: { [network]: { IPAMConfig: { IPv4Address: get('ipv4') || null, IPv6Address: get('ipv6') || null } } },
|
|
}
|
|
};
|
|
|
|
// Parse volume entries and populate Mounts
|
|
const volumeEntries = get('volume') || [];
|
|
const volumeNames = new Set((view.volumes || []).map(v => v.Name));
|
|
const volumeIds = new Set((view.volumes || []).map(v => v.Id));
|
|
const mounts = [];
|
|
for (const entry of volumeEntries) {
|
|
let e = typeof entry === 'string' ? entry : '';
|
|
let f = e.split(':')?.map(e => e && e.trim() || '');
|
|
let source = f[0];
|
|
let target = f[1];
|
|
let options = f[2];
|
|
|
|
if (!options) options = '';
|
|
|
|
// Validate source and target are not empty
|
|
if (!source || !target) {
|
|
console.warn('Invalid volume entry (empty source or target):', entry);
|
|
continue;
|
|
}
|
|
|
|
// Infer type: '@image' => image; '@tmpfs' => tmpfs; volume by name/id; else bind
|
|
let type = 'bind';
|
|
if (source === '@image') {
|
|
type = 'image';
|
|
} else if (source === '@tmpfs') {
|
|
type = 'tmpfs';
|
|
} else if (volumeNames.has(source) || volumeIds.has(source)) {
|
|
type = 'volume';
|
|
}
|
|
|
|
const mount = {
|
|
Type: type,
|
|
Source: source,
|
|
Target: target,
|
|
ReadOnly: options.split(',').includes('ro')
|
|
};
|
|
|
|
// Add type-specific options
|
|
if (type === 'bind') {
|
|
const bindOptions = {};
|
|
const propagation = options.split(',').find(opt =>
|
|
['rprivate', 'private', 'rshared', 'shared', 'rslave', 'slave'].includes(opt)
|
|
);
|
|
if (propagation) bindOptions.Propagation = propagation;
|
|
if (Object.keys(bindOptions).length > 0) mount.BindOptions = bindOptions;
|
|
} else if (type === 'volume') {
|
|
const volumeOptions = {};
|
|
if (options.includes('nocopy')) volumeOptions.NoCopy = true;
|
|
if (Object.keys(volumeOptions).length > 0) mount.VolumeOptions = volumeOptions;
|
|
} else if (type === 'image') {
|
|
const imageOptions = {};
|
|
const subpathOpt = options.split(',').find(opt => opt.startsWith('subpath='));
|
|
if (subpathOpt) imageOptions.Subpath = subpathOpt.slice('subpath='.length);
|
|
if (Object.keys(imageOptions).length > 0) mount.ImageOptions = imageOptions;
|
|
// Image source is implied by selected container image
|
|
mount.Source = createBody.Image;
|
|
} else if (type === 'tmpfs') {
|
|
const tmpfsOptions = {};
|
|
const optsList = options.split(',').map(o => o.trim()).filter(Boolean);
|
|
for (const opt of optsList) {
|
|
if (opt.startsWith('size=')) tmpfsOptions.SizeBytes = toInt(opt.slice('size='.length));
|
|
else if (opt.startsWith('mode=')) tmpfsOptions.Mode = toInt(opt.slice('mode='.length));
|
|
else {
|
|
if (!tmpfsOptions.Options) tmpfsOptions.Options = [];
|
|
const kv = opt.split('=');
|
|
if (kv.length === 2) tmpfsOptions.Options.push([kv[0], kv[1]]);
|
|
else if (kv.length === 1) tmpfsOptions.Options.push([kv[0]]);
|
|
}
|
|
}
|
|
mount.Source = '';
|
|
if (Object.keys(tmpfsOptions).length > 0) mount.TmpfsOptions = tmpfsOptions;
|
|
}
|
|
|
|
mounts.push(mount);
|
|
}
|
|
createBody.HostConfig.Mounts = mounts.length > 0 ? mounts : undefined;
|
|
|
|
// Clean up undefined values
|
|
Object.keys(createBody.HostConfig).forEach(key => {
|
|
if (createBody.HostConfig[key] === undefined)
|
|
delete createBody.HostConfig[key];
|
|
});
|
|
|
|
if (!name)
|
|
return Promise.reject(new Error(_('No name specified.')));
|
|
|
|
return { name, createBody };
|
|
})
|
|
.then(({ name, createBody }) => view.executeDockerAction(
|
|
dm2.container_create,
|
|
{ query: { name: name }, body: createBody },
|
|
_('Create container'),
|
|
{
|
|
showOutput: false,
|
|
showSuccess: false,
|
|
onSuccess: (response) => {
|
|
const isDuplicate = view.isDuplicate && view.duplicateContainer;
|
|
const msgTitle = isDuplicate ? _('Container duplicated') : _('Container created');
|
|
const msgText = isDuplicate ?
|
|
_('New container duplicated from ') + view.duplicateContainer.Name?.substring(1) :
|
|
_('New container has been created.');
|
|
|
|
if (response?.body?.Warnings) {
|
|
view.showNotification(msgTitle + _(' with warnings'), response?.body?.Warning || msgText, 5000, 'warning');
|
|
} else {
|
|
view.showNotification(msgTitle, msgText, 4000, 'success');
|
|
}
|
|
window.location.href = `${this.dockerman_url}/containers`;
|
|
}
|
|
}
|
|
))
|
|
.catch((err) => {
|
|
view.showNotification(_('Create container failed'), err?.message || String(err), 7000, 'error');
|
|
return false;
|
|
});
|
|
},
|
|
|
|
handleSaveApply: null,
|
|
handleReset: null,
|
|
|
|
});
|