💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

@@ -0,0 +1,326 @@
'use strict';
'require baseclass';
'require dom';
'require form';
'require ui';
'require podman.form as podmanForm';
'require podman.ui as podmanUI';
'require podman.utils as podmanUtil';
const FormTableSelectSection = form.TableSection.extend({
__name__: 'Podman.Form.TableSelectSection',
checkboxColumn: null,
view: null,
toolbarExtraButtons: [],
modalCreateBtnText: null,
modalCreateTitle: null,
handleRefreshCb: null,
render() {
if (!this.checkboxColumn) {
this.checkboxColumn = this.option(
podmanForm.field.SelectDummyValue,
this.selectKey,
new ui.Checkbox(0, { hiddenname: 'all' }).render()
);
this.checkboxColumn.width = 30;
podmanUtil.moveArrayItem(this.children, this.children.length - 1, 0);
}
return this.super('render', []);
},
renderContents(cfgsections, nodes) {
const config_name = this.uciconfig ?? this.map.config;
const max_cols = this.children.length;
if (this.footer === true) {
this.footer = [
`${_('Total')} ${nodes.length}`,
];
}
const sectionEl = E('div', {
id: 'cbi-%s-%s'.format(config_name, this.sectiontype),
class: 'cbi-section cbi-tblsection',
});
const tableEl = E('table', { class: 'table cbi-section-table cbi-section-table-select' });
const theadEl = E('thead', { class: 'thead cbi-section-thead' });
const tbodyEl = E('tbody', { class: 'tbody cbi-section-tbody' });
const tfootEl = E('tfoot', { class: 'tfoot cbi-section-tfoot' });
if (this.description != null && this.description !== '') {
sectionEl.appendChild(E('div', { class: 'cbi-section-descr' }, this.description));
}
sectionEl.appendChild(E('div', { class: 'cbi-section-actions' }, this.renderToolbar()));
theadEl.appendChild(this.renderHeaderRows(false));
if (theadEl.hasChildNodes())
tableEl.appendChild(theadEl);
for (let i = 0; i < nodes.length; i++) {
const trEl = E('tr', {
id: 'cbi-%s-%s'.format(config_name, cfgsections[i]),
class: 'tr cbi-section-table-row',
'data-sid': cfgsections[i],
'data-section-id': cfgsections[i]
});
for (let j = 0; j < max_cols && nodes[i].firstChild; j++) {
trEl.appendChild(nodes[i].firstChild);
}
tbodyEl.appendChild(trEl);
}
if (nodes.length === 0)
tbodyEl.appendChild(E('tr', { class: 'tr cbi-section-table-row' },
E('td', { class: 'td text-center', colspan: max_cols }, this.renderSectionPlaceholder())));
tableEl.appendChild(tbodyEl);
tfootEl.appendChild(this.renderFooterRows(false));
if (tfootEl.hasChildNodes()) {
tableEl.appendChild(tfootEl);
}
this.registerCheckboxEvents(theadEl, tableEl);
sectionEl.appendChild(tableEl);
dom.bindClassInstance(sectionEl, this);
return sectionEl;
},
renderToolbar() {
return E('div', { class: 'list-toolbar mb-sm d-flex justify-center gap-xs' }, this.getToolbarButtons());
},
getToolbarButtons() {
const buttons = [];
if (this.handleCreate) {
buttons.push(this.getCreateButton());
}
if (this.handleRemove) {
buttons.push(this.getRemoveButton());
}
if (this.handleRefresh) {
buttons.push(this.getReloadButton());
}
if (this.toolbarExtraButtons.length > 0) {
buttons.push(...this.toolbarExtraButtons);
}
return buttons;
},
getCreateButton() {
return new podmanUI.ButtonNew(_('Create'), {
click: ui.createHandlerFn(this, 'handleCreate'),
type: 'add',
}).render();
},
getRemoveButton() {
return new podmanUI.ButtonNew(_('Delete'), {
click: ui.createHandlerFn(this, 'handleRemove'),
type: 'remove',
}).render();
},
getReloadButton() {
return new podmanUI.ButtonNew(_('Reload'), {
click: ui.createHandlerFn(this, 'handleRefresh'),
type: 'apply',
}).render();
},
async handleCreate() {
const createForm = new this.createForm.init();
const formElement = await createForm.render();
const title = this.modalCreateTitle || _('Create %s').format(this.view.titleSingle || '').trim();
const modal = new podmanUI.Modal(title, [ formElement ]);
modal.getButtons = () => [
modal.getCloseButton(),
new podmanUI.ButtonNew(this.modalCreateBtnText || _('Create'), {
click: () => {
createForm.handleCreate().then(() => this.handleRefresh());
},
type: 'positive',
}).render(),
];
modal.render();
},
async handleRemove() {
const selected = this.getSelectedData();
if (selected.length === 0) {
return this.showNoneSelectedWarning();
}
this.view.confirm([
E('p', {}, _('Are you sure to delete the records?')),
], async () => {
let i = 1;
for (const item of selected) {
this.view.loading(_('Deleting records: %d/%d').format(i, selected.length));
await item.remove();
i++;
}
ui.hideModal();
this.handleRefresh();
});
},
async handleRefresh() {
const indicatorId = 'podman-refresh-' + this.sectiontype;
ui.showIndicator(indicatorId, _('Refreshing %s...').format(this.sectiontype));
const data = await this.view.load();
if (this.footer) {
const count = Array.isArray(data) ? data.length : 0;
this.footer = [`${_('Total')}&nbsp;${count}`];
}
const obj = {};
obj[this.sectiontype] = data;
this.map.data.__init__(obj);
this.map.root
.querySelector('input[type="hidden"][name="all"] ~ input[type=checkbox]').checked = false;
await this.map.save(null, false);
await this.map.load();
await this.map.reset();
if (this.handleRefreshCb) await this.handleRefreshCb();
ui.hideIndicator(indicatorId);
},
handleInspect(item, hiddenFields) {
this.view.loading(_('Fetching information...'));
item.inspect().then((data) => {
ui.hideModal();
this.showInspectModal(data, hiddenFields);
});
},
showInspectModal(data, hiddenFields) {
const displayData = JSON.parse(JSON.stringify(data));
if (hiddenFields && hiddenFields.length > 0) {
hiddenFields.forEach((field) => {
if (displayData[field]) {
displayData[field] = '***HIDDEN***';
}
});
}
const content = [
new podmanUI.JsonArea(displayData).render(),
];
if (hiddenFields && hiddenFields.length > 0) {
content.unshift(E('p', { class: 'mb-sm text-error' }, [
E('strong', {}, _('Security Notice:')), ' ', _('Sensitive data is hidden for security reasons.')
]));
}
const modal = new podmanUI.Modal('', content);
modal.handleSubmit = undefined;
modal.render();
},
showNoneSelectedWarning() {
this.view.warning(_('No %s selected').format(this.sectiontype));
},
registerCheckboxEvents(theadEl, tableEl) {
const checkboxSelectAll = theadEl.querySelector('input[name="all"] + input[type="checkbox"]');
const rows = tableEl.querySelectorAll('tbody tr');
let lastClickedIndex = -1;
if (!checkboxSelectAll) {
return;
}
checkboxSelectAll.addEventListener('click', (event) => {
const checked = event.target.checked;
tableEl.querySelectorAll('tbody tr input[type="checkbox"]').forEach((checkbox) => {
checkbox.checked = checked;
});
});
rows.forEach((row, index) => {
const checkbox = row.querySelector('input[type=checkbox]');
if (!checkbox) {
return;
}
row.addEventListener('click', (ev) => {
const clickedInteractiveElement = ev.target.closest('button, a, input, .cbi-tooltip-container');
// If the click was inside an interactive element, ignore it and exit.
if (clickedInteractiveElement) {
return;
}
checkbox.click();
});
checkbox.addEventListener('click', (ev) => {
// Handle shift+click for range selection
if (ev.shiftKey && lastClickedIndex !== -1 &&
lastClickedIndex !== index) {
const start = Math.min(lastClickedIndex, index);
const end = Math.max(lastClickedIndex, index);
const targetState = checkbox.checked;
// Select/deselect all checkboxes in range
for (let i = start; i <= end; i++) {
rows[i].querySelector('input[type=checkbox]').checked = targetState;
}
}
// Update last clicked index
lastClickedIndex = index;
});
});
},
getSelectedCheckboxes() {
return this.map.root.querySelectorAll('tbody tr input[type=checkbox]:checked');
},
getSelectedData() {
return [...this.getSelectedCheckboxes()].map((checkbox) => {
const sectionId = checkbox.previousSibling.name;
return this.map.data.data[sectionId];
});
}
});
return baseclass.extend({
TableSelectSection: FormTableSelectSection
});
@@ -0,0 +1,22 @@
'use strict';
'require baseclass';
const NOTIFICATION_TIMEOUT = 2000;
const ICON = {
START: '►',
STOP: '◼',
RESTART: '↻',
PAUSE: '❚❚',
CLEAR_LOG: '🗑️',
INIT_ENABLED: '✓',
INIT_MISSING: '⚠',
INIT_DISABLED: '⏼',
BACK: '🔙',
};
return baseclass.extend({
NOTIFICATION_TIMEOUT,
ICON,
});
@@ -0,0 +1,156 @@
'use strict';
'require baseclass';
'require ui';
'require form';
'require podman.ui as podmanUI';
'require podman.utils as podmanUtil';
const FormMemoryValue = form.Value.extend({
placeholder: '512m, 1g, -1',
validate(_section_id, value) {
if (!value) return true;
if (value === '-1' || value === '0') return true;
if (!/^\d+(?:\.\d+)?\s*[kmg]b?$/i.test(value)) {
return _('Expecting: %s').format(_('512m, 1g, or -1 for unlimited'));
}
return true;
},
});
/**
*
*/
const FormDummyValue = form.DummyValue.extend({
__name__: 'Podman.Form.DummyValue',
cfgformatter: (cfg, _data) => cfg,
cfgdatavalue: (_data) => '',
cfgtt: (_cfg, _data) => '',
cfgvalue(section_id, set_value) {
if (section_id == null)
L.error('TypeError', 'Section ID required');
const data = this.map.data.data[section_id];
const dataValue = this.cfgdatavalue(data);
if (dataValue) {
return this._renderValue(dataValue, data);
}
if (arguments.length === 2 && set_value) {
this.data ??= {};
this.data[section_id] = set_value;
}
return this._renderValue(this.data?.[section_id], data);
},
_renderValue(value, data) {
const tooltip = this.cfgtt(value, data);
const formatted = this.cfgformatter(value, data);
return tooltip ? new podmanUI.Tooltip(formatted, tooltip).render() : formatted;
},
});
/**
*
*/
const FormSelectDummyValue = form.DummyValue.extend({
__name__: 'Podman.Form.SelectDummyValue',
cfgvalue(sectionId) {
return new ui.Checkbox(0, { hiddenname: sectionId }).render();
}
});
/**
*
*/
const FormLinkDummyValue = FormDummyValue.extend({
__name__: 'Podman.Form.LinkDummyValue',
url: '#',
click: undefined,
cfgformatter(cfg, model) {
return E('a', {
class: 'text-bold',
href: this.url,
click: (ev) => {
if (typeof this.click === 'function') {
ev.preventDefault();
this.click(cfg, model);
}
}
}, cfg);
}
});
/**
*
*/
const FormDateDummyValue = FormDummyValue.extend({
__name__: 'Podman.Form.DateDummyValue',
cfgformatter: podmanUtil.format.date,
});
/**
*
*/
const FormTimestampDummyValue = FormDateDummyValue.extend({
__name__: 'Podman.Form.TimestampDummyValue',
cfgformatter: (value) => podmanUtil.format.date(parseInt(value)),
});
/**
*
*/
const FormByteDummyValue = FormDummyValue.extend({
__name__: 'Podman.Form.ByteDummyValue',
cfgformatter: podmanUtil.format.bytes,
});
const FormEditableField = baseclass.extend({
__name__: 'Podman.Form.EditableField',
name: null,
formField: null,
__init__(name, formField, onSubmit) {
this.name = name;
this.formField = formField;
if (onSubmit) {
this.onSubmit = onSubmit;
}
},
render() {
return E('div', { class: `editable-field editable-field-${this.name}` }, [
this.formField,
new podmanUI.Button(
_('Update'),
() => this.onSubmit(document.querySelector(`.editable-field-${this.name} [name="${this.name}"]`).value),
'apply'
).render()
]);
},
onSubmit(_value) {}
});
return baseclass.extend({
__name__: 'Podman.Form',
EditableField: FormEditableField,
field: {
MemoryValue: FormMemoryValue,
DummyValue: FormDummyValue,
ByteDummyValue: FormByteDummyValue,
DateDummyValue: FormDateDummyValue,
LinkDummyValue: FormLinkDummyValue,
SelectDummyValue: FormSelectDummyValue,
TimestampDummyValue: FormTimestampDummyValue,
},
});
@@ -0,0 +1,337 @@
'use strict';
'require baseclass';
'require form';
'require podman.form as podmanForm';
'require podman.utils as podmanUtil';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
'require podman.model.Container as Container';
/**
* Create podman container
*/
const PodmanFormContainer = podmanView.form.extend({
__name__: 'Podman.Form.Container',
makeData() {
return {
container: {
name: '',
image: '',
pod: '',
command: null,
ports: null,
env: null,
volumes: null,
network: 'bridge',
restart: 'no',
privileged: '0',
tty: '0',
remove: '0',
autoupdate: '0',
start: '0',
workdir: null,
hostname: null,
user: null,
groups: null,
expose: null,
labels: null,
cpus: null,
memory: null,
}
};
},
async createForm() {
const [images, networks, pods] = await Promise.all([
podmanRPC.images.list(),
podmanRPC.networks.list(),
podmanRPC.pods.list()
]);
let field;
field = this.section.option(form.Value, 'name', _('Container Name'));
field.placeholder = 'my-container';
field.optional = true;
field.datatype = 'maxlength(253)';
field.description = _('Leave empty to auto-generate');
field = this.section.option(form.ListValue, 'image', _('Image'));
field.value('', _('-- Select %s --').format(_('Image')));
images.forEach((img) => {
img.getRepoTags().forEach((tag) => {
if (tag !== '<none>:<none>') {
field.value(tag, tag);
}
});
});
field.rmempty = false;
field.optional = false;
field.validate = (_section_id, value) => {
if (!value) {
return _('Expecting: %s').format(_('non-empty value'));
}
return true;
};
field.description = _('Container image to use');
field = this.section.option(form.ListValue, 'pod', _('Pod'));
field.value('', _('(none)'));
pods.forEach((pod) => {
const name = pod.getName();
if (name && name !== _('Unknown')) {
field.value(name, name);
}
});
field.description = _('Add the container to an existing pod. Network, hostname and port mappings come from the pod\'s infra container.');
field = this.section.option(form.Value, 'command', _('Command'));
field.placeholder = '/bin/sh';
field.optional = true;
field.description = _('Command to run (space-separated)');
field = this.section.option(form.TextValue, 'ports', _('Port Mappings'));
field.depends('pod', '');
field.placeholder = '8080:80\n8443:443';
field.rows = 3;
field.optional = true;
field.description = _('One per line, format: host:container[/protocol]');
field = this.section.option(form.Value, 'expose', _('Expose Ports'));
field.depends('pod', '');
field.placeholder = '6052, 8080/udp';
field.optional = true;
field.description = _('Comma-separated ports to expose (e.g., 6052, 8080/udp)');
field = this.section.option(form.TextValue, 'env', _('Environment Variables'));
field.placeholder = 'VAR1=value1\nVAR2=value2';
field.rows = 4;
field.optional = true;
field.description = _('One per line, format: key=value');
field = this.section.option(form.TextValue, 'volumes', _('Volumes'));
field.placeholder = '/host/path:/container/path:ro\nvolume-name:/data';
field.rows = 4;
field.optional = true;
field.description = _('One per line. Format: source:destination[:options]. Options: ro, rw, Z, z');
field = this.section.option(form.ListValue, 'network', _('Network'));
field.depends('pod', '');
field.value('bridge', 'bridge (default)');
field.value('host', 'host');
field.value('none', 'none');
networks.forEach((net) => {
const name = net.getName();
if (name && name !== 'bridge' && name !== 'host' && name !== 'none') {
field.value(name, name);
}
});
field.rmempty = false;
field.description = _(
'Select network for the new container. User-created networks provide better isolation and DNS resolution between containers.'
);
field = this.section.option(form.ListValue, 'restart', _('Restart Policy'));
field.value('no', _('No'));
field.value('always', _('Always'));
field.value('on-failure', _('On Failure'));
field.value('unless-stopped', _('Unless Stopped'));
field = this.section.option(form.Flag, 'privileged', _('Privileged Mode'));
field = this.section.option(form.Flag, 'tty', _('Allocate TTY (-t)'));
field = this.section.option(form.Flag, 'remove', _('Auto Remove (--rm)'));
field = this.section.option(form.Flag, 'autoupdate', _('Auto-Update'));
field.description = _(
'Automatically update container when newer image is available. Adds label: io.containers.autoupdate=registry'
);
field = this.section.option(form.Flag, 'start', _('Start after creation'));
field.description = _('Automatically start the container after it is created');
field = this.section.option(form.Value, 'workdir', _('Working Directory'));
field.placeholder = '/app';
field.optional = true;
field = this.section.option(form.Value, 'hostname', _('Hostname'));
field.depends('pod', '');
field.placeholder = 'container-host';
field.optional = true;
field.datatype = 'hostname';
field = this.section.option(form.Value, 'user', _('User'));
field.placeholder = '1000:1000';
field.optional = true;
field.description = _('User and group to run as (UID:GID)');
field = this.section.option(form.Value, 'groups', _('Supplementary Groups'));
field.placeholder = '500,1000';
field.optional = true;
field.description = _('Comma-separated list of supplementary group IDs');
field = this.section.option(form.TextValue, 'labels', _('Labels'));
field.placeholder = 'key1=value1\nkey2=value2';
field.rows = 3;
field.optional = true;
field.description = _('One per line, format: key=value');
field = this.section.option(form.Value, 'cpus', _('CPU Limit'));
field.placeholder = '1.0';
field.optional = true;
field.datatype = 'ufloat';
field.description = _('Number of CPUs (e.g., 0.5, 1.0, 2.0)');
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
field.optional = true;
field.description = _('Memory limit (e.g., 512m, 1g)');
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const data = this.getFieldValues();
const spec = {
image: data.image,
privileged: Boolean(parseInt(data.privileged)),
terminal: Boolean(parseInt(data.tty)),
remove: Boolean(parseInt(data.remove)),
};
if (data.name) spec.name = data.name;
if (data.pod) spec.pod = data.pod;
if (data.command) spec.command = data.command.split(/\s+/).filter((c) => c.length > 0);
if (!data.pod && data.ports) {
spec.portmappings = [];
data.ports.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed) return;
const parts = trimmed.split(':');
if (parts.length === 2) {
const hostPort = parseInt(parts[0], 10);
const [containerPortStr, proto] = parts[1].split('/');
const containerPort = parseInt(containerPortStr, 10);
if (!isNaN(hostPort) && !isNaN(containerPort)) {
spec.portmappings.push({
host_port: hostPort,
container_port: containerPort,
protocol: (proto || 'tcp').toLowerCase()
});
}
}
});
}
const parseKeyValue = (text, target) => {
text.split('\n').forEach((line) => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
if (key) target[key] = value;
}
});
};
if (data.env) {
spec.env = {};
parseKeyValue(data.env, spec.env);
}
if (data.volumes) {
const mounts = [];
const volumes = [];
data.volumes.split('\n').forEach((line) => {
const parts = line.trim().split(':');
if (parts.length < 2) return;
const opts = parts.length > 2 ? parts[2].split(',') : [];
if (parts[0].includes('/')) {
const mount = { Source: parts[0], Destination: parts[1] };
if (opts.includes('ro')) mount.ReadOnly = true;
const selinux = opts.filter((o) => o === 'Z' || o === 'z');
if (selinux.length > 0) mount.options = selinux;
mounts.push(mount);
return;
}
const vol = { Name: parts[0], Dest: parts[1] };
if (opts.length > 0) vol.Options = opts;
volumes.push(vol);
});
if (mounts.length > 0) spec.mounts = mounts;
if (volumes.length > 0) spec.volumes = volumes;
}
if (!data.pod) {
if (data.network === 'host') {
spec.netns = { nsmode: 'host' };
} else if (data.network === 'none') {
spec.netns = { nsmode: 'none' };
} else if (data.network && data.network !== 'bridge') {
spec.networks = { [data.network]: {} };
}
}
if (data.restart !== 'no') spec.restart_policy = data.restart;
if (data.workdir) spec.work_dir = data.workdir;
if (!data.pod && data.hostname) spec.hostname = data.hostname;
if (data.user) spec.user = data.user;
if (data.groups) {
spec.groups = data.groups.split(',').map((g) => g.trim()).filter((g) => g);
}
if (!data.pod && data.expose) {
spec.expose = {};
data.expose.split(',').forEach((p) => {
const trimmed = p.trim();
if (!trimmed) return;
const parts = trimmed.split('/');
const port = parseInt(parts[0], 10);
if (port >= 1 && port <= 65535)
spec.expose[String(port)] = (parts[1] || 'tcp').toLowerCase();
});
}
if (data.labels || data.autoupdate === '1') {
spec.labels = {};
if (data.labels) parseKeyValue(data.labels, spec.labels);
if (data.autoupdate === '1') spec.labels['io.containers.autoupdate'] = 'registry';
}
if (data.cpus || data.memory) {
spec.resource_limits = {};
if (data.cpus) {
spec.resource_limits.cpu = { quota: parseFloat(data.cpus) * 100000 };
}
if (data.memory) {
const memBytes = podmanUtil.format.parseMemory(data.memory);
if (memBytes > 0) spec.resource_limits.memory = { limit: memBytes };
}
}
const hasRestartPolicy = data.restart && data.restart !== 'no';
const shouldStart = Boolean(parseInt(data.start));
const createFn = async () => {
const raw = await podmanRPC.containers.create(spec);
const inspected = await Container.getSingleton(raw).inspect();
const container = Container.getSingleton(inspected);
if (hasRestartPolicy) {
await container.generateInitScript();
await container.enableInitScript();
}
if (shouldStart) await container.start();
};
return this.super('handleCreate', [ createFn, _('Container') ]);
},
});
return baseclass.extend({
init: PodmanFormContainer
});
@@ -0,0 +1,91 @@
'use strict';
'require baseclass';
'require form';
'require ui';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.model.Image as Image';
/**
* Pull podman image
*/
const PodmanFormImage = podmanView.form.extend({
__name__: 'Podman.Form.Image',
makeData() {
return {
image: {
registry: '',
image: '',
}
};
},
createForm() {
let field;
field = this.section.option(form.ListValue, 'registry', _('Registry'));
field.value('', 'docker.io');
field.value('quay.io/', 'quay.io');
field.value('ghcr.io/', 'ghcr.io');
field.value('gcr.io/', 'gcr.io');
field = this.section.option(form.Value, 'image', _('Image'));
field.placeholder = 'nginx:latest';
field.rmempty = false;
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const registry = this.getFieldValue('registry');
const image = this.getFieldValue('image');
const imageName = this.buildImageName(registry, image);
const imageModel = Image.getSingleton({ RepoTags: [imageName] });
const progressEl = E('pre', { class: 'terminal-area' });
const modal = new podmanUI.Modal(_('Pulling %s').format(imageName), [ progressEl ]);
/** @type {HTMLElement} */
const closeButton = modal.getCloseButton();
closeButton.classList.add('d-none');
modal.getButtons = () => [ closeButton ];
modal.render();
const append = (text) => {
progressEl.appendChild(document.createTextNode(text));
progressEl.scrollTop = progressEl.scrollHeight;
};
try {
await imageModel.streamPull(append);
closeButton.classList.remove('d-none');
podmanUI.alert(_('Image pulled successfully'), 'success', true);
} catch (error) {
this.error(error);
}
},
buildImageName(registry, image) {
const reg = (registry || '').trim();
const img = (image || '').trim();
if (!reg) {
return img.includes('/') ? 'docker.io/' + img : 'docker.io/library/' + img;
}
return reg.replace(/\/+$/, '') + '/' + img.replace(/^\/+/, '');
},
});
return baseclass.extend({
init: PodmanFormImage
});
@@ -0,0 +1,304 @@
'use strict';
'require baseclass';
'require form';
'require ui';
'require uci';
'require network';
'require firewall';
'require podman.utils as utils';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
'require podman.model.Network as Network';
/**
* Create podman network
*/
const PodmanFormNetwork = podmanView.form.extend({
__name__: 'Podman.Form.Network',
existingZones: null,
parentDevices: null,
makeData() {
return {
network: {
name: null,
driver: 'bridge',
subnet: null,
gateway: null,
ip_range: null,
ipv6: '0',
internal: '0',
labels: null,
setup_openwrt: '1',
firewall_zone: '_create_new_',
}
};
},
async render() {
const [zones, parentDevices] = await Promise.all([
firewall.getZones().then((zones) =>
zones
.filter(zone => zone.data.name?.startsWith('podman'))
.map(zone => zone.data.name)
),
network.getDevices().then((devices) =>
devices.filter(this.isValidParentDevice)
),
]);
this.existingZones = zones;
this.parentDevices = parentDevices;
return this.super('render', []);
},
createForm: function () {
let field;
field = this.section.option(form.Value, 'name', _('Network Name'));
field.placeholder = 'my-network';
field.datatype = 'and(uciname,maxlength(15))';
field.description = _('Name for the network');
field.rmempty = false;
field = this.section.option(form.ListValue, 'driver', _('Driver'));
field.value('bridge', 'bridge');
field.value('macvlan', 'macvlan');
field.value('ipvlan', 'ipvlan');
field.description = _('Network driver');
field = this.section.option(form.ListValue, 'parent', _('Parent Interface'));
field.depends('driver', 'macvlan');
field.depends('driver', 'ipvlan');
field.description = _(
'Existing physical interface to use as parent. Required for macvlan/ipvlan networks.'
);
if (this.parentDevices.length > 0) {
this.parentDevices.forEach((device) => {
const name = device.getName();
field.value(name, name + ' (' + device.getType() + ')');
});
} else {
field.value('', _('No suitable interfaces found'));
}
field = this.section.option(form.Value, 'subnet', _('IPv4 Subnet (CIDR)'));
field.placeholder = '10.89.0.0/24';
field.datatype = 'cidr4';
field.description = _('IPv4 subnet in CIDR notation');
// field.rmempty = false;
field = this.section.option(form.Value, 'gateway', _('IPv4 Gateway'));
field.placeholder = '10.89.0.1';
field.optional = true;
field.datatype = 'ip4addr';
field.description = _('IPv4 gateway address');
field = this.section.option(form.Value, 'ip_range', _('IP Range (CIDR)'));
field.placeholder = '10.89.0.0/28';
field.optional = true;
field.datatype = 'cidr4';
field.description = _('Allocate container IP from this range');
field = this.section.option(form.Flag, 'ipv6', _('Enable IPv6'));
field.description = _('Enable IPv6 networking');
field = this.section.option(form.Flag, 'internal', _('Internal Network'));
field.description = _('Restrict external access to the network');
field = this.section.option(form.Flag, 'setup_openwrt', _('Setup OpenWrt Integration'));
field.description = _(
'Automatically configure OpenWrt network interface, bridge, and firewall zone. <strong>Highly recommended</strong> for proper container networking on OpenWrt.'
);
field = this.section.option(form.Value, 'bridge_name', _('Bridge Interface Name'));
field.placeholder = _('Leave empty to auto-generate');
field.optional = true;
field.datatype = 'netdevname';
field.depends({
'setup_openwrt': '1',
'driver': 'bridge'
});
field.description = _(
'Name of the bridge interface (e.g., podman0, mynet0). Leave empty to use: &lt;network-name&gt;0. Note: If the generated name conflicts with an existing interface, OpenWrt will auto-increment it.'
);
field = this.section.option(form.ListValue, 'firewall_zone', _('Firewall Zone'));
field.value('_create_new_', _('Create new zone (will be named: podman_<network-name>)'));
if (this.existingZones.length > 0) {
this.existingZones.forEach((zoneName) => {
field.value(zoneName, zoneName);
});
}
field.depends('setup_openwrt', '1');
field.description = _(
'Choose firewall zone for this network.'
);
field = this.section.option(form.TextValue, 'labels', _('Labels'));
field.placeholder = 'key1=value1\nkey2=value2';
field.rows = 3;
field.optional = true;
field.description = _('Labels in key=value format, one per line');
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
await uci.load(['network', 'firewall', 'dhcp']);
const ulaPrefix = uci.get('network', 'globals', 'ula_prefix');
const podnetwork = this.getFieldValues();
const setupOpenwrt = podnetwork.setup_openwrt === '1';
const driver = podnetwork.driver || 'bridge';
const requestedZone = podnetwork.firewall_zone || '_create_new_';
const zoneName = requestedZone === '_create_new_' ? 'podman_' + podnetwork.name :
requestedZone;
const baseBridgeName = podnetwork.bridge_name || podnetwork.name;
let bridgeName = baseBridgeName;
let i = 1;
while (await network.getDevice(bridgeName)) {
bridgeName = `${baseBridgeName}${i}`;
i++;
}
if ((driver === 'macvlan' || driver === 'ipvlan') && !podnetwork.parent) {
this.error(_('Parent interface is required for macvlan/ipvlan networks'));
return;
}
// if (setupOpenwrt && !podnetwork.subnet) {
// this.error(_('OpenWrt integration requires subnet to be specified'));
// return;
// }
if (!podnetwork.gateway && podnetwork.subnet) {
podnetwork.gateway = utils.ipv4.firstHost(podnetwork.subnet);
}
const payload = {
name: podnetwork.name,
driver: driver
};
if (driver === 'bridge') {
payload.network_interface = bridgeName;
} else if (driver === 'macvlan' || driver === 'ipvlan') {
payload.network_interface = podnetwork.parent;
}
if (podnetwork.subnet) {
payload.subnets = [{
subnet: podnetwork.subnet
}];
if (podnetwork.gateway) payload.subnets[0].gateway = podnetwork.gateway;
if (podnetwork.ip_range) {
const range = utils.ipv4.cidrToRange(podnetwork.ip_range);
if (range) payload.subnets[0].lease_range = range;
}
}
payload.ipv6_enabled = false;
if (podnetwork.ipv6 === '1') {
const ipv6obj = utils.ipv6.deriveUlaFromIpv4(podnetwork.subnet, ulaPrefix);
payload.ipv6_enabled = true;
if (podnetwork.subnet) {
payload.subnets.push({
subnet: ipv6obj.ipv6subnet
});
if (podnetwork.gateway)
payload.subnets[1].gateway = ipv6obj.ipv6gateway;
}
}
payload.internal = podnetwork.internal === '1';
if (podnetwork.labels) {
payload.labels = {};
podnetwork.labels.split('\n').forEach((line) => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
if (key) payload.labels[key] = value;
}
});
}
const createFn = async () => {
const newNetwork = await podmanRPC.networks.create(payload);
if (!setupOpenwrt) return;
const inspectData = await Network.getSingleton({
name: podnetwork.name
}).inspect();
const NetworkModel = Network.getSingleton(inspectData);
const deviceName = await NetworkModel.integrationCreateDevice();
await NetworkModel.integrationCreateNetwork(deviceName);
if (await NetworkModel.hasDnsmasq()) {
await NetworkModel.integrationAddDnsmasqExclusion();
}
if (await firewall.getZone(zoneName)) {
const currentNetworks = uci.get('firewall', zoneName, 'network');
const networkList = Array.isArray(currentNetworks) ? currentNetworks :
currentNetworks ? [currentNetworks] : [];
if (!networkList.includes(podnetwork.name)) {
networkList.push(podnetwork.name);
uci.set('firewall', zoneName, 'network', networkList);
}
} else {
const zoneId = uci.add('firewall', 'zone');
uci.set('firewall', zoneId, 'name', zoneName);
uci.set('firewall', zoneId, 'input', 'DROP');
uci.set('firewall', zoneId, 'output', 'ACCEPT');
uci.set('firewall', zoneId, 'forward', 'REJECT');
uci.set('firewall', zoneId, 'network', [podnetwork.name]);
}
const dnsRuleName = 'Allow-' + zoneName + '-DNS';
const existingDnsRule = uci.sections('firewall', 'rule').find((s) =>
uci.get('firewall', s['.name'], 'name') === dnsRuleName
);
if (!existingDnsRule) {
const ruleId = uci.add('firewall', 'rule');
uci.set('firewall', ruleId, 'name', dnsRuleName);
uci.set('firewall', ruleId, 'src', zoneName);
uci.set('firewall', ruleId, 'dest_port', '53');
uci.set('firewall', ruleId, 'target', 'ACCEPT');
}
await uci.save();
ui.changes.apply();
await network.flushCache();
};
return this.super('handleCreate', [createFn, _('Network')]);
},
isValidParentDevice(device) {
const type = device.getType();
const name = device.getName();
return ['ethernet', 'bridge', 'vlan'].includes(type) &&
name !== 'lo' &&
!name.match(/^(podman|cni-|docker|veth)/);
}
});
return baseclass.extend({
init: PodmanFormNetwork
});
@@ -0,0 +1,178 @@
'use strict';
'require baseclass';
'require form';
'require podman.form as podmanForm';
'require podman.utils as podmanUtil';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
const PodmanFormPod = podmanView.form.extend({
__name__: 'Podman.Form.Pod',
makeData() {
return {
pod: {
name: '',
hostname: null,
share: ['cgroup', 'ipc', 'net', 'uts'],
infra: '1',
infra_image: null,
network: 'bridge',
dns_server: null,
dns_search: null,
cpus: null,
cpuset_cpus: null,
memory: null,
labels: null,
}
};
},
async createForm() {
const networks = await podmanRPC.networks.list();
let field;
field = this.section.option(form.Value, 'name', _('Pod Name'));
field.placeholder = 'my-pod';
field.optional = true;
field.datatype = 'and(uciname,maxlength(63))';
field.description = _('Leave empty to auto-generate');
field = this.section.option(form.Value, 'hostname', _('Hostname'));
field.placeholder = 'pod-host';
field.optional = true;
field.datatype = 'hostname';
field.description = _('Hostname for the pod (defaults to pod name)');
field = this.section.option(form.MultiValue, 'share', _('Shared Namespaces'));
field.value('cgroup', 'cgroup');
field.value('ipc', 'ipc');
field.value('net', 'net');
field.value('pid', 'pid');
field.value('uts', 'uts');
field.description = _('Linux namespaces shared between containers in the pod. Default matches `podman pod create`.');
field = this.section.option(form.Flag, 'infra', _('Create Infra Container'));
field.description = _('Required for shared network namespace. Disable only if you know you do not need it.');
field = this.section.option(form.Value, 'infra_image', _('Infra Image'));
field.depends('infra', '1');
field.placeholder = 'k8s.gcr.io/pause:3.5';
field.optional = true;
field.description = _('Override the image used for the infra container');
field = this.section.option(form.ListValue, 'network', _('Network'));
field.depends('infra', '1');
field.value('bridge', 'bridge (default)');
field.value('host', 'host');
field.value('none', 'none');
networks.forEach((net) => {
const name = net.getName();
if (name && name !== 'bridge' && name !== 'host' && name !== 'none') {
field.value(name, name);
}
});
field.description = _('Network namespace for the pod. Containers in the pod share this network.');
field = this.section.option(form.DynamicList, 'dns_server', _('DNS Servers'));
field.depends('infra', '1');
field.datatype = 'ipaddr';
field.optional = true;
field.description = _('DNS servers for the infra container (shared with all pod containers)');
field = this.section.option(form.DynamicList, 'dns_search', _('DNS Search Domains'));
field.depends('infra', '1');
field.optional = true;
field.description = _('DNS search domains for the infra container');
field = this.section.option(form.Value, 'cpus', _('CPU Limit'));
field.placeholder = '1.0';
field.optional = true;
field.datatype = 'ufloat';
field.description = _('Number of CPUs available to the pod (e.g., 0.5, 1.0, 2.0)');
field = this.section.option(form.Value, 'cpuset_cpus', _('CPU Set'));
field.placeholder = '0-3';
field.optional = true;
field.description = _('CPUs the pod is allowed to run on (e.g., "0-3" or "0,2")');
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
field.optional = true;
field.description = _('Memory limit for the pod (e.g., 512m, 1g)');
field = this.section.option(form.TextValue, 'labels', _('Labels'));
field.placeholder = 'key1=value1\nkey2=value2';
field.rows = 3;
field.optional = true;
field.description = _('One per line, format: key=value');
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const data = this.getFieldValues();
const spec = {};
if (data.name) spec.name = data.name;
if (data.hostname) spec.hostname = data.hostname;
const shareRaw = data.share;
spec.share = Array.isArray(shareRaw)
? shareRaw
: (typeof shareRaw === 'string' && shareRaw.trim() ? shareRaw.trim().split(/\s+/) : []);
const hasInfra = data.infra === '1';
spec.infra = hasInfra;
if (hasInfra && data.infra_image) spec.infra_image = data.infra_image;
if (hasInfra) {
if (data.network === 'host') {
spec.netns = { nsmode: 'host' };
} else if (data.network === 'none') {
spec.netns = { nsmode: 'none' };
} else if (data.network && data.network !== 'bridge') {
spec.Networks = { [data.network]: {} };
}
if (data.dns_server && data.dns_server.length) spec.dns_server = data.dns_server;
if (data.dns_search && data.dns_search.length) spec.dns_search = data.dns_search;
}
if (data.cpus) spec.cpus = parseFloat(data.cpus);
if (data.cpuset_cpus) spec.cpuset_cpus = data.cpuset_cpus;
if (data.memory) {
const memBytes = podmanUtil.format.parseMemory(data.memory);
if (memBytes > 0) {
spec.resource_limits = { memory: { limit: memBytes } };
}
}
if (data.labels) {
spec.labels = {};
data.labels.split('\n').forEach((line) => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
if (key) spec.labels[key] = value;
}
});
}
const createFn = () => podmanRPC.pods.create(spec);
return this.super('handleCreate', [ createFn, _('Pod') ]);
},
});
return baseclass.extend({
init: PodmanFormPod
});
@@ -0,0 +1,117 @@
'use strict';
'require baseclass';
'require form';
'require podman.utils as podmanUtil';
'require podman.form as podmanForm';
'require podman.view as podmanView';
/**
* Update podman container resources
*/
const PodmanFormResource = podmanView.form.extend({
__name__: 'Podman.Form.Resource',
container: null,
makeData() {
if (!this.container) {
return { resources: { cpuLimit: '', cpuShares: '', memory: '', memorySwap: '' } };
}
const hostConfig = this.container.getHostConfig();
return {
resources: {
cpuLimit: hostConfig.CpuQuota > 0 ? (hostConfig.CpuQuota / 100000).toFixed(2) : '',
cpuShares: hostConfig.CpuShares || '',
memory: hostConfig.Memory > 0 ? podmanUtil.format.bytes(hostConfig.Memory, 0) : '',
memorySwap: hostConfig.MemorySwap > 0 ? podmanUtil.format.bytes(hostConfig.MemorySwap, 0) : '',
}
};
},
async render(container) {
this.container = container;
return this.super('render', []);
},
createForm() {
let field;
field = this.section.option(form.Value, 'cpuLimit', _('CPU Limit'));
field.datatype = 'ufloat';
field.placeholder = '0.5, 1.0, 2.0';
field.description = _('Number of CPUs (e.g., 0.5, 1.0, 2.0). Leave empty for unlimited.');
field = this.section.option(form.Value, 'cpuShares', _('CPU Shares Weight'));
field.datatype = 'uinteger';
field.placeholder = '1024';
field.validate = (_section_id, value) => {
if (value) {
const n = parseInt(value);
if (n < 0 || n > 262144) return _('Must be between 0 and 262144');
}
return true;
};
field.description = _('CPU shares (relative weight). 0 = use default.');
field = this.section.option(podmanForm.field.MemoryValue, 'memory', _('Memory Limit'));
field.description = _('Memory limit (e.g., 512m, 1g). -1 = unlimited, 0 = use default.');
field = this.section.option(podmanForm.field.MemoryValue, 'memorySwap', _('Memory + Swap Limit'));
field.description = _('Total memory limit (memory + swap). -1 = unlimited, 0 = use default.');
field = this.section.option(form.Button, '_update', ' ');
field.inputtitle = _('Update Resources');
field.inputstyle = 'save';
field.onclick = () => this.handleUpdate();
},
async handleUpdate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const data = this.getFieldValues();
const updateData = {
cpu: {
quota: 0,
period: 0,
shares: parseInt(data.cpuShares) || 0,
},
};
const parseMemory = (val) =>
(!val || val === '-1' || val === '0') ? -1 : podmanUtil.format.parseMemory(val);
const memory = parseMemory(data.memory);
const memorySwap = parseMemory(data.memorySwap);
if (data.cpuLimit) {
const period = 100000;
updateData.cpu.quota = Math.floor(parseFloat(data.cpuLimit) * period);
updateData.cpu.period = period;
}
updateData.memory = { limit: memory };
if (memory === -1 || memorySwap === -1) {
updateData.memory.swap = -1;
} else if (memorySwap > 0) {
updateData.memory.swap = memorySwap;
} else {
updateData.memory.swap = null;
}
const createFn = () => this.container.update(updateData);
return this.super('handleCreate', [ createFn, _('Saving resource'), _('Updating container'), _('Container updated successfully') ]);
},
});
return baseclass.extend({
init: PodmanFormResource
});
@@ -0,0 +1,89 @@
'use strict';
'require baseclass';
'require form';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
/**
* Create podman secret
*/
const PodmanFormSecret = podmanView.form.extend({
__name__: 'Podman.Form.Secret',
makeData() {
return {
secret: {
name: null,
data: null,
labels: null,
}
};
},
createForm() {
let field;
field = this.section.option(form.Value, 'name', _('Secret Name'));
field.placeholder = 'my-secret';
field.rmempty = false;
field.datatype = 'maxlength(253)';
field.validate = (_section_id, value) => {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return _(
'Secret name can only contain letters, numbers, underscores, and hyphens'
);
}
return true;
};
field.description = _(
'1-253 characters: letters, numbers, underscore (_), hyphen (-) only');
field = this.section.option(form.TextValue, 'data', _('Secret Data'));
field.placeholder = _('Enter secret data (password, token, key, etc.)');
field.rmempty = false;
field.rows = 6;
field.description = _('The sensitive data to store securely');
field = this.section.option(form.TextValue, 'labels', _('Labels'));
field.placeholder = 'key1=value1\nkey2=value2';
field.rows = 3;
field.optional = true;
field.description = _('Labels in key=value format, one per line');
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const data = this.getFieldValues();
if (!data.name || !data.data) {
return;
}
const labels = {};
if (data.labels) {
data.labels.split('\n').forEach((line) => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
if (key) labels[key] = value;
}
});
}
const createFn = () => podmanRPC.secrets.create(data.name, data.data, labels);
return this.super('handleCreate', [ createFn, _('Secret') ]);
},
});
return baseclass.extend({
init: PodmanFormSecret
});
@@ -0,0 +1,97 @@
'use strict';
'require baseclass';
'require form';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
/**
* Create podman volume
*/
const PodmanFormVolume = podmanView.form.extend({
__name__: 'Podman.Form.Volume',
sectionName: 'volume',
makeData() {
return {
volume: {
name: null,
driver: 'local',
options: null,
labels: null,
}
};
},
createForm() {
let field;
field = this.section.option(form.Value, 'name', _('Volume Name'));
field.placeholder = 'my-volume';
field.optional = true;
field.datatype = 'maxlength(253)';
field.description = _('Volume name. Leave empty to auto-generate.');
field = this.section.option(form.ListValue, 'driver', _('Driver'));
field.value('local', 'local');
field.value('image', 'image');
field.description = _('Volume driver to use');
field = this.section.option(form.Value, 'options', _('Mount Options'));
field.placeholder = 'type=tmpfs,device=tmpfs,o=size=100m';
field.optional = true;
field.description = _('Driver-specific options (comma-separated, e.g., type=tmpfs,o=size=100m)');
field = this.section.option(form.TextValue, 'labels', _('Labels'));
field.placeholder = 'key1=value1\nkey2=value2';
field.rows = 3;
field.optional = true;
field.description = _('Labels in key=value format, one per line');
},
async handleCreate() {
if (!this.isValid()) {
return this.scrollToInvalid();
}
await this.save();
const data = this.getFieldValues();
const payload = {
Name: data.name,
Driver: data.driver,
};
if (data.options) {
payload.Options = {};
data.options.split(',').forEach((opt) => {
const parts = opt.split('=');
if (parts.length >= 2) {
payload.Options[parts[0].trim()] = parts.slice(1).join('=').trim();
}
});
}
if (data.labels) {
payload.Labels = {};
data.labels.split('\n').forEach((line) => {
const parts = line.split('=');
if (parts.length >= 2) {
const key = parts[0].trim();
const value = parts.slice(1).join('=').trim();
if (key) payload.Labels[key] = value;
}
});
}
const createFn = () => podmanRPC.volumes.create(payload);
return this.super('handleCreate', [ createFn, _('Volume') ]);
},
});
return baseclass.extend({
init: PodmanFormVolume,
});
@@ -0,0 +1,75 @@
'use strict';
'require baseclass';
'require form';
'require request';
'require podman.rpc as podmanRPC';
'require podman.view as podmanView';
/**
* Import podman volume
*/
const PodmanFormVolumeImport = podmanView.form.extend({
__name__: 'Podman.Form.VolumeImport',
sectionName: 'volume',
isCompressed: false,
file: null,
makeData() {
return {
volume: {
name: null,
}
};
},
async render(file) {
this.file = file;
return this.super('render', []);
},
createForm() {
const isCompressed = this.file.name.endsWith('.tar.gz') || this.file.name.endsWith('.tgz');
const volumeName = this.file.name.replace(/\.(tar\.gz|tgz|tar)$/, '');
this.isCompressed = isCompressed;
const field = this.section.option(form.Value, 'name', _('Volume Name'));
field.placeholder = 'my-volume';
field.rmempty = false;
field.datatype = 'rangelength(1,253)';
field.default = volumeName;
},
async handleCreate() {
await this.save();
const name = this.getFieldValue('name');
this.loading(_('Uploading file...'));
const formData = new FormData();
formData.append('sessionid', L.env.sessionid);
formData.append('filename', '/tmp/podman-import');
formData.append('filedata', this.file);
const response = await request.post(L.env.cgi_base + '/cgi-upload', formData);
if (!response.ok) {
this.error(_('Upload failed: %s').format(response.statusText));
return;
}
this.loading(_('Importing volume...'));
await podmanRPC.volumes.import(name, this.isCompressed);
this.success(_('Volume imported successfully'));
},
});
return baseclass.extend({
init: PodmanFormVolumeImport,
});
@@ -0,0 +1,785 @@
'use strict';
'require baseclass';
'require podman.model.Model as Model';
'require podman.model.Network as Network';
'require podman.model.Image as Image';
'require podman.constants as constants';
'require podman.utils as podmanUtil';
const InitScript = {
generate: Model.declareRPC({
object: 'podman',
method: 'init_script_generate',
params: ['name']
}),
show: Model.declareRPC({
object: 'podman',
method: 'init_script_show',
params: ['name']
}),
status: Model.declareRPC({
object: 'podman',
method: 'init_script_status',
params: ['name']
}),
setEnabled: Model.declareRPC({
object: 'podman',
method: 'init_script_set_enabled',
params: ['name', 'enabled']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'init_script_remove',
params: ['name']
})
};
const ContainerRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'container_inspect',
params: ['id']
}),
update: Model.declareRPC({
object: 'podman',
method: 'container_update',
params: ['id', 'data']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'container_remove',
params: ['id', 'force', 'depend']
}),
start: Model.declareRPC({
object: 'podman',
method: 'container_start',
params: ['id']
}),
stop: Model.declareRPC({
object: 'podman',
method: 'container_stop',
params: ['id']
}),
restart: Model.declareRPC({
object: 'podman',
method: 'container_restart',
params: ['id']
}),
pause: Model.declareRPC({
object: 'podman',
method: 'container_pause',
params: ['id']
}),
unpause: Model.declareRPC({
object: 'podman',
method: 'container_unpause',
params: ['id']
}),
rename: Model.declareRPC({
object: 'podman',
method: 'container_rename',
params: ['id', 'name']
}),
create: Model.declareRPC({
object: 'podman',
method: 'container_create',
params: ['data']
}),
};
const Container = Model.base.extend({
__name__: 'Podman.Model.Container',
getID() {
return this.Id || '';
},
getName() {
if (this.Names && Array.isArray(this.Names) && this.Names.length > 0) {
return this.Names[0];
}
if (this.Names && !Array.isArray(this.Names) && this.Names !== '') {
return this.Names;
}
if (this.Name) {
return this.Name;
}
return _('Unknown');
},
getState() {
if (typeof this.State === 'string') {
return this.State;
}
return this.State?.Status || this.Status || '';
},
getStateBadge() {
return E('div', { class: `badge ${this.getState()}` }, [ this.getState() ]);
},
getAutoUpdateLabel() {
const labels = this.Config?.Labels || this.Labels;
if (!labels) {
return false;
}
return labels['io.containers.autoupdate'] || false;
},
getCmdString() {
return this.Config?.Cmd ? this.Config.Cmd.join(' ') : '';
},
getEntrypointString() {
return this.Config?.Entrypoint ? this.Config.Entrypoint.join(' ') : '';
},
getCreateCommandString() {
return this.Config?.CreateCommand ? this.Config?.CreateCommand.join(' ') : '';
},
inspectToSpec(imageRef) {
const config = this.Config || {};
const hostConfig = this.HostConfig || {};
const networkSettings = this.NetworkSettings || {};
const mounts = this.Mounts || [];
const spec = { image: imageRef };
// Name (strip leading slash added by Podman inspect)
if (this.Name) spec.name = this.Name.replace(/^\//, '');
// Process
if (config.Cmd && config.Cmd.length) spec.command = config.Cmd;
if (config.Entrypoint && config.Entrypoint.length) spec.entrypoint = config.Entrypoint;
if (config.Tty) spec.terminal = true;
if (config.OpenStdin) spec.stdin = true;
if (config.WorkingDir) spec.work_dir = config.WorkingDir;
if (config.User) spec.user = config.User;
if (config.Hostname) spec.hostname = config.Hostname;
if (config.StopSignal) {
const sigMap = {
SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5,
SIGABRT: 6, SIGBUS: 7, SIGFPE: 8, SIGKILL: 9, SIGUSR1: 10,
SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15,
SIGCHLD: 17, SIGCONT: 18, SIGSTOP: 19, SIGTSTP: 20, SIGTTIN: 21,
SIGTTOU: 22, SIGURG: 23, SIGXCPU: 24, SIGXFSZ: 25, SIGVTALRM: 26,
SIGPROF: 27, SIGWINCH: 28, SIGIO: 29, SIGPWR: 30, SIGSYS: 31,
};
const sigStr = String(config.StopSignal).toUpperCase().replace(/^SIG/, '');
const sigNum = sigMap['SIG' + sigStr] ?? parseInt(config.StopSignal, 10);
if (sigNum > 0) spec.stop_signal = sigNum;
}
if (config.StopTimeout) spec.stop_timeout = config.StopTimeout;
if (config.Healthcheck) spec.healthconfig = config.Healthcheck;
// Environment: ["KEY=val", ...] → {KEY: "val", ...}
if (config.Env && config.Env.length) {
spec.env = {};
for (const e of config.Env) {
const idx = e.indexOf('=');
if (idx > 0) spec.env[e.substring(0, idx)] = e.substring(idx + 1);
}
}
// Labels
if (config.Labels && Object.keys(config.Labels).length)
spec.labels = config.Labels;
// HostConfig
if (hostConfig.Privileged) spec.privileged = true;
if (hostConfig.ReadonlyRootfs) spec.read_only_filesystem = true;
if (hostConfig.AutoRemove) spec.remove = true;
if (hostConfig.CapAdd && hostConfig.CapAdd.length) spec.cap_add = hostConfig.CapAdd;
if (hostConfig.CapDrop && hostConfig.CapDrop.length) spec.cap_drop = hostConfig.CapDrop;
if (hostConfig.Dns && hostConfig.Dns.length) spec.dns_server = hostConfig.Dns;
if (hostConfig.DnsOptions && hostConfig.DnsOptions.length) spec.dns_option = hostConfig.DnsOptions;
if (hostConfig.DnsSearch && hostConfig.DnsSearch.length) spec.dns_search = hostConfig.DnsSearch;
if (hostConfig.ExtraHosts && hostConfig.ExtraHosts.length) spec.hostadd = hostConfig.ExtraHosts;
if (hostConfig.GroupAdd && hostConfig.GroupAdd.length) spec.groups = hostConfig.GroupAdd;
if (hostConfig.ShmSize) spec.shm_size = hostConfig.ShmSize;
if (hostConfig.OomScoreAdj) spec.oom_score_adj = hostConfig.OomScoreAdj;
// Restart policy
const rp = hostConfig.RestartPolicy;
if (rp && rp.Name && rp.Name !== 'no') {
spec.restart_policy = rp.Name;
if (rp.MaximumRetryCount) spec.restart_tries = rp.MaximumRetryCount;
}
// Exposed ports: {"8080/tcp": {}} → {8080: "tcp"}
const exposedPorts = config.ExposedPorts || {};
if (Object.keys(exposedPorts).length) {
spec.expose = {};
for (const portProto of Object.keys(exposedPorts)) {
const [port, proto] = portProto.split('/');
const portNum = parseInt(port, 10);
if (portNum > 0 && portNum <= 65535)
spec.expose[portNum] = proto || 'tcp';
}
}
// Port mappings: {"8080/tcp": [{HostIp, HostPort}]} → array
const portBindings = hostConfig.PortBindings || {};
if (Object.keys(portBindings).length) {
spec.portmappings = [];
for (const [portProto, bindings] of Object.entries(portBindings)) {
if (!bindings || !bindings.length) continue;
const [port, protocol] = portProto.split('/');
const containerPort = parseInt(port, 10);
for (const b of bindings) {
const m = { container_port: containerPort, protocol: protocol || 'tcp' };
if (b.HostPort) m.host_port = parseInt(b.HostPort, 10);
if (b.HostIp && b.HostIp !== '0.0.0.0') m.host_ip = b.HostIp;
spec.portmappings.push(m);
}
}
}
// Resource limits - use CFS quota/period directly (most precise)
if (hostConfig.Memory > 0 || hostConfig.CpuQuota > 0) {
spec.resource_limits = {};
if (hostConfig.Memory > 0)
spec.resource_limits.memory = { limit: hostConfig.Memory };
if (hostConfig.CpuQuota > 0) {
spec.resource_limits.cpu = { quota: hostConfig.CpuQuota };
if (hostConfig.CpuPeriod > 0)
spec.resource_limits.cpu.period = hostConfig.CpuPeriod;
}
}
// Volumes and bind mounts
const namedVols = mounts.filter(m => m.Type === 'volume');
const bindMounts = mounts.filter(m => m.Type === 'bind');
if (namedVols.length) {
spec.volumes = namedVols.map(m => ({
Name: m.Name,
Dest: m.Destination,
Options: m.Options || []
}));
}
if (bindMounts.length) {
spec.mounts = bindMounts.map(m => ({
Source: m.Source,
Destination: m.Destination,
ReadOnly: !m.RW,
options: m.Options || []
}));
}
// Network
const nm = hostConfig.NetworkMode || '';
if (nm === 'host') {
spec.netns = { nsmode: 'host' };
} else if (nm === 'none') {
spec.netns = { nsmode: 'none' };
} else {
const nets = networkSettings.Networks || {};
const nonDefault = Object.keys(nets).filter(n => n !== 'bridge' && n !== 'default');
if (nonDefault.length) {
spec.networks = {};
for (const n of nonDefault) spec.networks[n] = {};
}
}
return spec;
},
getWorkingDir() {
return this.Config?.WorkingDir;
},
getHostname() {
return this.Config?.Hostname;
},
getUser() {
return this.Config?.User;
},
getInteractive() {
return this.Config?.OpenStdin;
},
getPrivileged() {
return this.HostConfig?.Privileged;
},
getNetworkMode() {
return this.HostConfig?.NetworkMode;
},
getNetworkSettings() {
return this.NetworkSettings || {};
},
getConfig() {
return this.Config || {};
},
getHostConfig() {
return this.HostConfig || {};
},
getTty() {
return this.Config?.Tty;
},
getMounts() {
return this.Mounts || [];
},
getEnvironmentVars() {
return this.Config?.Env || [];
},
getPorts() {
const ports = [];
const networkPorts = this.getNetworkSettings().Ports || {};
Object.keys(networkPorts).forEach((port) => {
const [portOnly, protocol] = port.split('/');
const portBindings = networkPorts[port] || [];
if (portBindings.length === 0) {
ports.push({ string: port, port: portOnly, protocol: protocol || '', hostPort: null, hostIp: null });
return;
}
portBindings.forEach((portBinding) => {
const bindingString = [port];
if (portBinding.HostIp) bindingString.push(portBinding.HostIp);
if (portBinding.HostPort) bindingString.push(portBinding.HostPort);
ports.push({
string: bindingString.join(':'),
port: portOnly,
protocol: protocol || '',
hostIp: portBinding.HostIp,
hostPort: portBinding.HostPort,
});
});
});
return ports;
},
getImageName() {
return (this.ImageName || this.Image).replace(/^[^\/]+\//, '').split(':')[0];
},
_parseImageRef() {
const cleanUrl = this.Image.split('@')[0];
const lastColon = cleanUrl.lastIndexOf(':');
const lastSlash = cleanUrl.lastIndexOf('/');
return { cleanUrl, lastColon, lastSlash };
},
getImageRepo() {
const { cleanUrl, lastColon, lastSlash } = this._parseImageRef();
return lastColon > lastSlash ? cleanUrl.substring(0, lastColon) : '';
},
getImageTag() {
const { cleanUrl, lastColon, lastSlash } = this._parseImageRef();
return lastColon > lastSlash ? cleanUrl.substring(lastColon + 1) : 'latest';
},
getCreated(dateFormat) {
return dateFormat ? podmanUtil.format.date(this.Created) : this.Created;
},
getStartedAt(dateFormat) {
const date = this.StartedAt || this.State?.StartedAt;
return dateFormat ? podmanUtil.format.date(date) : date;
},
getRestartPolicyName() {
if (this.HostConfig?.RestartPolicy?.Name) {
return this.HostConfig.RestartPolicy.Name;
}
return 'no';
},
getDetailLink(text) {
return E('a', {
class: 'text-bold',
href: this.getDetailUrl(),
'data-container-id': this.getID()
}, text || this.getID());
},
getDetailUrl() {
return L.url('admin/podman/container', this.getID());
},
getAutoStartStatusIcon(status) {
let text = '-';
if (status === 'enabled') {
text = constants.ICON.INIT_ENABLED;
} else if (status === 'missing') {
text = constants.ICON.INIT_MISSING;
} else if (status === 'disabled') {
text = constants.ICON.INIT_DISABLED;
}
return E('span', { class: `autostart-status autostart-${status || ''}` }, text);
},
isRunning() {
const state = this.getState();
return this.State?.Running
|| state === 'running'
|| state === 'starting'
|| state === 'unhealthy';
},
isStopped() {
const state = this.getState();
return state === 'exited' || state === 'created';
},
isPaused() {
return !!this.State?.Paused || this.getState() === 'paused';
},
async checkInitScript() {
if (!this.HostConfig) {
const inspectData = await this.inspect();
if (!inspectData) return 'none';
this.HostConfig = inspectData.HostConfig;
}
const hasRestartPolicy = this._hasRestartPolicy();
if (!hasRestartPolicy) {
return 'none';
}
const initStatus = await InitScript.status(this.getName());
if (!initStatus) return 'none';
if (initStatus.exists && initStatus.enabled) {
return 'enabled';
} else if (!initStatus.exists) {
return 'missing';
} else if (initStatus.exists && !initStatus.enabled) {
return 'disabled';
}
return 'none';
},
async generateInitScript() {
return InitScript.generate(this.getName());
},
async showInitScript() {
return InitScript.show(this.getName());
},
async removeInitScript() {
return InitScript.remove(this.getName());
},
async enableInitScript() {
return InitScript.setEnabled(this.getName(), true);
},
async disableInitScript() {
return InitScript.setEnabled(this.getName(), false);
},
_hasRestartPolicy() {
return this.getRestartPolicyName() !== 'no';
},
async inspect() {
return ContainerRPC.inspect(this.getID());
},
async update(data) {
await ContainerRPC.update(this.getID(), data);
const initScriptStatus = await this.checkInitScript();
const policy = data.RestartPolicy;
const hasRestartPolicy = policy !== 'no';
if (hasRestartPolicy && (initScriptStatus === 'none' || initScriptStatus === 'missing')) {
await this.generateInitScript();
await this.enableInitScript();
} else if (!hasRestartPolicy && (initScriptStatus === 'enabled' || initScriptStatus === 'disabled')) {
await this.removeInitScript();
}
},
async remove(force, volumes) {
const initScriptStatus = await this.checkInitScript();
if (initScriptStatus === 'enabled' || initScriptStatus === 'disabled') {
await this.removeInitScript();
}
return ContainerRPC.remove(this.getID(), force ?? false, volumes ?? false);
},
async start() {
return ContainerRPC.start(this.getID());
},
async stop() {
return ContainerRPC.stop(this.getID());
},
async restart() {
return ContainerRPC.restart(this.getID());
},
async pause() {
return ContainerRPC.pause(this.getID());
},
async unpause() {
return ContainerRPC.unpause(this.getID());
},
async connect(networkName, payload) {
return Network.getSingleton({ name: networkName }).connect(Object.assign({ container: this.getID() }, payload));
},
async disconnect(networkName) {
return Network.getSingleton({ name: networkName }).disconnect({ Container: this.getID() });
},
streamTop(onChunk, delay, psargs) {
const params = new URLSearchParams({ delay: String(delay || 2) });
if (psargs) params.set('ps_args', psargs);
const url = L.url('admin/podman/stream/top', this.getID()) + '?' + params;
return this._stream(
() => url,
(data) => { if (!data.raw && !data.Error) onChunk(data); }
);
},
streamStats(onChunk, interval) {
const params = new URLSearchParams({ interval: String(interval || 2) });
const url = L.url('admin/podman/stream/stats', this.getID()) + '?' + params;
return this._stream(
() => url,
(data) => {
if (data.raw || data.Error || !data.Stats || data.Stats.length === 0) {
return;
}
onChunk(data.Stats[0]);
}
);
},
streamLogs(onChunk, { tail = 100, since = null, until = null, follow = true } = {}) {
if (!follow) {
// Historical fetch: single request, stop after completion (no reconnect)
let handle;
handle = this._stream(
() => {
const params = new URLSearchParams();
params.set('tail', String(tail));
params.set('follow', 'false');
if (since !== null) params.set('since', String(since));
if (until !== null) params.set('until', String(until));
return L.url('admin/podman/stream/logs', this.getID()) + '?' + params;
},
onChunk,
() => handle?.stop()
);
return handle;
}
// Live stream: reconnect with since= after each disconnect
let reconnectSince = null;
return this._stream(
() => {
const params = new URLSearchParams();
if (reconnectSince !== null) {
// Reconnect: continue from last close, no tail (would cap new lines)
params.set('since', String(reconnectSince));
} else {
// First connect: apply user params
params.set('tail', String(tail));
if (since !== null) params.set('since', String(since));
}
params.set('follow', 'true');
if (until !== null) params.set('until', String(until));
return L.url('admin/podman/stream/logs', this.getID()) + '?' + params;
},
onChunk,
() => { reconnectSince = Date.now() / 1000; }
);
},
async checkImageUpdate() {
const findArchDigest = (manifest, arch, os) => {
if (!manifest || !manifest.manifests) {
return manifest.digest || null;
}
const entry = manifest.manifests.find((m) =>
m.platform &&
m.platform.architecture === arch &&
m.platform.os === os
);
return entry ? entry.digest : null;
};
try {
const image = await this.getImage();
const imageRef = image.getDisplayTag() || this.Image;
if (!imageRef || imageRef.startsWith('<none>'))
return { hasUpdate: false };
const localArch = image.Architecture || 'amd64';
const localOs = image.Os || 'linux';
const manifest = await image.inspectManifest(imageRef);
const remoteDigest = findArchDigest(manifest, localArch, localOs);
const repoDigests = image.RepoDigests || [];
const hasUpdate = !!(remoteDigest && !repoDigests.some(rd => rd.includes(remoteDigest)));
return {
hasUpdate,
remoteDigest,
};
} catch (err) {
return {
hasUpdate: false,
error: err.message || String(err)
};
}
},
async _reinstateInitScript(status) {
if (status !== 'enabled' && status !== 'disabled') return;
await this.generateInitScript();
if (status === 'enabled') await this.enableInitScript();
},
getUpdateSkipReason() {
if (this.Pod)
return _('Part of a pod ("%s")').format(this.Pod);
const hostConfig = this.getHostConfig();
if (hostConfig.AutoRemove)
return _('Ephemeral (--rm flag)');
if (hostConfig.NetworkMode?.startsWith('container:'))
return _('Shares network with another container');
return null;
},
async updateImage(onProgress) {
const skipReason = this.getUpdateSkipReason();
if (skipReason) throw new Error(skipReason);
const [image, initStatus] = await Promise.all([this.getImage(), this.checkInitScript()]);
if (!image.RepoTags?.length || image.RepoTags[0] === '<none>:<none>') {
const ref = this.ImageName || this.Config?.Image;
if (ref) image.RepoTags = [ref];
}
const imageRef = image.getDisplayTag();
if (!imageRef || imageRef === '<none>:<none>')
throw new Error(_('Container image has no tag - cannot update'));
onProgress?.(_('Pulling %s').format(imageRef) + '\n');
const oldImageId = image.getID();
const newImageId = await image.update(onProgress);
if (!newImageId)
throw new Error(_('Pull did not return an image ID - pull may have failed'));
if (newImageId === oldImageId)
throw new Error(_('Image is already up-to-date'));
const wasRunning = this.isRunning();
const spec = this.inspectToSpec(newImageId);
if (wasRunning) {
onProgress?.('→ ' + _('Stopping container') + '\n');
try { await this.stop(); }
catch (e) { throw new Error(_('Stop failed: %s').format(e.message || e)); }
}
onProgress?.('→ ' + _('Removing container') + '\n');
try { await this.remove(); }
catch (e) { throw new Error(_('Remove failed: %s').format(e.message || e)); }
onProgress?.('→ ' + _('Creating container') + '\n');
let created;
try { created = await ContainerRPC.create(spec); }
catch (e) { throw new Error(_('Create failed: %s').format(e.message || e)); }
if (!created || created.error)
throw new Error(_('Create failed: %s').format(created?.error || _('Failed to create container')));
if (wasRunning) {
onProgress?.('→ ' + _('Starting container') + '\n');
try { await ContainerRPC.start(created.Id); }
catch (e) { throw new Error(_('Start failed: %s').format(e.message || e)); }
}
onProgress?.('→ ' + _('Reinstating init script') + '\n');
try { await this._reinstateInitScript(initStatus); }
catch (e) { throw new Error(_('Init script reinstate failed: %s').format(e.message || e)); }
onProgress?.('✓ ' + _('Updated to %s').format((newImageId || '').substring(0, 12)) + '\n');
return { oldImage: image };
},
async getImage() {
const image = Image.getSingleton({ Id: this.ImageID || this.Image });
return Image.getSingleton(await image.inspect());
},
async updateName(name) {
if (!name || name === this.getName()) return;
await ContainerRPC.rename(this.getID(), name);
const initScriptStatus = await this.checkInitScript();
if (initScriptStatus === 'enabled' || initScriptStatus === 'disabled') {
await this.removeInitScript();
}
if (this.Names && this.Names.length > 0) this.Names[0] = name;
if (this.Name) this.Name = name;
await this._reinstateInitScript(initScriptStatus);
},
});
return baseclass.extend({
getSingleton(container) {
return Container.extend(container).instantiate([]);
},
});
@@ -0,0 +1,103 @@
'use strict';
'require baseclass';
'require podman.model.Model as Model';
const ImageRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'image_inspect',
params: ['id'],
}),
inspectManifest: Model.declareRPCSilent({
object: 'podman',
method: 'image_manifest_inspect',
params: ['image']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'image_remove',
params: ['id', 'force'],
}),
};
const Image = Model.base.extend({
__name__: 'Podman.Model.Image',
getID() {
return this.Id;
},
getDisplayTag() {
return this.getRepoTags()[0];
},
getRepoTags() {
return this.RepoTags || ['<none>:<none>'];
},
getRepository() {
const tag = this.getDisplayTag() || '<none>:<none>';
return tag.split(':')[0] || '<none>';
},
getTag() {
const tag = this.getDisplayTag() || '<none>:<none>';
return tag.split(':')[1] || '<none>';
},
getDigest() {
if (this.Digest) {
return this.Digest;
}
if (this.RepoDigests && this.RepoDigests.length > 0) {
for (const rd of this.RepoDigests) {
if (rd.includes('@sha256:')) {
return rd.split('@')[1];
}
}
}
return null;
},
inspect() {
return ImageRPC.inspect(this.getID());
},
inspectManifest(ref) {
return ImageRPC.inspectManifest(ref || this.getDisplayTag());
},
remove() {
return ImageRPC.remove(this.getID());
},
update(onProgress) {
return this.streamPull(onProgress);
},
streamPull(onProgress) {
return new Promise((resolve, reject) => {
let handle;
handle = this._stream(
() => L.url('admin/podman/stream/pull') + '?reference=' + encodeURIComponent(this.getDisplayTag()),
(chunk) => {
if (chunk.images && chunk.images.length > 0) { resolve(chunk.images[0]); handle.stop(); return; }
if (chunk.error) { reject(new Error(chunk.error)); handle.stop(); return; }
if (chunk.stream) onProgress?.(chunk.stream);
if (chunk.raw) onProgress?.(chunk.raw + '\n');
}
);
});
},
});
return baseclass.extend({
getSingleton(image) {
return Image.extend(image).instantiate([]);
}
});
@@ -0,0 +1,112 @@
'use strict';
'require baseclass';
'require rpc';
'require ui';
'require podman.ui as podmanUI';
function checkResponse(response) {
if (response?.response >= 400) {
throw new Error(response.message);
}
return response;
}
function catchError(err) {
const msg = err?.message || String(err);
if (msg.includes('Access denied') || msg.includes('session is expired')) {
window.location.href = L.url('admin');
return;
}
ui.hideModal();
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
podmanUI.alert(msg, 'error');
throw err;
}
function processLines(buffer, onChunk) {
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
onChunk(JSON.parse(line));
} catch (e) {
onChunk({ raw: line.trimEnd() });
}
}
}
return buffer;
}
const Model = baseclass.extend({
__name: 'Podman.Model',
_stream(getUrl, onData, onDone) {
let stopped = false;
let currentController = null;
const doStream = async () => {
const decoder = new TextDecoder();
let backoff = 1000;
while (!stopped) {
currentController = new AbortController();
try {
const response = await fetch(getUrl(), { signal: currentController.signal });
if (response.status === 403) {
window.location.reload();
return;
}
const reader = response.body?.getReader();
let buffer = '';
while (!stopped) {
const { done, value } = await reader.read();
if (done) {
// Flush any trailing line the server sent without a final '\n'
if (buffer.trim() && !stopped)
processLines(buffer + '\n', onData);
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = processLines(buffer, onData);
}
backoff = 1000;
if (!stopped) onDone?.();
} catch (err) {
if (stopped) break; // AbortController fired from stop()
// network error - reconnect with backoff
await new Promise(r => setTimeout(r, backoff));
backoff = Math.min(backoff * 2, 30000);
}
}
};
doStream();
return {
stop: () => {
stopped = true;
if (currentController) currentController.abort();
}
};
},
});
return baseclass.extend({
base: Model,
declareRPC(options) {
const fn = rpc.declare(options);
return (...args) => fn(...args).then(checkResponse).catch(catchError);
},
// Like declareRPC but does not show the global error dialog on failure.
// Use for background checks where the caller handles the error itself.
declareRPCSilent(options) {
const fn = rpc.declare(options);
return (...args) => fn(...args).then(checkResponse);
},
});
@@ -0,0 +1,316 @@
'use strict';
'require baseclass';
'require network';
'require firewall';
'require uci';
'require ui';
'require podman.model.Model as Model';
const NetworkRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'network_inspect',
params: ['name']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'network_remove',
params: ['name', 'force']
}),
connect: Model.declareRPC({
object: 'podman',
method: 'network_connect',
params: ['name', 'data']
}),
disconnect: Model.declareRPC({
object: 'podman',
method: 'network_disconnect',
params: ['name', 'data']
}),
};
const Network = Model.base.extend({
__name__: 'Podman.Model.Network',
getID() {
return this.ID;
},
getName() {
return this.name || _('Unknown');
},
getDriver() {
return this.driver;
},
getSubnetIP4() {
if (this.subnets?.length > 0) {
return this.subnets[0].subnet || '';
}
if (this.IPAM?.Config?.length > 0) {
return this.IPAM.Config[0].Subnet || '';
}
return '';
},
getSubnetIP6() {
if (this.ipv6_enabled && this.subnets?.length > 1) {
return this.subnets[1].subnet || '';
}
return '';
},
getGatewayIP4() {
if (this.subnets?.length > 0) {
return this.subnets[0].gateway || '';
}
if (this.IPAM?.Config?.length > 0) {
return this.IPAM.Config[0].Gateway || '';
}
return '';
},
getGatewayIP6() {
if (this.ipv6_enabled && this.subnets?.length > 1) {
return this.subnets[1].gateway || '';
}
return '';
},
isBridge() {
return this.getDriver() === 'bridge';
},
async inspect() {
return NetworkRPC.inspect(this.getName());
},
async remove(force) {
return NetworkRPC.remove(this.getName(), force ?? false);
},
async connect(data) {
return NetworkRPC.connect(this.getName(), data || {});
},
async disconnect(data) {
return NetworkRPC.disconnect(this.getName(), data || {});
},
async integrationCheck() {
const check = {
hasInterface: false,
hasDevice: false,
hasDnsmasqExclusion: false,
};
const networkObj = await network.getNetwork(this.getName());
let deviceObj;
if (networkObj) {
check.hasInterface = true;
deviceObj = networkObj.getDevice();
if (deviceObj) {
check.hasDevice = true;
}
}
if (!deviceObj) {
const devices = (await network.getDevices()).map((device) => device.device);
if (devices.includes(this.network_interface)) {
check.hasDevice = true;
}
}
await uci.load('dhcp');
const mainSection = await this.hasDnsmasq() ? this._getDnsmasqSection() : null;
if (mainSection) {
const notInterfaces = uci.get('dhcp', mainSection, 'notinterface');
if ([].concat(notInterfaces ?? []).includes(this.getName())) {
check.hasDnsmasqExclusion = true;
}
} else {
check.hasDnsmasqExclusion = true;
}
return check;
},
async integrationRepair() {
await uci.load('dhcp');
const status = await this.integrationCheck();
if (status.hasInterface && status.hasDevice && status.hasDnsmasqExclusion) {
return;
}
let deviceName;
let changes = false;
if (status.hasDevice === false && this.isBridge()) {
deviceName = await this.integrationCreateDevice();
changes = true;
if (status.hasInterface === true) {
const net = await network.getNetwork(this.getName());
net.addDevice(deviceName);
}
}
if (status.hasInterface === false) {
if (status.hasDevice === true) {
deviceName = this.network_interface;
}
await this.integrationCreateNetwork(deviceName);
changes = true;
}
if (await this.hasDnsmasq() && status.hasDnsmasqExclusion === false) {
await this.integrationAddDnsmasqExclusion();
changes = true;
}
if (changes === false) {
return;
}
await uci.save();
ui.changes.apply();
await network.flushCache();
},
async integrationRemove() {
await uci.load('dhcp');
const status = await this.integrationCheck();
let changes = false;
// Check device shared status BEFORE deleting the interface to avoid stale network cache
let removeDevice = false;
if (status.hasDevice === true && this.isBridge()) {
const device = await network.getDevice(this.network_interface);
removeDevice = device?.getNetworks().length === 1;
}
if (status.hasInterface === true) {
const zone = await firewall.getZoneByNetwork(this.getName());
if (zone) await zone.deleteNetwork(this.getName());
await network.deleteNetwork(this.getName());
changes = true;
}
if (removeDevice) {
uci.remove('network', this.network_interface + '_device');
changes = true;
}
if (await this.hasDnsmasq() && status.hasDnsmasqExclusion === true) {
const mainSection = this._getDnsmasqSection();
if (mainSection) {
const notInterfaces = [].concat(uci.get('dhcp', mainSection, 'notinterface') || [])
.filter(iface => iface !== this.getName());
if (notInterfaces.length > 0)
uci.set('dhcp', mainSection, 'notinterface', notInterfaces);
else
uci.unset('dhcp', mainSection, 'notinterface');
changes = true;
}
}
if (changes === false) {
return;
}
await uci.save();
ui.changes.apply();
await network.flushCache();
},
async integrationCreateNetwork(deviceName) {
const networkName = this.getName();
if (this.subnets?.length === 0 && this.ipam_options?.driver === 'dhcp') {
uci.add('network', 'interface', networkName);
uci.set('network', networkName, 'proto', 'dhcp');
return;
}
const subnet = this.getSubnetIP4();
if (!subnet) return;
const prefix = parseInt(subnet.split('/')[1], 10);
const netmask = network.prefixToMask(prefix);
const sid = `${networkName}`;
uci.add('network', 'interface', sid);
uci.set('network', sid, 'proto', 'static');
if (deviceName) uci.set('network', sid, 'device', deviceName);
uci.set('network', sid, 'ipaddr', this.getGatewayIP4());
uci.set('network', sid, 'netmask', netmask);
if (this.ipv6_enabled) {
uci.set('network', sid, 'ip6addr', this.getGatewayIP6() + '/64');
}
},
async integrationCreateDevice() {
const devices = (await network.getDevices()).map((device) => device.device);
const baseName = this.network_interface || this.getName();
let i = 0;
let deviceName = baseName;
while (devices.includes(deviceName)) {
i++;
deviceName = `${baseName}${i}`;
}
const sid = `${deviceName}_device`;
uci.add('network', 'device', sid);
uci.set('network', sid, 'type', 'bridge');
uci.set('network', sid, 'name', deviceName);
uci.set('network', sid, 'bridge_empty', '1');
uci.set('network', sid, 'ipv6', this.ipv6_enabled ? '1' : '0');
if (this.ipv6_enabled) {
uci.set('network', sid, 'ip6assign', '64');
}
return deviceName;
},
_getDnsmasqSection() {
const sections = uci.sections('dhcp', 'dnsmasq');
return sections.length > 0 ? sections[0]['.name'] : null;
},
async hasDnsmasq() {
return L.hasSystemFeature('dnsmasq');
},
async integrationAddDnsmasqExclusion() {
const mainSection = this._getDnsmasqSection();
if (mainSection) {
const notInterfaces = [].concat(uci.get('dhcp', mainSection, 'notinterface') || []);
if (!notInterfaces.includes(this.getName())) {
notInterfaces.push(this.getName());
uci.set('dhcp', mainSection, 'notinterface', notInterfaces);
}
}
},
});
return baseclass.extend({
__name__: 'Podman.Model.Network',
getSingleton(data) {
return Network.extend(data).instantiate([]);
},
});
@@ -0,0 +1,182 @@
'use strict';
'require baseclass';
'require ui';
'require podman.model.Model as Model';
'require podman.ui as podmanUI';
const PodRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'pod_inspect',
params: ['name']
}),
start: Model.declareRPC({
object: 'podman',
method: 'pod_start',
params: ['name']
}),
stop: Model.declareRPC({
object: 'podman',
method: 'pod_stop',
params: ['name']
}),
kill: Model.declareRPCSilent({
object: 'podman',
method: 'pod_kill',
params: ['name', 'signal']
}),
restart: Model.declareRPC({
object: 'podman',
method: 'pod_restart',
params: ['name']
}),
pause: Model.declareRPC({
object: 'podman',
method: 'pod_pause',
params: ['name']
}),
unpause: Model.declareRPC({
object: 'podman',
method: 'pod_unpause',
params: ['name']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'pod_remove',
params: ['name', 'force']
}),
};
const Pod = Model.base.extend({
__name__: 'Podman.Model.Pod',
getID() {
return this.Id;
},
getName() {
return this.Name || this.Id || _('Unknown');
},
getStatus() {
return this.Status || this.State || '';
},
getStatusBadge() {
const status = this.getStatus();
const cssClass = status.toLowerCase();
return E('div', { class: `badge ${cssClass}` }, [ status ]);
},
getCreatedAt() {
return this.Created;
},
getInfraId() {
return this.InfraId || this.InfraContainerID;
},
getNetworks() {
return this.Networks || [];
},
getLabels() {
return this.Labels || {};
},
getContainers() {
return this.Containers || [];
},
isRunning() {
return this.getStatus() === 'Running';
},
isPaused() {
return this.getStatus() === 'Paused';
},
isStopped() {
const state = this.getStatus();
return state === 'Stopped' || state === 'Exited' || state === 'Created' || state === 'Dead';
},
async inspect() {
return PodRPC.inspect(this.getName());
},
async start() {
return PodRPC.start(this.getName());
},
async stop() {
const result = await PodRPC.stop(this.getName());
if (result?.Id) return result;
try {
return await PodRPC.kill(this.getName(), 'SIGKILL');
} catch (err) {
const msg = String(err?.message || err);
if (msg.includes('no running containers')) return result;
ui.hideModal();
podmanUI.alert(msg, 'error');
throw err;
}
},
async restart() {
return PodRPC.restart(this.getName());
},
async pause() {
return PodRPC.pause(this.getName());
},
async unpause() {
return PodRPC.unpause(this.getName());
},
async remove(force) {
return PodRPC.remove(this.getName(), force ?? true);
},
streamTop(onChunk, delay) {
const params = new URLSearchParams({ delay: String(delay || 2) });
const url = L.url('admin/podman/stream/pod_top', this.getName()) + '?' + params;
return this._stream(
() => url,
(data) => { if (!data.raw && !data.Error) onChunk(data); }
);
},
streamStats(onChunk) {
const url = L.url('admin/podman/stream/pod_stats', this.getName());
return this._stream(
() => url,
(data) => {
if (!Array.isArray(data)) return;
onChunk(data);
}
);
},
getDetailLink(label) {
return E('a', { href: L.url('admin/podman/pod', this.getID()) }, label || this.getName());
},
});
return baseclass.extend({
__name__: 'Podman.Model.Pod',
getSingleton(data) {
return Pod.extend(data).instantiate([]);
},
});
@@ -0,0 +1,53 @@
'use strict';
'require baseclass';
'require podman.model.Model as Model';
const SecretRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'secret_inspect',
params: ['name']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'secret_remove',
params: ['name']
}),
};
const Secret = Model.base.extend({
__name__: 'Podman.Model.Secret',
getID() {
return this.ID;
},
getName() {
return this.Spec?.Name;
},
getDriver() {
return this.Spec?.Driver?.Name;
},
getLabels() {
return this.Spec?.Labels ?? {};
},
async inspect() {
return SecretRPC.inspect(this.getName());
},
async remove() {
return SecretRPC.remove(this.getName());
},
});
return baseclass.extend({
getSingleton(secret) {
return Secret.extend(secret).instantiate([]);
},
});
@@ -0,0 +1,41 @@
'use strict';
'require baseclass';
'require podman.model.Model as Model';
const VolumeRPC = {
inspect: Model.declareRPC({
object: 'podman',
method: 'volume_inspect',
params: ['name']
}),
remove: Model.declareRPC({
object: 'podman',
method: 'volume_remove',
params: ['name', 'force']
}),
};
const Volume = Model.base.extend({
__name__: 'Podman.Model.Volume',
getName() {
return this.Name || _('Unknown');
},
async inspect() {
return VolumeRPC.inspect(this.getName());
},
async remove(force) {
return VolumeRPC.remove(this.getName(), force || false);
},
});
return baseclass.extend({
getSingleton(volume) {
return Volume.extend(volume).instantiate([]);
},
});
@@ -0,0 +1,187 @@
'use strict';
'require baseclass';
'require podman.model.Model as Model';
'require podman.model.Container as Container';
'require podman.model.Image as Image';
'require podman.model.Network as Network';
'require podman.model.Volume as Volume';
'require podman.model.Secret as Secret';
'require podman.model.Pod as Pod';
return baseclass.extend({
/**
* Container methods.
*/
containers: {
list: Model.declareRPC({
object: 'podman',
method: 'containers_list',
params: ['query'],
expect: {
data: []
},
filter: (containers) => containers
.map((container) => Container.getSingleton(container))
.sort((containerA, containerB) => containerA
.getName()
.localeCompare(containerB.getName()))
}),
create: Model.declareRPC({
object: 'podman',
method: 'container_create',
params: ['data']
}),
},
/**
* Image methods.
*/
images: {
list: Model.declareRPC({
object: 'podman',
method: 'images_list',
params: [],
expect: {
data: []
},
filter: (images) => images
.map((image) => Image.getSingleton(image))
.sort((imageA, imageB) => imageA.getDisplayTag().localeCompare(imageB.getDisplayTag())),
}),
},
/**
* Pod methods.
*/
pods: {
list: Model.declareRPC({
object: 'podman',
method: 'pods_list',
params: [],
expect: {
data: []
},
filter: (pods) => pods
.map((pod) => Pod.getSingleton(pod))
.sort((a, b) => a.getName().localeCompare(b.getName()))
}),
create: Model.declareRPC({
object: 'podman',
method: 'pod_create',
params: ['data']
}),
},
/**
* Network management methods.
*/
networks: {
list: Model.declareRPC({
object: 'podman',
method: 'networks_list',
params: [],
expect: {
data: []
},
filter: (networks) => networks
.map((network) => Network.getSingleton(network))
.sort((networkA, networkB) => networkA.getName().localeCompare(networkB.getName())),
}),
create: Model.declareRPC({
object: 'podman',
method: 'network_create',
params: ['data']
}),
},
/**
* Volume management methods.
*/
volumes: {
list: Model.declareRPC({
object: 'podman',
method: 'volumes_list',
params: [],
expect: {
data: []
},
filter: (volumes) => volumes
.map((volume) => Volume.getSingleton(volume))
.sort((volumeA, volumeB) => volumeA.getName().localeCompare(volumeB.getName()))
}),
create: Model.declareRPC({
object: 'podman',
method: 'volume_create',
params: ['data']
}),
import: Model.declareRPC({
object: 'podman',
method: 'volume_import',
params: ['name', 'compressed']
}),
},
/**
* Secret management methods.
*/
secrets: {
list: Model.declareRPC({
object: 'podman',
method: 'secrets_list',
params: [],
expect: {
data: []
},
filter: (secrets) => secrets
.map((secret) => Secret.getSingleton(secret))
.sort((secretA, secretB) => secretA.getName().localeCompare(secretB.getName()))
}),
create: Model.declareRPC({
object: 'podman',
method: 'secret_create',
params: ['name', 'data', 'labels']
}),
},
/**
* System information methods.
*/
system: {
version: Model.declareRPC({
object: 'podman',
method: 'version',
params: []
}),
info: Model.declareRPC({
object: 'podman',
method: 'info',
params: []
}),
df: Model.declareRPC({
object: 'podman',
method: 'system_df',
params: []
}),
prune: Model.declareRPC({
object: 'podman',
method: 'system_prune',
params: ['all', 'volumes']
}),
debug: Model.declareRPC({
object: 'podman',
method: 'system_debug',
params: []
})
},
});
@@ -0,0 +1,559 @@
'use strict';
'require baseclass';
'require ui';
'require podman.constants as c';
const AbstractInputField = ui.Textfield.extend({
__name__: 'Podman.UI.AbstractInputField',
inputType: 'text',
__init__(value, options) {
this.value = value;
this.options = Object.assign({ min: '', max: '' }, options);
},
render() {
const frameEl = E('div', { id: this.options.id });
const inputEl = E('input', {
id: this.options.id ? `widget.${this.options.id}` : null,
name: this.options.name,
type: this.inputType,
class: 'cbi-input-text',
readonly: this.options.readonly ? '' : null,
disabled: this.options.disabled ? '' : null,
min: this.options.min,
max: this.options.max,
placeholder: this.options.placeholder,
value: this.value,
});
frameEl.appendChild(inputEl);
return this.bind(frameEl);
},
});
const UINumberfield = AbstractInputField.extend({
__name__: 'Podman.UI.Numberfield',
inputType: 'number',
});
const UIDatefield = AbstractInputField.extend({
__name__: 'Podman.UI.Datefield',
inputType: 'date',
});
const UIBase = baseclass.extend({
__name__: 'Podman.UI',
alert(text, type, timeLimited) {
timeLimited = timeLimited === true ? c.NOTIFICATION_TIMEOUT : timeLimited;
type = type || 'info';
if (Array.isArray(text) !== true) {
text = E('p', {}, text);
}
if (parseInt(timeLimited) > 0) {
ui.addTimeLimitedNotification(type.toUpperCase(), text, timeLimited, type);
return;
}
ui.addNotification(type.toUpperCase(), text, type);
},
showSpinningModal(title, text) {
ui.showModal(title, [E('div', { class: 'spinning' }, text)], 'loading-modal');
},
});
const UITooltip = baseclass.extend({
__name__: 'Podman.UI.Tooltip',
node: '',
tooltip: '',
options: {},
__init__(node, tooltip, options) {
this.node = node || '';
this.tooltip = tooltip || '';
this.options = options || {};
},
render() {
const cssClass = 'cbi-tooltip-container';
const cls = this.options.class ? `${this.options.class} ${cssClass}` : cssClass;
const options = Object.assign({}, this.options, { class: cls });
return E('div', options, [
this.node,
E('div', { class: 'cbi-tooltip' }, this.tooltip),
]);
}
});
const UISecretText = baseclass.extend({
__name__: 'Podman.UI.SecretText',
value: '',
censoredValue: '',
show: false,
__init__(value, censoredValue) {
this.value = value;
this.censoredValue = censoredValue;
},
render() {
const me = this;
return new UITooltip(
me.censoredValue,
_('Click to reveal/hide value'),
{
class: 'tooltip',
click() {
if (me.show) {
me.show = false;
this.childNodes[0].nodeValue = me.censoredValue;
return;
}
me.show = true;
this.childNodes[0].nodeValue = me.value;
},
}
).render();
}
});
const UIButton = baseclass.extend({
__name__: 'Podman.UI.Button',
__init__(text, href, cssClass, tooltip) {
this.text = text;
this.href = href;
this.cssClass = cssClass;
this.tooltip = tooltip;
},
render() {
const attrs = {
class: this.cssClass ? 'cbi-button cbi-button-' + this.cssClass : 'cbi-button',
click: typeof this.href === 'function' ? this.href : (ev) => {
ev.preventDefault();
window.location.href = this.href;
}
};
if (this.tooltip) {
attrs.title = this.tooltip;
}
return E('button', attrs, this.text || '');
}
});
const UIButtonNew = baseclass.extend({
__name__: 'Podman.UI.ButtonNew',
__init__(text, options) {
this.options = Object.assign({
text: text || '',
// class: ''
type: '',
href: '',
click: () => { },
tooltip: '',
}, options);
},
render() {
const click = this.options.href
? ui.createHandlerFn(this, (event) => {
event.preventDefault();
window.location.href = this.options.href;
})
: this.options.click;
const button = E('button', {
class: `cbi-button cbi-button-${this.options.type}`,
click,
}, this.options.text);
if (this.options.tooltip) {
return new UITooltip(button, this.options.tooltip).render();
}
return button;
}
});
const UIJsonArea = baseclass.extend({
__name__: 'Podman.UI.JsonArea',
__init__(data) {
this.data = data || this.data;
},
render() {
const el = E('pre', { class: 'json-area' });
const json = JSON.stringify(this.data, null, 2);
const nodes = [];
let last = 0;
json.replace(
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
(match, _p1, _p2, _p3, _p4, offset) => {
if (offset > last)
nodes.push(document.createTextNode(json.slice(last, offset)));
let cls = 'number';
if (/^"/.test(match)) cls = /:$/.test(match) ? 'key' : 'string';
else if (/true|false/.test(match)) cls = 'boolean';
else if (/null/.test(match)) cls = 'null';
nodes.push(E('span', { 'class': cls }, match));
last = offset + match.length;
}
);
if (last < json.length)
nodes.push(document.createTextNode(json.slice(last)));
for (const node of nodes) el.appendChild(node);
return el;
},
});
const UIBashCodeArea = baseclass.extend({
__name__: 'Podman.UI.BashCodeArea',
code: '',
__init__(code) {
this.code = code || this.code;
},
render() {
const el = E('pre', { class: 'code-area bash' });
const code = this.code || '';
const nodes = [];
let last = 0;
const bashRegex = /(#[^\n]*)|("(?:\\.|[^\\"])*"|'(?:\\.|[^\\'])*')|(\$\w+|\$\{[^}]+\})|\b(if|then|else|elif|fi|case|esac|for|while|do|done|in|break|continue|exit|return|stop_service|start_service)\b|(\b\d+\b)/g;
code.replace(bashRegex, (match, comment, string, variable, keyword, number, offset) => {
if (offset > last)
nodes.push(document.createTextNode(code.slice(last, offset)));
let cls = null;
if (comment) cls = 'comment';
else if (string) cls = 'string';
else if (variable) cls = 'variable';
else if (keyword) cls = 'keyword';
else if (number) cls = 'number';
nodes.push(cls ? E('span', { 'class': cls }, match) : document.createTextNode(match));
last = offset + match.length;
});
if (last < code.length)
nodes.push(document.createTextNode(code.slice(last)));
for (const node of nodes) el.appendChild(node);
return el;
},
});
/**
* Scrolling text log for streaming output. Each append() is one logical
* update: lines containing CR overwrite the last line (Podman pull progress
* uses CR to refresh "Copying blob X 50%"); plain lines append. Sticky
* auto-scroll: jumps to bottom on append unless the user scrolled up.
*/
const UIStreamLog = baseclass.extend({
__name__: 'Podman.UI.StreamLog',
maxLines: 2000,
autoScroll: true,
__init__(opts) {
opts = opts || {};
if (typeof opts.maxLines === 'number') this.maxLines = opts.maxLines;
if (typeof opts.autoScroll === 'boolean') this.autoScroll = opts.autoScroll;
this._container = E('div', { class: 'podman-stream-log' });
this._lastLineNode = null;
this._lineCount = 0;
},
append(text) {
if (text == null) return this;
const stripped = String(text).replace(/\n+$/, '');
if (!stripped) return this;
const stick = this.autoScroll && this._isAtBottom();
const cr = stripped.lastIndexOf('\r');
const isProgress = cr >= 0;
const display = isProgress ? stripped.slice(cr + 1) : stripped;
if (isProgress && this._lastLineNode) {
this._lastLineNode.textContent = display;
} else {
this._appendLine(display);
}
if (stick) this._container.scrollTop = this._container.scrollHeight;
return this;
},
appendBlock(lines) {
if (Array.isArray(lines)) for (const l of lines) this.append(l);
return this;
},
clear() {
while (this._container.firstChild) this._container.removeChild(this._container.firstChild);
this._lastLineNode = null;
this._lineCount = 0;
return this;
},
render() {
return this._container;
},
_appendLine(text) {
const node = E('div', { class: 'log-line' }, [text || ' ']);
this._container.appendChild(node);
this._lastLineNode = node;
this._lineCount++;
while (this._lineCount > this.maxLines && this._container.firstChild) {
const removed = this._container.firstChild;
this._container.removeChild(removed);
this._lineCount--;
if (this._lastLineNode === removed) this._lastLineNode = this._container.lastChild;
}
},
_isAtBottom() {
const el = this._container;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 5;
}
});
/**
* Table builder with header and row chaining.
*/
const UITable = baseclass.extend({
__name__: 'Podman.UI.Table',
options: { class: 'table' },
headers: [],
rows: [],
__init__(options) {
this.headers = [];
this.rows = [];
const baseClass = this.options.class;
this.options = Object.assign({}, this.options, options);
if (options?.class) this.options.class = `${baseClass} ${options.class}`;
},
addHeader(header, options) {
this.headers.push({
inner: header,
options: Object.assign({ class: 'th' }, options || {}),
});
return this;
},
setHeaders(headers) {
this.headers = headers;
return this;
},
addRow(cells, options) {
this.rows.push({
cells,
options: Object.assign({ class: 'tr' }, options || {}),
});
return this;
},
setRows(rows) {
this.rows = rows;
return this;
},
render() {
let headerRow = '';
if (Array.isArray(this.headers) && this.headers.length > 0) {
headerRow = E('tr', { class: 'tr table-titles' },
this.headers.map((header) => E('th', header.options, header.inner))
);
}
let rows = '';
if (Array.isArray(this.rows) && this.rows.length > 0) {
rows = this.rows.map((row) => E('tr', row.options,
row.cells.map((cell) => E('td', Object.assign({ class: 'td' }, cell.options || {}), cell.inner))
));
}
return E('table', this.options, [headerRow].concat(rows));
}
});
const UITableList = UITable.extend({
__name__: 'Podman.UI.TableList',
options: { class: 'table table-list' },
addRow(label, value, rowOptions) {
return this.super('addRow', [[
{ inner: label },
{ inner: value }
], rowOptions]);
},
});
/**
* Tabbed interface using LuCI tabs API.
*/
const UITabs = baseclass.extend({
__name__: 'Podman.UI.Tabs',
tabs: [],
activeTab: null,
__init__(activeTab) {
this.tabs = [];
this.activeTab = activeTab || null;
},
addTab(id, title, content, active) {
this.tabs.push({
id,
title,
content,
active: active || false
});
return this;
},
render() {
const tabPanes = this.tabs.map((tab, index) => {
const isActive = tab.active || (!this.activeTab && index === 0) || (this.activeTab === tab.id);
return E('div', {
class: 'tab-pane',
'data-tab': tab.id,
'data-tab-title': tab.title,
'data-tab-active': isActive ? 'true' : null
}, [tab.content || E('div', { class: 'cbi-section' }, E('div', { class: 'cbi-section-node' }))]);
});
const tabContainer = E('div', { class: 'cbi-section' }, [
E('div', { class: 'cbi-section-node' }, [
E('div', { class: 'tab-panes' }, tabPanes)
])
]);
requestAnimationFrame(() => {
const panes = tabContainer.querySelectorAll('.tab-pane');
ui.tabs.initTabGroup(panes);
});
return tabContainer;
}
});
const UIModal = baseclass.extend({
__name__: 'Podman.UI.Modal',
title: '',
content: null,
extraClasses: [],
__init__(title, content, extraClasses) {
this.title = title || this.title;
this.content = content || this.content;
this.extraClasses = extraClasses || this.extraClasses;
},
render() {
ui.showModal(this.title, [
E('div', {}, this.getContent()),
E('div', { class: 'd-flex justify-end mt-sm gap-5' }, this.getButtons()),
], ...this.extraClasses);
},
getContent() {
return this.content;
},
getButtons() {
const buttons = [];
if (typeof this.handleClose === 'function') {
buttons.push(this.getCloseButton());
}
if (typeof this.handleSubmit === 'function') {
buttons.push(this.getOkButton());
}
return buttons;
},
async handleClose() {
ui.hideModal();
},
async handleSubmit() {
ui.hideModal();
},
getOkButton() {
return new UIButtonNew(_('OK'), {
click: ui.createHandlerFn(this, 'handleSubmit'),
type: 'positive',
}).render();
},
getCloseButton() {
return new UIButtonNew(_('Close'), {
click: ui.createHandlerFn(this, 'handleClose'),
type: 'negative',
}).render();
},
});
const PodmanUI = UIBase.extend({
__name__: 'Podman.UI',
Modal: UIModal,
Button: UIButton,
ButtonNew: UIButtonNew,
// MultiButton: UIMultiButton,
Numberfield: UINumberfield,
Datefield: UIDatefield,
SecretText: UISecretText,
JsonArea: UIJsonArea,
BashCodeArea: UIBashCodeArea,
Table: UITable,
TableList: UITableList,
Tabs: UITabs,
Tooltip: UITooltip,
StreamLog: UIStreamLog,
});
return PodmanUI;
@@ -0,0 +1,238 @@
'use strict';
'require baseclass';
const UtilIpv6 = baseclass.extend({
/**
* Derive ULA IPv6 subnet and gateway from IPv4 subnet.
* Uses IPv4 octets 3-4 as subnet ID for consistent IPv4/IPv6 mapping.
* @param {string} ipv4 - IPv4 subnet in CIDR (e.g., "192.168.20.0/24")
* @param {string} ula_prefix - ULA prefix (e.g., "fd52:425:78eb::/48")
* @returns {{ipv6subnet: string, ipv6gateway: string}} IPv6 config with subnet/gateway
*/
deriveUlaFromIpv4(ipv4, ula_prefix) {
const ipv4Address = ipv4.split('/')[0];
const octets = ipv4Address.split('.').map(Number);
const subnetIdHex = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, '0');
const ulaAddress = ula_prefix.split('/')[0];
const ulaParts = ulaAddress.split('::');
let hextets = ulaParts[0].split(':');
if (hextets.length === 1 && hextets[0] === "") {
hextets = [];
}
while (hextets.length < 3) {
hextets.push('0');
}
const ipv6SubnetAddress = `${hextets.slice(0, 3).join(':')}:${subnetIdHex}::`;
return {
ipv6subnet: `${ipv6SubnetAddress}/64`,
ipv6gateway: `${ipv6SubnetAddress}1`
};
},
});
const UtilIpv4 = baseclass.extend({
// >>> 0 forces unsigned 32-bit interpretation (JS bitwise ops are signed)
_ipToInt(octets) {
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
},
_intToIp(n) {
return [(n >>> 24) & 0xFF, (n >>> 16) & 0xFF, (n >>> 8) & 0xFF, n & 0xFF].join('.');
},
/**
* Convert a CIDR string to a start/end IP range.
* Works for both network addresses (10.89.0.0/24) and host addresses
* within a subnet (10.89.0.5/24) - the mask normalises either to the
* full network range.
* @param {string} cidr - CIDR notation (e.g., "10.89.0.128/25")
* @returns {{start_ip: string, end_ip: string}|null} Range or null if invalid
*/
cidrToRange(cidr) {
if (!cidr || typeof cidr !== 'string') return null;
const parts = cidr.split('/');
if (parts.length !== 2) return null;
const octets = parts[0].split('.').map(Number);
const prefixLen = parseInt(parts[1], 10);
if (octets.length !== 4 || octets.some(o => isNaN(o) || o < 0 || o > 255)) return null;
if (isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) return null;
const ipInt = this._ipToInt(octets);
const mask = prefixLen === 0 ? 0 : (0xFFFFFFFF << (32 - prefixLen)) >>> 0;
return {
start_ip: this._intToIp((ipInt & mask) >>> 0),
end_ip: this._intToIp((ipInt | (~mask >>> 0)) >>> 0),
};
},
/**
* Return the first usable host address in a subnet (network address + 1).
* Conventionally used as the gateway address.
* @param {string} cidr - CIDR notation (e.g., "10.89.0.0/24")
* @returns {string|null} First host IP (e.g., "10.89.0.1") or null if invalid
*/
firstHost(cidr) {
const range = this.cidrToRange(cidr);
if (!range) return null;
const octets = range.start_ip.split('.').map(Number);
const next = (this._ipToInt(octets) + 1) >>> 0;
return this._intToIp(next);
},
});
const UtilFormat = baseclass.extend({
__name__: 'Podman.Util.Format',
/**
* Format bytes to human-readable size
*/
bytes(bytes, decimals) {
if (!bytes) return '0 B';
decimals = decimals ?? 2;
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / k ** i).toFixed(decimals)) + ' ' + sizes[i];
},
/**
* Format timestamp to locale string with leading zeros
*/
date(timestamp) {
if (!timestamp) {
return _('Never');
}
let date;
if (typeof timestamp === 'string') {
if (timestamp === '0001-01-01T00:00:00Z' || timestamp.startsWith('0001-')) {
return _('Never');
}
date = new Date(timestamp);
} else {
date = new Date(timestamp * 1000);
}
if (isNaN(date.getTime())) {
return _('Unknown');
}
if (date.getFullYear() < 1970) {
return _('Never');
}
const day = ('0' + date.getDate()).slice(-2);
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const year = date.getFullYear();
const hours = ('0' + date.getHours()).slice(-2);
const minutes = ('0' + date.getMinutes()).slice(-2);
const seconds = ('0' + date.getSeconds()).slice(-2);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
},
/**
* Parse memory string to bytes
* Supports formats: 512m, 1g, 2gb, 1024, etc.
* @param {string} memStr - Memory string (e.g., "512m", "1g", "2gb")
* @returns {number} Size in bytes
*/
parseMemory(memStr) {
if (!memStr) return 0;
const match = memStr.match(/^(\d+(?:\.\d+)?)\s*([kmgt]?b?)?$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'b').toLowerCase();
const multipliers = {
b: 1,
k: 1024,
kb: 1024,
m: 1024 * 1024,
mb: 1024 * 1024,
g: 1024 * 1024 * 1024,
gb: 1024 * 1024 * 1024,
t: 1024 * 1024 * 1024 * 1024,
tb: 1024 * 1024 * 1024 * 1024
};
return Math.floor(value * (multipliers[unit] || 1));
},
/**
* Parse duration string to nanoseconds (Podman format)
* Supports formats: 30s, 1m, 1h, 500ms, etc.
* @param {string} duration - Duration string (e.g., "30s", "1m", "1h")
* @returns {number} Duration in nanoseconds, or 0 if invalid
*/
parseDuration(duration) {
if (!duration) return 0;
const match = duration.match(/^(\d+(?:\.\d+)?)\s*(ns|us|ms|s|m|h)$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2];
const multipliers = {
ns: 1,
us: 1000,
ms: 1000000,
s: 1000000000,
m: 60000000000,
h: 3600000000000
};
return Math.floor(value * (multipliers[unit] || 0));
},
});
/**
* Shared utility functions.
*/
return baseclass.extend({
ipv4: new UtilIpv4(),
ipv6: new UtilIpv6(),
format: new UtilFormat(),
truncate(str, maxLength) {
if (!str || str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength) + '...';
},
moveArrayItem(arr, fromIndex, toIndex) {
const [item] = arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, item);
return arr;
},
_p(stringPlural) {
return this._n(2, '', stringPlural);
},
_n(length, stringSingular, stringPlural) {
return parseInt(length) === 1 ?
stringSingular : stringPlural;
},
});
@@ -0,0 +1,387 @@
'use strict';
'require baseclass';
'require view';
'require form';
'require dom';
'require ui';
'require podman.ui as podmanUI';
'require podman.constants as constant';
const ViewDefault = {
scrollToTop() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
},
loading(text) {
this.scrollToTop();
podmanUI.showSpinningModal(null, text || _('Loading...'));
},
confirm(content, onConfirm) {
const modal = new podmanUI.Modal(_('Confirm'), content, [ 'confirm-modal' ]);
modal.getCloseButton = () => new podmanUI.ButtonNew(_('Cancel'), {
click: () => ui.hideModal(),
type: 'negative',
}).render();
modal.handleSubmit = onConfirm;
modal.render();
},
error(message) {
this.scrollToTop();
ui.hideModal();
podmanUI.alert(message, 'error');
},
warning(message) {
this.scrollToTop();
ui.hideModal();
podmanUI.alert(message, 'warning', true);
},
success(message) {
this.scrollToTop();
ui.hideModal();
podmanUI.alert(message, 'success', true);
},
};
const ViewBase = view.extend({
...ViewDefault,
__name__: 'PodmanView.Base',
handleSaveApply: null,
handleSave: null,
handleReset: null,
__init__() {
document.querySelector('head').appendChild(E('link', {
rel: 'stylesheet',
type: 'text/css',
href: L.resource('view/podman/podman.css'),
}));
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') ui.hideModal();
});
this.super('__init__', []);
},
});
const ViewList = ViewBase.extend({
__name__: 'Podman.View.List',
map: null,
section: null,
title: '',
sectionName: '',
sectionHeadline: '',
TableSelectSection: null,
async render(data) {
const listData = {};
listData[this.sectionName] = data;
await this.initSection(listData);
await this.updateSection();
return this.transformMap(await this.map.render());
},
transformMap(map) {
return map;
},
initTableSelectSection(data) {
this.map = new form.JSONMap(data, this.title);
this.section = this.map.section(
this.TableSelectSection,
this.sectionName,
'',
this.sectionHeadline || _('Manage podman %s').format(this.title.toLowerCase())
);
this.section.view = this;
this.section.anonymous = true;
this.section.footer = true;
return this.section;
},
async initSection(data) {
if (!this.TableSelectSection) {
// Require on-the-fly to avoid circular dependency
const mod = await L.require('podman.TableSelectSection');
this.TableSelectSection = mod.TableSelectSection;
}
return this.initTableSelectSection(data);
},
getSection() {
return this.section;
},
});
/**
* Form View
*/
const ViewForm = baseclass.extend({
...ViewDefault,
__name__: 'Podman.View.Form',
map: null,
section: null,
__init__() {
},
makeData() {
return {};
},
async render() {
const data = this.makeData();
const sectionName = Object.keys(data)[0];
this.map = new form.JSONMap(data, '', '');
this.section = this.map.section(form.TypedSection, sectionName, '');
this.section.anonymous = true;
await this.createForm();
return this.map.render();
},
createForm() {},
getField(name) {
return this.section?.getOption(name);
},
getFields() {
return this.section?.getOption() || {};
},
getFieldValue(name) {
return this.getField(name)?.formvalue(this.section?.sectiontype);
},
getFieldValues() {
const values = {};
const fields = this.getFields();
Object.keys(fields).forEach((name) => {
values[name] = fields[name].formvalue(this.section?.sectiontype);
});
return values;
},
isValid() {
const fields = this.getFields();
const sectionId = this.section.sectiontype;
for (const [_key, field] of Object.entries(fields)) {
if (field.isValid(sectionId) === false) {
return false;
}
}
return true;
},
scrollToInvalid() {
const invalidElement = this.map.root.querySelector('.cbi-input-invalid');
invalidElement.scrollIntoView();
},
async save() {
try {
await this.map.save(() => {}, true);
} catch (error) {
return false;
}
return true;
},
async handleCreate(createFn, title, textLoading, textSuccess) {
ui.hideModal();
const loadingText = textLoading || _('Creating %s').format(title);
const successText = textSuccess || _('%s created successfully').format(title);
this.loading(loadingText);
await createFn();
ui.hideModal();
if (successText !== false) {
this.success(successText);
}
},
});
const ViewTabs = ViewBase.extend({
__name__: 'Podman.View.Tabs',
tabs: null,
__init__() {
this.tabs = new podmanUI.Tabs('info');
this.super('__init__', []);
},
renderTab(tab, content, description) {
const tabContainer = document.querySelector(`.tab-pane[data-tab="${tab}"]`);
const tabContainerNode = tabContainer?.querySelector('.cbi-section-node');
if (!tabContainerNode) return;
if (description) {
tabContainer.insertBefore(E('div', {
class: 'cbi-section-descr'
}, description), tabContainer.firstChild);
}
dom.content(tabContainerNode, content);
},
getTabInstance(name) {
const tabNode = document.querySelector(`.tab-pane[data-tab="${name}"]`);
return tabNode ? dom.findClassInstance(tabNode) : null;
},
});
const ViewContainer = ViewTabs.extend({
__name__: 'Podman.View.Container',
listUrl: '#',
async render() {
window.addEventListener('pagehide', () => this.stopStreams(), { once: true });
return E('div', {}, [ this.createHeader(), this.tabs.render() ]);
},
createHeader(name, isRunning, isStopped, isPaused) {
return E('div', { class: 'mb-sm container-toolbar' }, [
E('div', { class: 'd-flex align-start' }, [
E('h2', { class: 'mb-sm' }, [ name ]),
new podmanUI.ButtonNew(constant.ICON.BACK, {
click: () => this.redirectToList(),
type: 'none',
}).render(),
]),
E('div', { class: 'd-flex align-center' }, [
new podmanUI.ButtonNew(constant.ICON.START, {
click: ui.createHandlerFn(this, 'handleStart'),
type: isRunning ? 'active' : '',
}).render(),
new podmanUI.ButtonNew(constant.ICON.STOP, {
click: ui.createHandlerFn(this, 'handleStop'),
type: isStopped ? 'active' : '',
}).render(),
new podmanUI.ButtonNew(constant.ICON.RESTART, {
click: ui.createHandlerFn(this, 'handleRestart'),
}).render(),
new podmanUI.ButtonNew(constant.ICON.PAUSE, {
click: ui.createHandlerFn(this, 'handlePause'),
type: isPaused ? 'active' : '',
}).render(),
new podmanUI.ButtonNew(_('Delete'), {
click: ui.createHandlerFn(this, 'handleRemove'),
type: 'negative',
}).render(),
]),
]);
},
redirectToList() {
this.stopStreams();
window.location.href = this.listUrl;
},
stopStreams() {
},
});
const ViewTabContent = baseclass.extend({
...ViewDefault,
__name__: 'PodmanView.TabContent',
tab: null,
active: false,
__init__(tab) {
this.tab = tab || this.tab;
this.super('__init__', []);
if (!this.tab) {
return;
}
let attempts = 0;
new Promise((resolve, reject) => {
const check = () => {
if (attempts++ > 100) { reject(); return; }
const element = document.querySelector(`[data-tab="${this.tab}"]`);
if (element) resolve(element);
else setTimeout(check, 100);
};
check();
}).then(() => {
const mainNode = document.querySelector(`.tab-pane[data-tab="${this.tab}"]`);
dom.bindClassInstance(mainNode, this);
const mutationObserver = new MutationObserver((mutationsList) => {
let handled = false;
mutationsList.forEach((mutation) => {
const setActive = mutation.target.dataset.tabActive === 'true';
if (handled || mutation.attributeName !== 'data-tab-active') return;
handled = true;
if (this.active === setActive) {
return;
}
if (setActive) {
this.active = true;
this.onTabActive();
return;
}
this.active = false;
this.onTabInactive();
});
});
mutationObserver.observe(mainNode, { attributes: true });
}).catch(() => {});
},
async onTabActive() { },
async onTabInactive() { },
renderTabContent(headline, content) {
return E('div', {}, [
E('h4', {}, headline),
...(content || []),
]);
},
warningContent(message) {
return E('div', { class: 'alert-message d-flex justify-center p-sm' }, message);
},
});
return baseclass.extend({
base: ViewBase,
list: ViewList,
form: ViewForm,
container: ViewContainer,
tabContent: ViewTabContent,
});
@@ -0,0 +1,388 @@
'use strict';
'require ui';
'require podman.ui as podmanUI';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.rpc as podmanRPC';
'require podman.utils as utils';
'require podman.model.Container as Container';
const CENSORED_VALUE = '••••••••';
return podmanView.tabContent.extend({
tab: 'info',
container: null,
initScriptStatus: '',
async render(container) {
this.container = container;
const sections = await Promise.all([
this.basicSection(),
this.configSection(),
this.networkSection(),
this.envSection(),
this.mountSection(),
]);
this.updateInitScriptIcon(sections[0]);
return sections;
},
basicSection() {
const basicTable = new podmanUI.TableList();
const editableNameField = new ui.Textfield(this.container.getName(), { name: 'container-name' }).render();
const editableName = new podmanForm.EditableField('container-name', editableNameField);
editableName.onSubmit = (value) => this.handleUpdateName(value);
const policies = {
'no': _('No'),
'always': _('Always'),
'on-failure': _('On Failure'),
'unless-stopped': _('Unless Stopped')
};
const currentPolicy = this.container.getRestartPolicyName();
const editableRestartField = new ui.Select(currentPolicy, policies, { name: 'container-restart' }).render();
const editableRestart = new podmanForm.EditableField('container-restart', editableRestartField);
editableRestart.onSubmit = (value) => this.handleUpdateRestartPolicy(value);
basicTable
.addRow(_('Name'), editableName.render())
.addRow(_('ID'), this.container.getID().substring(0, 64))
.addRow(_('Image'), new podmanUI.Tooltip(this.container.getImageName(), this.container.Image, { class: 'tooltip' }).render())
.addRow(_('Tag'), this.container.getImageTag())
.addRow(_('Status'), this.container.getStateBadge())
.addRow(_('Created'), this.container.getCreated(true))
.addRow(_('Started'), this.container.getStartedAt(true))
.addRow(_('Restart Policy'), editableRestart.render())
.addRow(_('Auto Update'), this.container.getAutoUpdateLabel() || _('Disabled'))
.addRow(_('Init Script'), E('div', {
class: `init-script-form align-center ${this.container.getRestartPolicyName() === 'no' ? 'd-none' : 'd-flex'}`
}, [
new podmanUI.Tooltip(
new podmanUI.ButtonNew(
this.container.getAutoStartStatusIcon(),
{
click: ui.createHandlerFn(this, 'handleToggleInitScript'),
}
).render(),
_('Click to enable/disable'),
{
class: 'cursor-pointer',
}
).render(),
new podmanUI.ButtonNew(_('Show'), {
type: 'none',
click: ui.createHandlerFn(this, 'handleShowInitScript'),
}).render(),
new podmanUI.ButtonNew(_('Generate'), {
type: 'none',
click: ui.createHandlerFn(this, 'handleGenerateInitScript'),
}).render(),
]))
;
return E('div', {}, [
E('h4', {}, _('Basic Information')),
basicTable.render(),
]);
},
configSection() {
const configTable = new podmanUI.TableList();
const cmd = this.container.getCmdString() || '-';
const entrypoint = this.container.getEntrypointString() || '-';
const createCommand = this.container.getCreateCommandString() || '-';
const createCommandField = new podmanUI.SecretText(createCommand, CENSORED_VALUE);
configTable
.addRow(_('Create Command'), createCommandField.render())
.addRow(_('Command'), cmd)
.addRow(_('Entrypoint'), entrypoint)
.addRow(_('Working Directory'), this.container.getWorkingDir())
.addRow(_('User'), this.container.getUser())
.addRow(_('Hostname'), this.container.getHostname())
.addRow(_('Privileged'), this.container.getPrivileged() ? _('Yes') : _('No'))
.addRow(_('TTY'), this.container.getTty() ? _('Yes') : _('No'))
.addRow(_('Interactive'), this.container.getInteractive() ? _('Yes') : _('No'))
;
return E('div', {}, [
E('h4', {}, _('Configuration')),
configTable.render(),
]);
},
async networkSection () {
const table = new podmanUI.TableList();
const { NetworkMode, Links } = this.container.getHostConfig();
const { Networks, IPAddress } = this.container.getNetworkSettings();
table.addRow(_('Network Mode'), NetworkMode || _('default'));
const systemNetworks = ['bridge', 'host', 'none', 'container', 'slirp4netns'];
let userNetworks = [];
if (Networks && Object.keys(Networks).length > 0) {
userNetworks = Object.keys(Networks).filter((netName) => !systemNetworks.includes(netName));
userNetworks.forEach((netName) => {
const net = Networks[netName];
const tooltip = (() => {
if (!this.container.isRunning()) {
return _('Container is not running');
}
const parts = [];
if (net.IPAddress) parts.push(`IPv4: ${net.IPAddress}`);
if (net.GlobalIPv6Address) parts.push(`IPv6: ${net.GlobalIPv6Address}`);
else parts.push('IPv6: disabled');
if (net.Gateway) parts.push(`Gateway: ${net.Gateway}`);
if (net.MacAddress) parts.push(`MAC: ${net.MacAddress}`);
return parts.join('\n');
})();
const ipContent = net.GlobalIPv6Address
? [net.IPAddress || '-', E('br'), net.GlobalIPv6Address]
: net.IPAddress || '-';
table.addRow(
new podmanUI.Tooltip(E('span', { class: 'tooltip' }, netName), tooltip).render(),
E('div', { class: 'd-flex align-center' }, [
E('span', {}, ipContent),
new podmanUI.Tooltip('✖', _('Click to disconnect'), {
class: 'text-error cursor-pointer container-icon-disconnect ml-sm border-0',
click: () => this.handleNetworkDisconnect(netName),
}).render(),
])
);
});
}
if (IPAddress && userNetworks.length === 0) {
table.addRow(_('IP Address'), IPAddress);
}
const podmanNetworks = await podmanRPC.networks.list();
const networkOptions = Object.fromEntries(podmanNetworks.map(item => [item.name, item.name]));
const networkSelectWidget = new ui.Select([''], networkOptions, { name: 'container-connect-network', optional: false });
const ipTextWidget = new ui.Textfield('', { name: 'container-connect-ip', placeholder: '192.168.1.100', datatype: 'ip4addr' });
table.addRow(_('Connect to'), [
E('div', { class: 'd-flex align-center container-connect' }, [
networkSelectWidget.render(),
ipTextWidget.render(),
new podmanUI.Button(_('Connect'), () => {
const selectedNetwork = networkSelectWidget.getValue();
const selectedIp = ipTextWidget.getValue();
networkSelectWidget.triggerValidation();
ipTextWidget.triggerValidation();
if (!selectedNetwork || !ipTextWidget.isValid()) {
return;
}
this.handleNetworkConnect(selectedNetwork, selectedIp);
}).render(),
]),
]);
table.addRow(_('Ports'), E('div', {},
this.container.getPorts().map((port) => E('div', {}, port.string)),
));
if (Links && Links.length > 0) {
const linkNodes = Links.reduce((nodes, link, i) => {
if (i > 0) nodes.push(E('br'));
nodes.push(link);
return nodes;
}, []);
table.addRow(_('Links'), linkNodes);
}
return E('div', { class: 'networks' }, [
E('h4', {}, _('Network')),
table.render(),
]);
},
envSection() {
const table = new podmanUI.Table();
table
.addHeader(_('Variable'), { style: 'width: 50%;' })
.addHeader(_('Value'), { style: 'width: 50%;' });
this.container.getEnvironmentVars().forEach((env) => {
const parts = env.split('=');
const varName = parts[0];
const varValue = parts.slice(1).join('=');
const valueField = new podmanUI.SecretText(varValue, CENSORED_VALUE);
table.addRow([{
inner: varName
},
{
inner: valueField.render(),
},
]);
});
return E('div', {}, [
E('h4', {}, _('Environment Variables')),
table.render(),
]);
},
mountSection() {
const table = new podmanUI.Table();
table
.addHeader(_('Type'), { style: 'width: 10%;' })
.addHeader(_('Source'), { style: 'width: 35%;' })
.addHeader(_('Destination'), { style: 'width: 35%;' })
.addHeader(_('Mode'), { style: 'width: 10%;' })
;
const truncatedField = (value) => {
const v = value || '-';
return v.length > 50 ? new podmanUI.Tooltip(utils.truncate(v, 50), v).render() : v;
};
this.container.getMounts().forEach((mount) => {
const mountSourceField = truncatedField(mount.Source);
const mountDestField = truncatedField(mount.Destination);
table.addRow([
{ inner: mount.Type || '-' },
{ inner: mountSourceField },
{ inner: mountDestField },
{ inner: mount.RW ? 'rw' : 'ro' },
]);
})
return E('div', {}, [
E('h4', {}, _('Mounts')),
table.render(),
]);
},
async _refreshContainer() {
const inspectData = await this.container.inspect();
this.container = Container.getSingleton(inspectData);
},
async _refreshNetworkSection() {
const networkSection = await this.networkSection();
document.querySelector('.networks').replaceWith(networkSection);
},
async updateInitScriptIcon(infoSection) {
this.initScriptStatus = await this.container.checkInitScript();
const initScriptIconElement = (infoSection || document).querySelector('.autostart-status');
const initScriptIcon = this.container.getAutoStartStatusIcon(this.initScriptStatus);
initScriptIconElement.replaceWith(initScriptIcon);
},
async handleUpdateName(name) {
if (!name || this.container.Name === name) {
return;
}
this.loading(_('Update name'));
await this.container.updateName(name);
document.querySelector('.container-toolbar h2').textContent = name;
this.success(_('Name updated successfully'));
},
async handleUpdateRestartPolicy(policy) {
this.loading(_('Update restart policy'));
const updateData = { RestartPolicy: policy };
if (policy === 'on-failure') updateData.RestartRetries = 5;
await this.container.update(updateData);
await this._refreshContainer();
const initScriptForm = document.querySelector('.init-script-form');
if (policy === 'no') {
initScriptForm.classList.replace('d-flex', 'd-none');
} else {
initScriptForm.classList.replace('d-none', 'd-flex');
}
this.updateInitScriptIcon();
this.success(_('Restart policy updated successfully'));
},
async handleNetworkConnect(networkName, ip) {
this.loading(_('Connecting container to network'));
const params = {};
if (ip) params.static_ips = [ip];
await this.container.connect(networkName, params);
await this._refreshContainer();
this.success(_('Connected successfully to %s').format(networkName));
await this._refreshNetworkSection();
},
async handleNetworkDisconnect(networkName) {
this.loading(_('Disconnect container from network'));
await this.container.disconnect(networkName);
await this._refreshContainer();
this.success(_('Disconnected from %s').format(networkName));
await this._refreshNetworkSection();
},
async handleGenerateInitScript() {
this.loading(_('Creating auto-start configuration for %s').format(this.container.getName()));
await this.container.generateInitScript();
await this.container.enableInitScript();
ui.hideModal();
window.location.reload();
},
async handleShowInitScript() {
this.loading(_('Loading init script'));
const result = await this.container.showInitScript();
ui.hideModal();
if (!result || !result.path || !result.content) return;
const modal = new podmanUI.Modal(result.path, [
new podmanUI.BashCodeArea(result.content.replace(/\\n/g, '\n')).render()
]);
modal.handleSubmit = undefined;
modal.render();
},
async handleToggleInitScript() {
if (this.initScriptStatus === 'missing') {
return this.handleGenerateInitScript();
} else if (this.initScriptStatus === 'enabled') {
this.loading(_('Disabling init script'));
await this.container.disableInitScript();
ui.hideModal();
window.location.reload();
} else if (this.initScriptStatus === 'disabled') {
this.loading(_('Enabling init script'));
await this.container.enableInitScript();
ui.hideModal();
window.location.reload();
}
},
});
@@ -0,0 +1,137 @@
'use strict';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
const TIMESTAMP_RE_GLOBAL = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\s*/g;
const ANSI_REGEX = /\x1B\[[0-9;]*[A-Za-z]/g;
const MAX_LOG_LINES = 1000;
return podmanView.tabContent.extend({
tab: 'logs',
container: null,
render(container) {
this.container = container;
this.logViewer = E('pre', { id: 'log-viewer', class: 'terminal-area' }, []);
return this.renderTabContent('', [
this.renderLogToolbar(),
this.logViewer,
]);
},
onTabActive() {
// Running: auto-start live stream. Stopped: load historical logs on demand via play button.
if (this.container.isRunning()) {
this.startStream();
}
},
onTabInactive() {
this.stopStream();
},
_setStreamActive(active) {
this.logLinesInput.disabled = active;
this.logSinceInput.disabled = active;
this.logUntilInput.disabled = active;
this.playButton.querySelector('.cbi-button').classList.toggle('cbi-button-active', active);
this.stopButton.querySelector('.cbi-button').classList.toggle('cbi-button-active', !active);
},
startStream() {
if (!this.container || this.logsStream) {
return;
}
this._setStreamActive(true);
this.logViewer.textContent = '';
const toUnixSince = (val) => val ? new Date(val + 'T00:00:00').getTime() / 1000 : null;
const toUnixUntil = (val) => val ? new Date(val + 'T23:59:59').getTime() / 1000 : null;
this.logsStream = this.container.streamLogs((data) => {
if (!data || !data.raw) return;
const line = data.raw
.replace(TIMESTAMP_RE_GLOBAL, '')
.replace(ANSI_REGEX, '')
.trim();
if (!line) return;
this.logViewer.textContent += line + '\n';
this.trimLogOutput();
this.logViewer.scrollTop = this.logViewer.scrollHeight;
}, {
tail: this.logLinesInput.value || 100,
since: toUnixSince(this.logSinceInput.value),
until: toUnixUntil(this.logUntilInput.value),
follow: this.container.isRunning(),
});
},
stopStream() {
if (!this.logsStream) return;
this._setStreamActive(false);
this.logsStream.stop();
this.logsStream = null;
},
renderLogToolbar() {
// Fields start disabled only for running containers (stream auto-starts and locks them).
// For stopped containers they start enabled so the user can set filters before fetching.
const initialDisabled = this.container.isRunning();
const linesField = new podmanUI.Numberfield(100, { id: 'log-lines', min: '10', max: '250', disabled: initialDisabled }).render();
const sinceField = new podmanUI.Datefield(null, { id: 'log-since', disabled: initialDisabled }).render();
const untilField = new podmanUI.Datefield(null, { id: 'log-until', disabled: initialDisabled }).render();
this.logLinesInput = linesField.querySelector('input');
this.logSinceInput = sinceField.querySelector('input');
this.logUntilInput = untilField.querySelector('input');
this.stopButton = new podmanUI.ButtonNew('&#9724;', {
click: () => this.stopStream(),
type: ' stop-button',
tooltip: _('Stop stream'),
}).render();
this.playButton = new podmanUI.ButtonNew('&#9658;', {
click: () => this.startStream(),
type: 'active play-button',
tooltip: _('Start stream'),
}).render();
return E('div', { class: 'd-flex align-center mb-sm' }, [
E('label', { class: 'mr-xs' }, _('Number of lines')),
linesField,
'',
E('label', { class: 'ml-xs mr-xs' }, _('Since')),
sinceField,
E('label', { class: 'ml-xs mr-xs' }, _('Until')),
untilField,
E('div', { class: 'ml-xs' }, ''),
new podmanUI.ButtonNew('🗑️', {
click: () => this.logViewer.textContent = '',
tooltip: _('Clear logs'),
}).render(),
this.stopButton,
this.playButton,
]);
},
trimLogOutput() {
const lines = this.logViewer.textContent.split('\n');
if (lines.length > MAX_LOG_LINES) {
this.logViewer.textContent = lines.slice(lines.length - MAX_LOG_LINES).join('\n');
}
},
});
@@ -0,0 +1,99 @@
'use strict';
'require dom';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
return podmanView.tabContent.extend({
tab: 'ps',
container: null,
render(container) {
this.container = container;
if (!this.container.isRunning()) {
return this.warningContent(_('Container is not running'));
}
this.tableContent = E('div', { class: 'ps-table-content' }, []);
return this.renderTabContent('', [
this.tableContent,
]);
},
onTabActive() {
if (!this.container || !this.container.isRunning() || this.processStream) {
return;
}
this.processStream = this.container.streamTop((ps) => {
this.updateProcessList(ps);
});
},
onTabInactive() {
if (!this.processStream) return;
this.processStream.stop();
this.processStream = null;
},
updateProcessList(ps) {
if (!ps || !ps.Titles || !ps.Processes || ps.Titles.length === 0) {
return;
}
const psTable = new podmanUI.Table();
const columnWidth = `width: ${100 / ps.Titles.length}%;`;
const timeIndex = ps.Titles.indexOf('ELAPSED');
ps.Titles.forEach((title) => {
psTable.addHeader(_(title), { style: columnWidth });
});
ps.Processes.forEach((process) => {
psTable.addRow(process.map((detail, i) => ({
inner: i === timeIndex ? this._formatElapsedTime(detail) : detail
})));
});
dom.content(this.tableContent, psTable.render());
},
_formatElapsedTime(timeStr) {
if (!timeStr) return '-';
const result = [];
const pattern = /(\d+(?:\.\d+)?)([ydhms])/g;
let match;
while ((match = pattern.exec(timeStr)) !== null) {
let value = parseFloat(match[1]);
const unit = match[2];
if (unit === 's' && value > 1000) {
value = Math.floor(value / 1000000000);
if (value >= 60) {
const mins = Math.floor(value / 60);
const secs = value % 60;
if (mins > 0) result.push(`${mins}m`);
if (secs > 0) result.push(`${secs}s`);
continue;
}
} else {
value = Math.floor(value);
}
if (unit === 's' || unit === 'm') {
result.push(String(value).padStart(2, '0') + unit);
} else {
result.push(value + unit);
}
}
return result.length > 0 ? result.join('') : timeStr;
}
});
@@ -0,0 +1,107 @@
'use strict';
'require dom';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.utils as podmanUtil';
return podmanView.tabContent.extend({
tab: 'stats',
container: null,
render(container) {
this.container = container;
if (!this.container.isRunning()) {
return this.warningContent(_('Container is not running'));
}
return this.renderTabContent(_('Statistics'), [
this.renderStatsTable(),
]);
},
onTabActive() {
if (!this.container || !this.container.isRunning() || this.statsStream) {
return;
}
this.statsStream = this.container.streamStats((stats) => {
this.updateStatsDisplay(stats);
});
},
onTabInactive() {
if (!this.statsStream) {
return;
}
this.statsStream.stop();
this.statsStream = null;
},
renderStatsTable() {
const table = new podmanUI.TableList();
table
.addRow(_('CPU Usage'), '-', { 'data-stat': 'cpu' })
.addRow(_('Memory Usage'), '-', { 'data-stat': 'memory' })
.addRow(_('Memory Limit'), '-', { 'data-stat': 'memory-limit' })
.addRow(_('Memory %'), '-', { 'data-stat': 'memory-percent' })
.addRow(_('Network I/O'), '-', { 'data-stat': 'network-io' })
.addRow(_('Block I/O'), '-', { 'data-stat': 'block-io' })
.addRow(_('PIDs'), '-', { 'data-stat': 'pids' })
;
return table.render();
},
updateStatsDisplay(stats) {
if (!stats) return;
if (!this.statElements) {
this.statElements = {};
for (const key of ['cpu', 'memory', 'memory-limit', 'memory-percent', 'network-io', 'block-io', 'pids']) {
this.statElements[key] = document.querySelector(`[data-stat="${key}"] td:last-of-type`);
}
}
const updates = [
['cpu', stats.CPU != null ? stats.CPU.toFixed(2) + '%' : '-'],
['memory', podmanUtil.format.bytes(stats.MemUsage) || '-'],
['memory-limit', podmanUtil.format.bytes(stats.MemLimit) || '-'],
['memory-percent', stats.MemPerc != null ? stats.MemPerc.toFixed(2) + '%' : '-'],
['network-io', this._formatNetworkIO(stats.Network)],
['block-io', this._formatBlockIO(stats.BlockInput, stats.BlockOutput)],
['pids', stats.PIDs],
];
for (const [key, value] of updates) {
if (this.statElements[key])
dom.content(this.statElements[key], value);
}
},
_formatNetworkIO(networks) {
const parts = Object.keys(networks || {}).map((iface) => {
const net = networks[iface];
return `${iface}: ↓ ${podmanUtil.format.bytes(net.RxBytes)} / ↑ ${podmanUtil.format.bytes(net.TxBytes)}`;
});
if (parts.length === 0) return '-';
return parts.reduce((nodes, part, i) => {
if (i > 0) nodes.push(E('br'));
nodes.push(part);
return nodes;
}, []);
},
_formatBlockIO(blockInput, blockOutput) {
return _('Read: %s / Write: %s').format(
podmanUtil.format.bytes(blockInput || 0),
podmanUtil.format.bytes(blockOutput || 0)
);
}
});
@@ -0,0 +1,167 @@
'use strict';
'require ui';
'require dom';
'require podman.constants as constant';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.model.Container as Container';
'require podman.form.resource as PodmanFormResource';
'require view.podman.container-tab.info as ContainerInfoTab';
'require view.podman.container-tab.stats as ContainerStatsTab';
'require view.podman.container-tab.processes as ContainerProcessesTab';
'require view.podman.container-tab.logs as ContainerLogsTab';
/**
* Container detail view with tabbed interface
*/
return podmanView.container.extend({
container: null,
data: null,
listUrl: L.url('admin/podman/containers'),
async load() {
// Extract container ID from URL path
// URL format: /cgi-bin/luci/admin/podman/container/<id>
const path = L.location();
const matches = path.match(/container\/([a-f0-9]+)/i);
if (!matches || !matches[1]) {
this.redirectToList();
return;
}
const containerId = matches[1];
const container = Container.getSingleton({ Id: containerId });
const inspectData = await container.inspect();
this.data = inspectData;
return Container.getSingleton(inspectData);
},
async render(container) {
if (!container) {
this.redirectToList();
return;
}
this.container = container;
this.tabs
.addTab('info', _('Info'))
.addTab('resources', _('Resources'))
.addTab('stats', _('Stats'))
.addTab('ps', _('Processes'))
.addTab('logs', _('Logs'))
// .addTab('health', _('Health'))
.addTab('inspect', _('Inspect'))
// .addTab('console', _('Console'))
;
requestAnimationFrame(() => {
this.renderInfoTab();
this.renderResourcesTab();
this.renderStatsTab();
this.renderProcessesTab();
this.renderLogsTab();
this.renderInspectTab();
});
return this.super('render', []);
},
createHeader() {
return this.super('createHeader', [
this.container.getName(),
this.container.isRunning(),
this.container.isStopped(),
this.container.isPaused(),
]);
},
stopStreams() {
this.getTabInstance('stats')?.onTabInactive();
this.getTabInstance('ps')?.onTabInactive();
this.getTabInstance('logs')?.onTabInactive();
},
async renderInfoTab() {
const content = await ContainerInfoTab.render(this.container);
this.renderTab('info', content);
},
async renderResourcesTab() {
const resourceForm = await new PodmanFormResource.init().render(this.container);
this.renderTab('resources', resourceForm, _('Configure resource limits for this container.'));
},
async renderStatsTab() {
const content = await ContainerStatsTab.render(this.container);
this.renderTab('stats', content);
},
async renderProcessesTab() {
const content = await ContainerProcessesTab.render(this.container);
this.renderTab('ps', content);
},
async renderLogsTab() {
const content = await ContainerLogsTab.render(this.container);
this.renderTab('logs', content);
},
renderInspectTab() {
this.renderTab('inspect', new podmanUI.JsonArea(this.data).render());
},
async handleStart() {
if (this.container.isRunning()) {
return;
}
this.loading(_('Start container'));
if (this.container.isPaused()) {
this.container.unpause().then(() => window.location.reload());
return;
}
this.container.start().then(() => window.location.reload());
},
async handleStop() {
if (this.container.isStopped()) {
return;
}
this.loading(_('Stop container'));
this.stopStreams();
this.container.stop().then(() => window.location.reload());
},
async handleRestart() {
this.loading(_('Restart container'));
this.stopStreams();
this.container.restart().then(() => window.location.reload());
},
async handlePause() {
if (!this.container.isRunning()) {
return;
}
this.loading(_('Pause container'));
this.stopStreams();
this.container.pause().then(() => window.location.reload());
},
async handleRemove() {
this.confirm([
E('p', {}, _('Are you sure to remove container?')),
], async () => {
this.loading(_('Remove container'));
this.container.remove().then(() => this.redirectToList());
});
},
});
@@ -0,0 +1,151 @@
'use strict';
'require dom';
'require ui';
'require podman.constants as constant';
'require podman.rpc as podmanRPC';
'require podman.utils as utils';
'require podman.ui as podmanUI';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.form.container as PodmanFormContainer';
/**
* Manage podman containers
*/
return podmanView.list.extend({
sectionName: 'containers',
title: _('Containers'),
titleSingle: _('Container'),
async load() {
return podmanRPC.containers.list('all=true');
},
/**
* Update list section
*/
async updateSection() {
this.section.selectKey = 'Id';
this.section.createForm = PodmanFormContainer;
this.section.handleRefreshCb = () => this.checkInitScripts();
this.section.toolbarExtraButtons = [
new podmanUI.Button(constant.ICON.START, ui.createHandlerFn(this, 'handleStart')).render(),
new podmanUI.Button(constant.ICON.STOP, ui.createHandlerFn(this, 'handleStop')).render(),
new podmanUI.Button(constant.ICON.RESTART, ui.createHandlerFn(this, 'handleRestart')).render(),
new podmanUI.Button(constant.ICON.PAUSE, ui.createHandlerFn(this, 'handlePause')).render(),
];
let o;
o = this.section.option(podmanForm.field.DummyValue, 'Names', _('Name'));
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
o.cfgdatavalue = (container) => container.getDetailLink(utils.truncate(container.getID(), 10));
o.width = '12%';
o = this.section.option(podmanForm.field.DummyValue, 'Image', _('Image'));
o.cfgdatavalue = (container) => container.getImageName();
o.width = '25%';
o = this.section.option(podmanForm.field.DummyValue, 'ImageTag', _('Tag'));
o.cfgdatavalue = (container) => container.getImageTag();
o.cfgformatter = (imageTag) => utils.truncate(imageTag, 10);
o.cfgtt = (imageTag) => imageTag.length > 10 ? imageTag : '';
o.width = '11%';
o = this.section.option(podmanForm.field.DummyValue, 'State', _('Status'));
o.cfgdatavalue = (container) => container.getStateBadge();
o.width = '8%';
o = this.section.option(podmanForm.field.TimestampDummyValue, 'StartedAt', _('Started At'));
o.width = '17%';
o = this.section.option(podmanForm.field.DummyValue, 'InitScript', _('Boot'));
o.cfgvalue = () => E('span', { class: 'autostart-status autostart-' }, '...');
o.width = '6%';
},
transformMap(map) {
this.checkInitScripts(map);
return map;
},
async checkInitScripts(renderedTable) {
const data = this.map.data.data;
for (const [key, container] of Object.entries(data)) {
const iconEl = (renderedTable || document)
.querySelector(`tr[data-sid="${key}"] td[data-name="InitScript"] .autostart-status`);
container.checkInitScript().then((initScriptStatus) => {
let initScriptIcon = container.getAutoStartStatusIcon(initScriptStatus);
if (initScriptStatus === 'missing') {
initScriptIcon = new podmanUI.Tooltip(
initScriptIcon,
_('Start script missing\n\nClick to generate'),
{
class: 'border-0',
click: ui.createHandlerFn(this, 'handleGenerateInitScript')
}
).render();
} else if (initScriptStatus === 'disabled') {
initScriptIcon = new podmanUI.Tooltip(initScriptIcon, _('Start script disabled'), { class: 'border-0' }).render();
} else if (initScriptStatus === 'enabled') {
initScriptIcon = new podmanUI.Tooltip(initScriptIcon, _('Start on boot'), { class: 'border-0' }).render();
}
if (iconEl) iconEl.replaceWith(initScriptIcon);
}).catch(() => {});
}
},
async handleGenerateInitScript(event) {
const clickedIcon = event.target;
const recordRow = dom.parent(clickedIcon, 'tr');
const container = this.map.data.data[recordRow.getAttribute('data-sid')];
await container.generateInitScript();
await container.enableInitScript();
this.section.handleRefresh();
},
handleStart() {
return this.updateContainersStatus(
(container) => container.isPaused() ? container.unpause() : container.start(),
_('Start container')
);
},
handleStop() {
return this.updateContainersStatus((container) => container.stop(), _('Stop container'));
},
handleRestart() {
return this.updateContainersStatus((container) => container.restart(), _('Restart container'));
},
handlePause() {
return this.updateContainersStatus((container) => container.pause(), _('Pause container'));
},
async updateContainersStatus(statusFunction, textLoad) {
const section = this.getSection();
const selected = section.getSelectedData();
if (selected.length === 0) {
return section.showNoneSelectedWarning();
}
for (const [i, container] of selected.entries()) {
this.loading(_('%s: %d/%d').format(textLoad, i + 1, selected.length));
await statusFunction(container);
}
ui.hideModal();
section.handleRefresh();
},
});
@@ -0,0 +1,118 @@
'use strict';
'require form';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.utils as utils';
'require podman.ui as podmanUI';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.form.image as PodmanFormImage';
const IMAGE_ID_TRUNCATE_LEN = 12;
const IMAGE_TAG_TRUNCATE_LEN = 14;
/**
* Manage podman images
*/
return podmanView.list.extend({
sectionName: 'images',
title: _('Images'),
titleSingle: _('Image'),
async load() {
return podmanRPC.images.list();
},
/**
* Update list section
*/
async updateSection() {
this.section.selectKey = 'Id';
this.section.handleCreateText = _('Pull Image');
this.section.getCreateButton = function() {
return new podmanUI.ButtonNew(_('Pull Image'), {
click: ui.createHandlerFn(this, 'handleCreate'),
type: 'add',
}).render();
};
this.section.modalCreateTitle = _('Pull Image');
this.section.createForm = PodmanFormImage;
this.section.toolbarExtraButtons = [
new podmanUI.Button(_('Update'), ui.createHandlerFn(this, 'handlePullImages')).render(),
];
let o;
o = this.section.option(podmanForm.field.DummyValue, 'Repository', _('Repository'));
o.cfgdatavalue = async (image) => image.getRepository();
o = this.section.option(podmanForm.field.DummyValue, 'Tag', _('Tag'));
o.cfgdatavalue = async (image) => {
const tagValue = image.getTag();
if (tagValue.length > IMAGE_TAG_TRUNCATE_LEN) {
return new podmanUI.Tooltip(utils.truncate(tagValue, IMAGE_TAG_TRUNCATE_LEN), tagValue).render();
}
return tagValue;
};
o.width = '17%';
o = this.section.option(podmanForm.field.LinkDummyValue, 'ImageId', _('Image ID'));
o.click = (_value, image) => this.section.handleInspect(image);
o.cfgdatavalue = (image) => utils.truncate(image.getID(), IMAGE_ID_TRUNCATE_LEN);
o.width = '15%';
o = this.section.option(podmanForm.field.ByteDummyValue, 'Size', _('Size'));
o.width = '12%';
o = this.section.option(podmanForm.field.TimestampDummyValue, 'Created', _('Created'));
o.width = '18%';
return this.map.render();
},
handlePullImages: async function () {
const selected = this.section.getSelectedData();
if (selected.length === 0) {
return this.section.showNoneSelectedWarning();
}
const progressEl = E('pre', { class: 'terminal-area' });
const modal = new podmanUI.Modal(_('Updating Images'), [ progressEl ]);
/** @type {HTMLElement} */
const closeButton = modal.getCloseButton();
closeButton.classList.add('d-none');
modal.getButtons = () => [ closeButton ];
modal.render();
const append = (text) => {
progressEl.textContent += text;
progressEl.scrollTop = progressEl.scrollHeight;
};
let errors = 0;
let first = true;
for (const image of selected) {
if (!first) append('\n');
first = false;
try {
await image.streamPull((text) => append(text));
} catch (err) {
podmanUI.alert(`${_('Error')}: ${err.message}`);
errors++;
}
}
this.section.handleRefresh();
closeButton.classList.remove('d-none');
if (errors === 0)
podmanUI.alert(_('All images updated successfully'), 'success', true);
},
});
@@ -0,0 +1,216 @@
'use strict';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.ui as podmanUI';
'require podman.model.Container as Container';
return podmanUI.Modal.extend({
title: _('Auto Update'),
containers: [],
buttons: [],
getContent() {
if (!this.containers || this.containers.length === 0) {
this.buttons = [ this.getCloseButton() ];
return [
E('p', {}, _('No containers with auto-update label found.')),
E('p', { class: 'mt-sm' },
_('To enable auto-update for a container, add the label: io.containers.autoupdate=registry')
),
];
}
this.buttons = [
this.getCloseButton(),
new podmanUI.ButtonNew(_('Check'), {
click: ui.createHandlerFn(this, 'handleCheckUpdate'),
type: 'positive',
}).render()
];
return [
E('p', {}, _('Check %d containers for updates?').format(this.containers.length))
];
},
setContainers(containers) {
this.containers = containers ?? [];
return this;
},
getButtons() {
return this.buttons;
},
getCloseButton() {
return new podmanUI.ButtonNew(_('Close'), {
click: () => ui.hideModal(),
type: 'remove',
}).render();
},
async handleCheckUpdate() {
const containersWithUpdates = [];
const checkErrors = [];
for (const [index, container] of this.containers.entries()) {
podmanUI.showSpinningModal(null, _('Check for updates: %s/%s').format(index + 1, this.containers.length));
const updateState = await container.checkImageUpdate();
if (updateState.hasUpdate) {
containersWithUpdates.push(container);
} else if (updateState.error) {
checkErrors.push({ name: container.getName(), error: updateState.error });
}
}
if (containersWithUpdates.length === 0) {
const content = [ E('p', {}, _('No new updates available.')) ];
if (checkErrors.length > 0) {
content.push(E('p', { class: 'mt-sm' }, _('%d check(s) failed:').format(checkErrors.length)));
checkErrors.forEach((e) => content.push(E('p', { class: 'text-error' }, `${e.name}: ${e.error}`)));
}
const modal = new podmanUI.Modal(_('Check Complete'), content);
modal.getButtons = () => [ this.getCloseButton() ];
modal.render();
return;
}
const updateContent = [
E('p', {}, _('Select containers to update')),
];
const checkboxes = {};
containersWithUpdates.forEach((container) => {
const skipReason = container.getUpdateSkipReason();
const checkbox = new ui.Checkbox(0);
const checkboxNode = checkbox.render();
const checkboxInputNode = checkboxNode.querySelector('input[type="checkbox"]');
const checkboxId = checkboxInputNode.id;
if (skipReason) {
checkboxInputNode.disabled = true;
} else {
checkboxes[container.getID()] = { container, checkbox };
}
const inner = skipReason
? new podmanUI.Tooltip(checkboxNode, skipReason).render()
: checkboxNode;
updateContent.push(E('div', { class: 'd-flex align-center checkbox-with-label mb-xs' }, [
inner,
E('label', { for: checkboxId }, container.getName()),
]));
});
const modal = new podmanUI.Modal(_('Apply Updates'), updateContent);
modal.getButtons = () => [
this.getCloseButton(),
new podmanUI.ButtonNew(_('Update'), {
click: async () => {
const selectedUpdates = Object.values(checkboxes)
.filter(({ checkbox }) => checkbox.isChecked())
.map(({ container }) => container);
if (selectedUpdates.length === 0) {
return;
}
await this.handleUpdate(selectedUpdates);
},
type: 'positive',
}).render()
];
modal.render();
},
async handleUpdate(containers) {
const failures = [];
const oldImages = [];
const counterEl = E('strong', {}, '');
const spinnerEl = E('div', { class: 'spinning' });
const headerEl = E('div', { class: 'd-flex align-center mb-sm gap-5' }, [
counterEl,
spinnerEl,
]);
const log = new podmanUI.StreamLog();
const finalize = () => {
ui.hideModal();
if (failures.length === 0 && imageWarnings.length === 0) {
podmanUI.alert(_('Containers updated successfully'), 'success', true);
return;
}
const lines = [];
if (failures.length > 0) {
const succeeded = containers.length - failures.length;
lines.push(E('p', {}, _('%d updated, %d failed:').format(succeeded, failures.length)));
failures.forEach(f => lines.push(E('p', {}, `${f.name}: ${f.error}`)));
} else {
lines.push(E('p', {}, _('Containers updated successfully.')));
}
imageWarnings.forEach(w => lines.push(E('p', { class: 'mt-sm' }, w)));
podmanUI.alert(lines, failures.length > 0 ? 'warning' : 'info');
};
const closeBtn = new podmanUI.ButtonNew(_('Close'), {
click: () => finalize(),
type: 'negative',
}).render();
closeBtn.disabled = true;
const modal = new podmanUI.Modal(_('Apply Updates'), [ headerEl, log.render() ]);
modal.getButtons = () => [ closeBtn ];
modal.render();
let imageWarnings = [];
for (const [i, container] of containers.entries()) {
counterEl.textContent = _('Update image: %d/%d - %s').format(i + 1, containers.length, container.getName());
log.append('━━ ' + container.getName() + ' ━━\n');
try {
const inspected = Container.getSingleton(await container.inspect());
const result = await inspected.updateImage((line) => log.append(line));
if (result?.oldImage) oldImages.push({ image: result.oldImage });
} catch (err) {
const msg = err.message || String(err);
log.append('✗ ' + msg + '\n');
failures.push({ name: container.getName(), error: msg });
}
log.append('\n');
}
if (oldImages.length > 0) {
counterEl.textContent = _('Cleaning up old images...');
log.append('━━ ' + _('Cleaning up old images...') + ' ━━\n');
const allContainers = await podmanRPC.containers.list('all=true').catch(() => []);
const normalize = id => (id || '').replace(/^sha256:/, '').toLowerCase();
const usedIds = new Set(allContainers.map(c => normalize(c.ImageID)));
const seen = new Set();
for (const { image } of oldImages) {
const id = normalize(image.getID());
if (seen.has(id)) continue;
seen.add(id);
if (usedIds.has(id)) {
const warning = _('Old image still used by other containers');
imageWarnings.push(warning);
log.append('! ' + warning + '\n');
} else {
await image.remove();
log.append('✓ ' + _('Removed old image %s').format(image.getID().substring(0, 12)) + '\n');
}
}
}
counterEl.textContent = _('Done.');
spinnerEl.classList.remove('spinning');
closeBtn.disabled = false;
},
});
@@ -0,0 +1,120 @@
'use strict';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.utils as podmanUtil';
'require podman.ui as podmanUI';
return podmanUI.Modal.extend({
title: _('Cleanup Unused Resources'),
getContent() {
this.checkboxPruneAll = new ui.Checkbox(1, {
hiddenname: 'prune-all-images'
});
this.checkboxPruneVolumes = new ui.Checkbox(0, {
hiddenname: 'prune-volumes'
});
const pruneAllNode = this.checkboxPruneAll.render();
const pruneVolumesNode = this.checkboxPruneVolumes.render();
const pruneAllId = pruneAllNode.querySelector('input[type="checkbox"]').id;
const pruneVolumesId = pruneVolumesNode.querySelector('input[type="checkbox"]').id;
return E('div', {}, [
E('p', {}, _('Select what to clean up:')),
E('div', {
class: 'd-flex align-center checkbox-with-label',
style: 'margin-bottom: 2px;'
}, [
pruneAllNode,
E('label', {
for: pruneAllId
}, _('Remove all unused images (not just dangling)')),
]),
E('div', {
class: 'd-flex align-center checkbox-with-label'
}, [
pruneVolumesNode,
E('label', {
for: pruneVolumesId
}, _('Remove unused volumes')),
]),
]);
},
getButtons() {
return [
this.getCloseButton(),
new podmanUI.ButtonNew(_('Clean Up Now'), {
click: ui.createHandlerFn(this, 'handlePrune'),
type: 'positive',
}).render()
];
},
async handlePrune() {
const allImages = this.checkboxPruneAll.getValue() === '1';
const volumes = this.checkboxPruneVolumes.getValue() === '1';
podmanUI.showSpinningModal(_('Clean Up Now'), _(
'Removing unused resources, please wait...'));
const result = await podmanRPC.system.prune(allImages, volumes);
let freedSpace = 0;
const deletedItems = [];
const reportTypes = [{
key: 'ContainerPruneReports',
label: _('Containers')
},
{
key: 'ImagePruneReports',
label: _('Images')
},
{
key: 'VolumePruneReports',
label: _('Volumes')
}
];
reportTypes.forEach((type) => {
const reports = result[type.key];
if (reports && reports.length > 0) {
reports.forEach((r) => {
if (r.Size > 0) freedSpace += r.Size;
});
deletedItems.push('%d %s'.format(reports.length, type.label
.toLowerCase()));
}
});
const modal = new podmanUI.Modal(_('Cleanup Complete'));
modal.handleClose = null;
modal.handleSubmit = () => {
ui.hideModal();
window.location.reload();
};
modal.content = [
E('p', {}, _('Cleanup successful!')),
];
if (deletedItems.length > 0) {
modal.content.push(E('p', {
class: 'mt-sm'
}, _('Removed: %s').format(deletedItems.join(', '))));
} else {
modal.content.push(E('p', {
class: 'mt-sm'
}, _('No unused resources found')));
}
modal.content.push(E('p', {
class: 'mt-sm text-success text-bold'
}, _('Space freed: %s').format(podmanUtil.format.bytes(freedSpace))));
modal.render();
},
});
@@ -0,0 +1,128 @@
'use strict';
'require network';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.form as podmanForm';
'require podman.form.network as PodmanFormNetwork';
/**
* Manage podman networks
*/
return podmanView.list.extend({
sectionName: 'networks',
title: _('Networks'),
titleSingle: _('Network'),
async load() {
return podmanRPC.networks.list();
},
/**
* Update list section
*/
async updateSection() {
this.section.selectKey = 'ID';
this.section.createForm = PodmanFormNetwork;
this.section.handleRemove = async () => this.removeNetworks();
let o;
o = this.section.option(podmanForm.field.LinkDummyValue, 'name', _('Name'));
o.click = (_value, net) => this.section.handleInspect(net);
o = this.section.option(podmanForm.field.DummyValue, 'Int', _('Int'));
o.cfgdatavalue = (net) => this.getIntegrationIcon(net);
o.width = '5%';
o = this.section.option(podmanForm.field.DummyValue, 'driver', _('Driver'));
o.width = '10%';
const ipPairCell = (getV4, getV6) => (net) => E('div', {}, [
E('span', {}, getV4(net) || '-'),
E('br'),
E('span', {}, getV6(net) || '-'),
]);
o = this.section.option(podmanForm.field.DummyValue, 'Subnet', _('Subnet'));
o.cfgdatavalue = ipPairCell(n => n.getSubnetIP4(), n => n.getSubnetIP6());
o.width = '20%';
o = this.section.option(podmanForm.field.DummyValue, 'Gateway', _('Gateway'));
o.cfgdatavalue = ipPairCell(n => n.getGatewayIP4(), n => n.getGatewayIP6());
o.width = '20%';
o = this.section.option(podmanForm.field.DateDummyValue, 'created', _('Created'));
o.width = '20%';
await network.flushCache();
},
async removeNetworks() {
const selected = this.section.getSelectedData();
if (selected.length === 0) {
return this.section.showNoneSelectedWarning();
}
const checkboxRemoveIntegration = new ui.Checkbox('1', { hiddenname: 'remove-integration' });
const checkboxNode = checkboxRemoveIntegration.render();
const checkboxId = checkboxNode.querySelector('input[type="checkbox"]').id;
this.confirm([
E('p', {}, _('Are you sure to delete the records?')),
E('div', { class: 'd-flex align-center checkbox-with-label' }, [
checkboxNode,
E('label', { for: checkboxId }, _('Remove OpenWrt integration')),
]),
], async () => {
for (const [i, item] of selected.entries()) {
this.loading(_('Deleting records: %s/%s').format(i + 1, selected.length));
await item.remove();
if (checkboxRemoveIntegration.getValue() === '1') {
await item.integrationRemove();
}
}
ui.hideModal();
this.section.handleRefresh();
});
},
async getIntegrationIcon(net) {
const { hasInterface, hasDevice, hasDnsmasqExclusion } = await net.integrationCheck();
if (hasInterface && hasDevice && hasDnsmasqExclusion) {
return new podmanUI.Tooltip('✅', _('OpenWrt integration exists\n\nClick to remove'), {
click: () => {
this.confirm([
E('p', {}, _('Are you sure to remove integration?')),
], async () => {
await net.integrationRemove();
this.section.handleRefresh();
});
},
}).render();
}
const tooltip = [];
tooltip.push(_('OpenWrt integration incomplete'));
tooltip.push('');
tooltip.push(`${_('Interface')}: ${String(hasInterface)}`);
tooltip.push(`${_('Device')}: ${String(hasDevice)}`);
if (await net.hasDnsmasq()) {
tooltip.push(`${_('Dnsmasq')}: ${String(hasDnsmasqExclusion)}`);
}
tooltip.push('');
tooltip.push(_('Click icon to repair'));
return new podmanUI.Tooltip('❌', tooltip.join('\n'), {
click: () =>
net.integrationRepair().then(() => this.section.handleRefresh()),
}).render();
}
});
@@ -0,0 +1,202 @@
'use strict';
'require dom';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.utils as podmanUtil';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require view.podman.modal.prune as PruneModal';
'require view.podman.modal.autoUpdate as AutoUpdateModal';
/**
* Podman Overview Dashboard View
*/
return podmanView.base.extend({
async load() {
return Promise.all([
podmanRPC.system.version(),
podmanRPC.system.info(),
])
},
async render([version, info]) {
const sections = await Promise.all([
this.createToolbar(),
this.createInfoSection(version, info),
this.createDebugSection(),
this.createResourcesSection(),
]);
this.loadAdditionalInformation();
return E('div', {}, sections.filter(Boolean));
},
async loadAdditionalInformation() {
const [containers, images, volumes, networks, pods] = await Promise.all([
podmanRPC.containers.list('all=true'),
podmanRPC.images.list(),
podmanRPC.volumes.list(),
podmanRPC.networks.list(),
podmanRPC.pods.list()
]);
const runningContainers = containers.filter((container) => container.isRunning()).length;
const runningPods = pods.filter((p) => p.Status === 'Running').length;
if (this.resourceCardsEl) {
dom.content(this.resourceCardsEl, [
this.createResourceCards(containers, pods, images, networks, volumes,
runningContainers, runningPods)
]);
}
},
createToolbar() {
return E('div', { class: 'overview-actions mb-sm' }, [
new podmanUI.Button(
_('Check for Updates'),
ui.createHandlerFn(this, 'handleAutoUpdate'),
'positive'
).render(),
new podmanUI.Button(
_('Cleanup / Prune'),
ui.createHandlerFn(this, 'handlePrune'),
'remove'
).render()
]);
},
createInfoSection(version, info) {
const memTotal = podmanUtil.format.bytes(info.host?.memTotal);
const memFree = podmanUtil.format.bytes(info.host?.memFree);
const table = new podmanUI.TableList()
.addRow(_('Podman Version'), version.Version || _('Unknown'))
.addRow(_('API Version'), version.ApiVersion || _('Unknown'))
.addRow(_('CPU'), info.host?.cpus?.toString() || _('Unknown'))
.addRow(_('Memory'), memFree + ' / ' + memTotal)
.addRow(_('Socket Path'), info.host?.remoteSocket?.path || '/run/podman/podman.sock')
.addRow(_('Graph Root'), info.store?.graphRoot || _('Unknown'))
.addRow(_('Run Root'), info.store?.runRoot || _('Unknown'))
.addRow(_('Registries'), this.getRegistries(info))
;
return this.createSection(_('Information'), table.render());
},
createDebugSection() {
this.systemDiagnosticsEl = E('div', { class: 'system-diagnostics', style: 'margin-bottom: 18px;' }, [
new podmanUI.Button(
_('Load'),
ui.createHandlerFn(this, 'handleLoadSystemDiagnostics'),
'positive'
).render(),
]);
return this.createSection(_('System Diagnostics'), this.systemDiagnosticsEl);
},
createResourcesSection() {
this.resourceCardsEl = E('div', { class: 'resources-cards' }, [
E('div', { class: 'loading-placeholder' }, [
E('em', { class: 'spinning' }, _('Loading...')),
]),
]);
return this.createSection(_('Resources'), this.resourceCardsEl);
},
createSection(headline, content) {
return E('div', {}, [
E('h3', {}, headline),
content,
]);
},
getRegistries(info) {
if (info.registries && info.registries.search) {
return info.registries.search.join(', ');
}
return 'docker.io, registry.fedoraproject.org, registry.access.redhat.com';
},
createResourceCards(containers, pods, images, networks, volumes, runningContainers,
runningPods) {
return E('div', { class: 'overview-cards' }, [
this.createCard('🐳', _('Containers'), 'containers', containers.length, runningContainers, 'admin/podman/containers'),
this.createCard('🔗', _('Pods'), 'pods', pods.length, runningPods, 'admin/podman/pods'),
this.createCard('💿', _('Images'), 'images', images.length, null, 'admin/podman/images'),
this.createCard('🌐', _('Networks'), 'networks', networks.length, null, 'admin/podman/networks'),
this.createCard('💾', _('Volumes'), 'volumes', volumes.length, null, 'admin/podman/volumes'),
]);
},
createCard(icon, title, slug, total, running, url) {
const statsText = running !== null ? running + ' / ' + total : total.toString();
return E('a', {
href: L.url(url),
class: 'd-flex flex-column cursor-pointer align-center overview-card-link overview-card-' + slug,
}, [
E('div', { class: 'card-link-header d-flex align-center w-100' }, [
E('span', { class: 'card-link-title text-bold' }, title),
icon,
]),
E('div', { class: 'w-100' }, [
E('div', { class: 'card-link-headline text-bold' }, statsText),
running !== null
? E('div', { class: 'card-link-text' }, _('running') + ' / ' + _('total'))
: E('div', { class: 'card-link-text' }, _('total'))
])
]);
},
async handleLoadSystemDiagnostics() {
this.loading(_('Load System diagnostics'));
const debugData = await podmanRPC.system.debug();
const statusIcons = { ok: '\u2713', warn: '\u26A0', error: '\u2717' };
const statusColors = { ok: '#2ecc71', warn: '#e67e22', error: '#e74c3c' };
const table = new podmanUI.TableList();
(debugData.checks || []).forEach((check) => {
const icon = statusIcons[check.status] || '?';
const color = statusColors[check.status] || '#666';
const detail = check.detail || '';
const message = check.message ? ' \u2014 ' + check.message : '';
table.addRow(
_(check.label),
E('span', { style: 'color:' + color }, [
E('strong', {}, icon + ' '),
E('span', { class: 'cli-value' }, detail),
message
])
);
});
dom.content(this.systemDiagnosticsEl, [table.render()]);
ui.hideModal();
},
handlePrune() {
PruneModal.render();
},
handleAutoUpdate() {
this.loading(_('Find updateable containers'));
// @todo Use podman api filter
return podmanRPC.containers.list('all=true').then((containers) => {
AutoUpdateModal
.setContainers(containers.filter((c) => c.getAutoUpdateLabel()))
.render();
});
},
});
@@ -0,0 +1,87 @@
'use strict';
'require dom';
'require form';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.form as podmanForm';
'require podman.model.Container as Container';
'require podman.utils as podmanUtil';
'require podman.TableSelectSection as TableSelectSection';
return podmanView.tabContent.extend({
tab: 'ps',
pod: null,
async render(pod) {
this.pod = pod;
const containers = [];
for (const podContainer of this.pod.getContainers()) {
const containerInspect = await Container.getSingleton(podContainer).inspect();
const container = Container.getSingleton(containerInspect);
containers.push(container);
}
this.map = new form.JSONMap({ containers }, '');
this.section = this.map.section(
TableSelectSection.TableSelectSection,
'containers',
'',
''
);
this.section.view = this;
this.section.anonymous = true;
this.section.footer = true;
this.section.selectKey = 'Id';
// this.section.createForm = PodmanFormContainer;
this.section.handleCreate = null;
this.section.handleRemove = null;
this.section.handleRefresh = null;
// this.section.handleRefreshCb = () => this.checkInitScripts();
// this.section.handleRefreshCb = null;
// this.section.toolbarExtraButtons = [
// new podmanUI.Button('&#9658;', ui.createHandlerFn(this, 'handleStart')).render(),
// new podmanUI.Button('&#9724;', ui.createHandlerFn(this, 'handleStop')).render(),
// new podmanUI.Button('&#8635;', ui.createHandlerFn(this, 'handleRestart')).render(),
// ];
let o;
o = this.section.option(podmanForm.field.DummyValue, 'Names', _('Name'));
o.cfgdatavalue = (container) => container.getName();
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
o.cfgdatavalue = (container) => container.getDetailLink(podmanUtil.truncate(container.getID(), 10));
o.width = '12%';
o = this.section.option(podmanForm.field.DummyValue, 'Image', _('Image'));
o.cfgdatavalue = (container) => container.getImageName();
o.width = '25%';
o = this.section.option(podmanForm.field.DummyValue, 'ImageTag', _('Tag'));
o.cfgdatavalue = (container) => container.getImageTag();
o.cfgformatter = (imageTag) => podmanUtil.truncate(imageTag, 10);
o.cfgtt = (imageTag) => imageTag.length > 10 ? imageTag : '';
o.width = '11%';
o = this.section.option(podmanForm.field.DummyValue, 'State', _('Status'));
o.cfgdatavalue = (container) => container.getStateBadge();
o.width = '8%';
o = this.section.option(podmanForm.field.DummyValue, 'StartedAt', _('Started At'));
o.cfgdatavalue = (container) => container.getStartedAt(true);
o.width = '17%';
o = this.section.option(podmanForm.field.DummyValue, 'InitScript', _('Boot'));
o.cfgvalue = () => E('span', { class: 'autostart-status autostart-' }, '...');
o.width = '6%';
return this.map.render();
},
});
@@ -0,0 +1,135 @@
'use strict';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.utils as podmanUtil';
'require podman.model.Container as Container';
const DASH = '-';
function joinOrDash(values) {
return Array.isArray(values) && values.length > 0 ? values.join(', ') : DASH;
}
function valueOrDash(value) {
return (value === undefined || value === null || value === '') ? DASH : value;
}
return podmanView.tabContent.extend({
tab: 'info',
pod: null,
render(pod) {
this.pod = pod;
const sections = [
this.basicSection(),
this.configSection(),
this.resourcesSection(),
this.networkSection(),
];
const labels = this.labelsSection();
if (labels) sections.push(labels);
return sections;
},
basicSection() {
const table = new podmanUI.TableList();
table
.addRow(_('Name'), this.pod.getName())
.addRow(_('ID'), (this.pod.getID() || '').substring(0, 64))
.addRow(_('Status'), this.pod.getStatusBadge())
.addRow(_('Created'), podmanUtil.format.date(this.pod.getCreatedAt()));
return E('div', {}, [
E('h4', {}, _('Basic Information')),
table.render(),
]);
},
configSection() {
const table = new podmanUI.TableList();
const infraId = this.pod.getInfraId();
const infraIdCell = infraId
? E('a', { href: L.url('admin/podman/container', infraId) }, infraId.substring(0, 12))
: DASH;
table
.addRow(_('Hostname'), valueOrDash(this.pod.Hostname))
.addRow(_('Cgroup parent'), valueOrDash(this.pod.CgroupParent))
.addRow(_('Exit policy'), valueOrDash(this.pod.ExitPolicy))
.addRow(_('Restart policy'), valueOrDash(this.pod.RestartPolicy))
.addRow(_('Shared namespaces'), joinOrDash(this.pod.SharedNamespaces))
.addRow(_('Infra container'), infraIdCell);
return E('div', {}, [
E('h4', {}, _('Configuration')),
table.render(),
]);
},
resourcesSection() {
const table = new podmanUI.TableList();
const memLimit = this.pod.memory_limit;
const memLimitDisplay = memLimit > 0 ? podmanUtil.format.bytes(memLimit) : _('Unlimited');
const cpuQuota = this.pod.cpu_quota;
const cpuQuotaDisplay = (cpuQuota === undefined || cpuQuota < 0) ? _('Unlimited') : `${cpuQuota} µs`;
const cpuPeriod = this.pod.cpu_period;
const cpuPeriodDisplay = cpuPeriod > 0 ? `${cpuPeriod} µs` : DASH;
table
.addRow(_('CPU period'), cpuPeriodDisplay)
.addRow(_('CPU quota'), cpuQuotaDisplay)
.addRow(_('CPU shares'), valueOrDash(this.pod.cpu_shares))
.addRow(_('CPU set'), valueOrDash(this.pod.cpuset_cpus))
.addRow(_('Memory limit'), memLimitDisplay);
return E('div', {}, [
E('h4', {}, _('Resources')),
table.render(),
]);
},
networkSection() {
const table = new podmanUI.TableList();
const infra = this.pod.InfraConfig || {};
table
.addRow(_('Networks'), joinOrDash(infra.Networks || this.pod.getNetworks()))
.addRow(_('Static IPv4'), valueOrDash(infra.StaticIP))
.addRow(_('Static MAC'), valueOrDash(infra.StaticMAC))
.addRow(_('DNS servers'), joinOrDash(infra.DNSServer))
.addRow(_('DNS search'), joinOrDash(infra.DNSSearch))
.addRow(_('DNS options'), joinOrDash(infra.DNSOption))
.addRow(_('Extra hosts'), joinOrDash(infra.HostAdd))
.addRow(_('Host network'), infra.HostNetwork ? _('Yes') : _('No'));
return E('div', {}, [
E('h4', {}, _('Network')),
table.render(),
]);
},
labelsSection() {
const labels = this.pod.getLabels();
const keys = Object.keys(labels || {});
if (keys.length === 0) return null;
const table = new podmanUI.TableList();
for (const key of keys) {
table.addRow(key, labels[key]);
}
return E('div', {}, [
E('h4', {}, _('Labels')),
table.render(),
]);
},
});
@@ -0,0 +1,96 @@
'use strict';
'require dom';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
return podmanView.tabContent.extend({
tab: 'ps',
pod: null,
render(pod) {
this.pod = pod;
if (!this.pod.isRunning()) {
return this.warningContent(_('Pod is not running'));
}
this.tableContent = E('div', { class: 'ps-table-content' }, []);
return this.renderTabContent('', [ this.tableContent ]);
},
onTabActive() {
if (!this.pod || !this.pod.isRunning() || this.processStream) return;
this.processStream = this.pod.streamTop((ps) => {
this.updateProcessList(ps);
});
},
onTabInactive() {
if (!this.processStream) return;
this.processStream.stop();
this.processStream = null;
},
updateProcessList(ps) {
if (!ps || !ps.Titles || !ps.Processes || ps.Titles.length === 0) {
return;
}
const psTable = new podmanUI.Table();
const columnWidth = `width: ${100 / ps.Titles.length}%;`;
const timeIndex = ps.Titles.indexOf('ELAPSED');
ps.Titles.forEach((title) => {
psTable.addHeader(_(title), { style: columnWidth });
});
ps.Processes.forEach((process) => {
psTable.addRow(process.map((detail, i) => ({
inner: i === timeIndex ? this._formatElapsedTime(detail) : detail
})));
});
dom.content(this.tableContent, psTable.render());
},
// @todo: duplicated code (containers-tab/processes.js)
_formatElapsedTime(timeStr) {
if (!timeStr) return '-';
const result = [];
const pattern = /(\d+(?:\.\d+)?)([ydhms])/g;
let match;
while ((match = pattern.exec(timeStr)) !== null) {
let value = parseFloat(match[1]);
const unit = match[2];
if (unit === 's' && value > 1000) {
value = Math.floor(value / 1000000000);
if (value >= 60) {
const mins = Math.floor(value / 60);
const secs = value % 60;
if (mins > 0) result.push(`${mins}m`);
if (secs > 0) result.push(`${secs}s`);
continue;
}
} else {
value = Math.floor(value);
}
if (unit === 's' || unit === 'm') {
result.push(String(value).padStart(2, '0') + unit);
} else {
result.push(value + unit);
}
}
return result.length > 0 ? result.join('') : timeStr;
}
});
@@ -0,0 +1,83 @@
'use strict';
'require dom';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.utils as podmanUtil';
return podmanView.tabContent.extend({
tab: 'stats',
pod: null,
render(pod) {
this.pod = pod;
if (!this.pod.isRunning()) {
return this.warningContent(_('Pod is not running'));
}
this.containerNames = this.buildContainerNameMap();
this.tableContent = E('div', { class: 'stats-table-content' }, []);
return this.renderTabContent('', [ this.tableContent ]);
},
onTabActive() {
if (!this.pod || !this.pod.isRunning() || this.statsStream) return;
this.statsStream = this.pod.streamStats((reports) => {
this.updateStats(reports);
});
},
onTabInactive() {
if (!this.statsStream) return;
this.statsStream.stop();
this.statsStream = null;
},
buildContainerNameMap() {
const map = {};
for (const c of (this.pod.getContainers() || [])) {
if (c?.Id) map[c.Id] = c.Name;
}
return map;
},
updateStats(reports) {
if (!Array.isArray(reports)) return;
const table = new podmanUI.Table();
const headers = [
_('Container'),
_('CID'),
_('CPU %'),
_('Memory'),
_('Memory %'),
_('Net I/O'),
_('Block I/O'),
_('PIDs'),
];
const columnWidth = `width: ${100 / headers.length}%;`;
headers.forEach((h) => table.addHeader(h, { style: columnWidth }));
for (const r of reports) {
const cid = r.CID || '';
const name = this.containerNames[cid] || r.Name || '-';
table.addRow([
{ inner: name },
{ inner: podmanUtil.truncate(cid, 12) },
{ inner: r.CPU || '-' },
{ inner: r.MemUsage || '-' },
{ inner: r.Mem || '-' },
{ inner: r.NetIO || '-' },
{ inner: r.BlockIO || '-' },
{ inner: r.PIDS || '-' },
]);
}
dom.content(this.tableContent, table.render());
},
});
@@ -0,0 +1,150 @@
'use strict';
'require ui';
'require dom';
'require podman.ui as podmanUI';
'require podman.view as podmanView';
'require podman.model.Pod as Pod';
'require view.podman.pod-tab.info as PodInfoTab';
'require view.podman.pod-tab.containers as PodContainersTab';
'require view.podman.pod-tab.stats as PodStatsTab';
'require view.podman.pod-tab.processes as PodProcessesTab';
return podmanView.container.extend({
pod: null,
data: null,
listUrl: L.url('admin/podman/pods'),
async load() {
const path = L.location();
const matches = path.match(/pod\/([a-f0-9]+)/i);
if (!matches || !matches[1]) {
this.redirectToList();
return;
}
const podId = matches[1];
const pod = Pod.getSingleton({ Id: podId });
const inspectData = await pod.inspect();
this.data = inspectData;
return Pod.getSingleton(inspectData);
},
async render(pod) {
if (!pod) {
this.redirectToList();
return;
}
this.pod = pod;
this.tabs
.addTab('info', _('Info'))
.addTab('containers', _('Containers'))
.addTab('stats', _('Stats'))
.addTab('ps', _('Processes'))
.addTab('inspect', _('Inspect'));
requestAnimationFrame(() => {
this.renderInfoTab();
this.renderContainersTab();
this.renderStatsTab();
this.renderProcessesTab();
this.renderInspectTab();
});
return this.super('render', []);
},
createHeader() {
return this.super('createHeader', [
this.pod.getName(),
this.pod.isRunning(),
this.pod.isStopped(),
this.pod.isPaused(),
]);
},
stopStreams() {
this.getTabInstance('stats')?.onTabInactive();
this.getTabInstance('ps')?.onTabInactive();
},
async renderInfoTab() {
const content = await PodInfoTab.render(this.pod);
this.renderTab('info', content);
},
async renderContainersTab() {
const content = await PodContainersTab.render(this.pod);
this.renderTab('containers', content);
},
async renderStatsTab() {
const content = await PodStatsTab.render(this.pod);
this.renderTab('stats', content);
},
async renderProcessesTab() {
const content = await PodProcessesTab.render(this.pod);
this.renderTab('ps', content);
},
renderInspectTab() {
this.renderTab('inspect', new podmanUI.JsonArea(this.data).render());
},
async handleStart() {
if (this.pod.isRunning()) {
return;
}
this.loading(_('Start pod'));
if (this.pod.isPaused()) {
this.pod.unpause().then(() => window.location.reload());
return;
}
this.pod.start().then(() => window.location.reload());
},
async handleStop() {
if (!this.pod.isRunning() && !this.pod.isPaused()) {
return;
}
this.loading(_('Stop pod'));
this.stopStreams();
this.pod.stop().then(() => window.location.reload());
},
async handleRestart() {
this.loading(_('Restart pod'));
this.stopStreams();
this.pod.restart().then(() => window.location.reload());
},
async handlePause() {
if (!this.pod.isRunning()) {
return;
}
this.loading(_('Pause pod'));
this.stopStreams();
this.pod.pause().then(() => window.location.reload());
},
async handleRemove() {
this.confirm([
E('p', {}, _('Are you sure to remove pod?')),
], async () => {
this.loading(_('Remove pod'));
this.pod.remove().then(() => this.redirectToList());
});
},
});
@@ -0,0 +1,431 @@
:root {
--podman-terminal-bg: #1a1a1a;
--podman-terminal-fg: #0f0;
--warn-background-color-dark: rgba(166, 141, 69, 0.2);
}
.overview-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1.3125rem;
}
div.cbi-tooltip-container {
width: fit-content;
}
div.cbi-tooltip-container div.cbi-tooltip {
font-size: 13px;
font-weight: 400;
line-height: normal;
}
.overview-card-link,
.overview-card-link:hover,
.overview-card-link:focus {
text-decoration: none;
color: inherit;
justify-content: space-between;
min-height: 120px;
padding: 1.3125rem;
margin: 0 0 .625rem 0;
transition: all 0.2s ease-in-out;
}
.overview-card-link .card-link-header {
justify-content: space-between;
margin-bottom: .625rem;
font-size: 20px;
}
.overview-card-link .card-link-title {
font-size: 16px;
opacity: 0.8;
}
.overview-card-link .card-link-icon {
font-size: 24px;
opacity: 0.6;
}
.overview-card-link .card-link-headline {
font-size: 32px;
}
.overview-card-link .card-link-text {
font-size: 12px;
opacity: 0.7;
margin-top: .3125rem;
}
.overview-card-containers {
border-left: 4px solid #3498db;
}
.overview-card-containers .card-link-headline {
color: #3498db;
}
.overview-card-pods {
border-left: 4px solid #2ecc71;
}
.overview-card-pods .card-link-headline {
color: #2ecc71;
}
.overview-card-images {
border-left: 4px solid #9b59b6;
}
.overview-card-images .card-link-headline {
color: #9b59b6;
}
.overview-card-networks {
border-left: 4px solid #e67e22;
}
.overview-card-networks .card-link-headline {
color: #e67e22;
}
.overview-card-volumes {
border-left: 4px solid #34495e;
}
.overview-card-volumes .card-link-headline {
color: #34495e;
}
.border-0 {
border: 0 !important;
}
.cursor-pointer {
cursor: pointer;
}
.d-flex {
display: flex;
}
.align-center {
align-items: center;
}
.align-start {
align-items: start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: end;
}
.flex-column {
flex-direction: column;
}
.gap-5 {
gap: .3125rem;
}
.cbi-button-active {
background: linear-gradient(lightgray, lightgray 25%, var(--background-color-low));
}
.badge {
font-size: 0.85em;
padding: 0 4px;
text-align: center;
border-radius: .3125rem;
width: fit-content;
}
.badge.running {
background-color: var(--success-color-high);
}
.badge.exited {
background-color: var(--error-color-high);
}
.badge.created {
background-color: var(--background-color-low);
}
.badge.paused {
background-color: var(--warning-color-high, #d68a00);
}
.badge.degraded {
background-color: var(--warning-color-high, #d68a00);
}
.badge.stopped,
.badge.dead {
background-color: var(--error-color-high);
}
.badge.running,
.badge.error,
.badge.exited,
.badge.paused,
.badge.degraded,
.badge.stopped,
.badge.dead {
color: white;
}
.tooltip {
border-bottom: 1px dotted;
}
.w-100 {
width: 100%;
}
.d-none {
display: none;
}
.modal.loading-modal {
padding: 1em;
}
.cbi-checkbox + label {
margin-left: 3px;
}
.text-success {
color: var(--success-color-high);
}
.text-error {
color: var(--error-color-high);
}
.mr-xs {
margin-right: .3125rem;
}
.ml-xs {
margin-left: .3125rem;
}
.ml-sm {
margin-left: .625rem;
}
.mt-sm {
margin-top: .625rem;
}
.mb-xs {
margin-bottom: .3125rem;
}
.mb-sm {
margin-bottom: .625rem;
}
.p-sm {
padding: .625rem;
}
.text-bold {
font-weight: bold;
}
.text-center {
text-align: center !important;
}
.d-flex {
display: flex;
}
.gap-xs {
gap: .25rem;
}
.gap-sm {
gap: .625rem;
}
.editable-field {
width: 100%;
display: flex;
}
.table-list>tr>td:first-of-type {
width: 33%;
font-weight: bold;
}
.code-area,
.json-area,
.terminal-area {
max-height: 600px;
font-weight: 100;
overflow: auto !important;
white-space: pre !important;
display: block;
word-wrap: normal;
}
.json-area .key {
color: #660066;
font-weight: bold;
}
.json-area .string {
color: #007700;
}
.json-area .boolean {
color: #aa5500;
}
.json-area .null {
color: #777777;
}
.json-area .number {
color: #0000ff;
}
.code-area.bash .comment {
color: #888888;
font-style: italic;
}
.code-area.bash .string {
color: #007700;
}
.code-area.bash .variable {
color: #aa5500;
}
.code-area.bash .keyword {
color: #0000ff;
font-weight: bold;
}
.code-area.bash .number {
color: #ff4500;
}
.terminal-area {
min-height: 100px;
background: var(--podman-terminal-bg);
color: var(--podman-terminal-fg);
}
.autostart-enabled {
color: var(--success-color-high);
}
.autostart-none,
.autostart-disabled {
color: var(--text-color-medium);
}
.autostart-missing {
color: var(--warn-color-high);
cursor: pointer;
}
.autostart-error {
color: var(--error-color-high);
}
.loading-placeholder {
text-align: center;
padding: 30px;
}
.cbi-button-none {
border: 0;
background: transparent;
box-shadow: none;
}
.container-toolbar .d-flex {
gap: .25rem;
}
.checkbox-with-label .cbi-checkbox {
display: flex;
align-items: center;
}
.container-icon-disconnect {
border: 0;
}
#log-viewer {
height: 600px;
overflow: auto;
white-space: pre;
}
#log-lines {
width: 50px;
}
#log-since,
#log-until {
width: 100px;
}
#log-lines input,
#log-since input,
#log-until input {
width: inherit;
}
.cbi-section-table-select {
table-layout: fixed;
}
th[data-widget="Podman.Form.SelectDummyValue"] {
padding: .625rem .625rem 8px !important;
}
th[data-widget="Podman.Form.SelectDummyValue"] .cbi-checkbox {
margin-top: 1px;
}
td[data-widget="Podman.Form.SelectDummyValue"] {
padding: .625rem .625rem 7px !important;
}
td[data-widget="Podman.Form.SelectDummyValue"] .cbi-checkbox {
margin-top: 2px;
}
.podman-stream-log {
font-family: monospace;
font-size: 0.85rem;
line-height: 1.35;
background: #1e1e1e;
color: #e8e8e8;
border: 1px solid #444;
border-radius: 3px;
padding: 0.5rem 0.75rem;
max-height: 320px;
overflow-y: auto;
overflow-x: auto;
white-space: pre;
}
.podman-stream-log .log-line {
white-space: pre;
word-break: keep-all;
}
@@ -0,0 +1,172 @@
'use strict';
'require ui';
'require podman.rpc as podmanRPC';
'require podman.utils as utils';
'require podman.ui as podmanUI';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.form.pod as PodmanFormPod';
'require podman.model.Container as Container';
return podmanView.list.extend({
sectionName: 'pods',
title: _('Pods'),
titleSingle: _('Pod'),
async load() {
return podmanRPC.pods.list();
},
async updateSection() {
this.section.selectKey = 'Id';
this.section.createForm = PodmanFormPod;
this.section.toolbarExtraButtons = [
new podmanUI.Button('&#9658;', ui.createHandlerFn(this, 'handleStart')).render(),
new podmanUI.Button('&#9724;', ui.createHandlerFn(this, 'handleStop')).render(),
new podmanUI.Button('&#8635;', ui.createHandlerFn(this, 'handleRestart')).render(),
new podmanUI.Button('&#10074;&#10074;', ui.createHandlerFn(this, 'handlePause')).render(),
];
let o;
o = this.section.option(podmanForm.field.DummyValue, 'Name', _('Name'));
o = this.section.option(podmanForm.field.DummyValue, 'Id', _('ID'));
o.cfgdatavalue = (pod) => pod.getDetailLink(utils.truncate(pod.getID(), 16));
o.width = '20%';
o = this.section.option(podmanForm.field.DummyValue, 'Status', _('Status'));
o.cfgdatavalue = (pod) => pod.getStatusBadge();
o.width = '10%';
o = this.section.option(podmanForm.field.DummyValue, 'Containers', _('Containers'));
o.cfgdatavalue = (pod) => this.renderContainersCell(pod);
o.width = '12%';
o = this.section.option(podmanForm.field.DummyValue, 'Networks', _('Networks'));
o.cfgdatavalue = (pod) => {
const nets = pod.getNetworks();
return nets.length > 0 ? nets.join(', ') : '-';
};
o.width = '15%';
o = this.section.option(podmanForm.field.DateDummyValue, 'Created', _('Created'));
o.width = '20%';
},
renderContainersCell(pod) {
const count = pod.getContainers().length;
if (count === 0) {
return E('span', {}, '0');
}
return E('a', {
href: '#',
class: 'text-bold',
click: (ev) => {
ev.preventDefault();
this.handleShowContainers(pod);
}
}, `${count} ▸`);
},
handleShowContainers(pod) {
const containers = pod.getContainers();
const headerRow = E('tr', {}, [
E('th', {}, _('Name')),
E('th', {}, _('ID')),
E('th', {}, _('Status')),
]);
const rows = containers.map((c) => {
const container = Container.getSingleton(c);
const idShort = utils.truncate(container.getID(), 10);
const idCell = container.getDetailLink(idShort);
return E('tr', {}, [
E('td', {}, container.getName()),
E('td', {}, idCell),
E('td', {}, container.getStateBadge()),
]);
});
// @todo: create table with helpers or show a simpler list
const table = E('table', {
class: 'table cbi-section-table'
}, [
E('thead', {}, headerRow),
E('tbody', {}, rows.length > 0 ? rows : [
E('tr', {}, E('td', {
colspan: 3,
class: 'text-center'
}, _('No containers in this pod'))),
]),
]);
const title = _('Containers in pod %s').format(pod.getName());
const modal = new podmanUI.Modal(title, [table]);
modal.render();
},
handleStart() {
return this.runOnSelected(
(pod) => pod.isPaused() ? pod.unpause() : pod.start(),
_('Starting pod')
);
},
handleStop() {
return this.runOnSelected((pod) => pod.stop(), _('Stopping pod'));
},
handleRestart() {
return this.runOnSelected((pod) => pod.restart(), _('Restarting pod'));
},
handlePause() {
return this.runOnSelected((pod) => pod.pause(), _('Pausing pod'));
},
async runOnSelected(action, textLoad) {
const section = this.getSection();
const selected = section.getSelectedData();
if (selected.length === 0) {
return section.showNoneSelectedWarning();
}
const failures = [];
for (const [i, pod] of selected.entries()) {
this.loading(_('%s: %d/%d').format(textLoad, i + 1, selected.length));
const result = await action(pod);
if (result?.Errs?.length > 0) {
failures.push({
name: pod.getName(),
errs: result.Errs
});
}
}
ui.hideModal();
if (failures.length > 0) {
const blocks = failures.map(({
name,
errs
}) => E('div', {
class: 'mb-sm'
}, [
E('strong', {}, name + ':'),
E('ul', {
class: 'm-0'
}, errs.map((e) => E('li', {}, e))),
]));
podmanUI.alert(blocks, 'error');
}
section.handleRefresh();
},
});
@@ -0,0 +1,59 @@
'use strict';
'require form';
'require podman.utils as utils';
'require podman.rpc as podmanRPC';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.form.secret as PodmanFormSecret';
const SECRET_LABELS_TRUNCATE_LEN = 30;
/**
* Manage podman secrets
*/
return podmanView.list.extend({
sectionName: 'secrets',
title: _('Secrets'),
titleSingle: _('Secret'),
/**
* Load secret data
*/
async load() {
return podmanRPC.secrets.list();
},
/**
* Update list section
*/
async updateSection() {
this.section.selectKey = 'ID';
this.section.createForm = PodmanFormSecret;
let o;
o = this.section.option(podmanForm.field.LinkDummyValue, 'Name', _('Name'));
o.cfgdatavalue = (secret) => secret.getName();
o.click = (_value, secret) => this.section.handleInspect(secret, ['SecretData']);
o = this.section.option(podmanForm.field.DummyValue, 'Driver', _('Driver'));
o.cfgdatavalue = (secret) => secret.getDriver();
o.width = '10%';
o = this.section.option(podmanForm.field.DummyValue, 'Labels', _('Labels'));
o.cfgdatavalue = (secret) => {
const labels = secret.getLabels();
const keys = Object.keys(labels);
return keys.length ? keys.map((key) => `${key}=${labels[key]}`).join(', ') : '-';
};
o.cfgformatter = (labels) => utils.truncate(labels, SECRET_LABELS_TRUNCATE_LEN);
o.cfgtt = (labels) => labels.length > SECRET_LABELS_TRUNCATE_LEN ? labels : '';
o.width = '20%';
o = this.section.option(podmanForm.field.DateDummyValue, 'CreatedAt', _('Created'));
o.width = '20%';
},
});
@@ -0,0 +1,122 @@
'use strict';
'require ui';
'require fs';
'require podman.utils as utils';
'require podman.rpc as podmanRPC';
'require podman.ui as podmanUI';
'require podman.form as podmanForm';
'require podman.view as podmanView';
'require podman.form.volume as PodmanFormVolume';
'require podman.form.volumeImport as PodmanFormVolumeImport';
const VOLUME_MOUNT_TRUNCATE_LEN = 30;
return podmanView.list.extend({
sectionName: 'volumes',
title: _('Volumes'),
titleSingle: _('Volume'),
async load() {
return podmanRPC.volumes.list();
},
async updateSection() {
this.section.selectKey = 'Name';
this.section.createForm = PodmanFormVolume;
this.section.toolbarExtraButtons = [
new podmanUI.ButtonNew(_('Import'), {
click: ui.createHandlerFn(this, 'handleImport'),
}).render(),
new podmanUI.ButtonNew(_('Export'), {
click: ui.createHandlerFn(this, 'handleExport'),
}).render(),
];
let o;
o = this.section.option(podmanForm.field.LinkDummyValue, 'VolumeName', _('Name'));
o.cfgdatavalue = (volume) => utils.truncate(volume.getName(), 20);
o.cfgtt = (_cfg, volume) => volume.getName().length > 20 ? volume.getName() : '';
o.click = (_cfg, volume) => this.section.handleInspect(volume);
o = this.section.option(podmanForm.field.DummyValue, 'Driver', _('Driver'));
o.width = '10%';
o = this.section.option(podmanForm.field.DummyValue, 'Mountpoint', _('Mountpoint'));
o.cfgformatter = (mountpoint) => utils.truncate(mountpoint, VOLUME_MOUNT_TRUNCATE_LEN);
o.cfgtt = (mountpoint) => mountpoint.length > VOLUME_MOUNT_TRUNCATE_LEN ? mountpoint : '';
o.width = '35%';
o = this.section.option(podmanForm.field.DateDummyValue, 'CreatedAt', _('Created'));
o.width = '20%';
},
handleImport() {
const fileInput = E('input', {
type: 'file',
accept: '.tar,.tar.gz,.tgz',
class: 'hidden'
});
fileInput.addEventListener('change', async (ev) => {
document.body.removeChild(fileInput);
const file = ev.target.files[0];
if (!file) return;
const importForm = new PodmanFormVolumeImport.init();
const formContent = await importForm.render(file);
const title = _('Import from archive: %s').format(file.name);
const modal = new podmanUI.Modal(title, [ formContent ]);
modal.getButtons = () => [
modal.getCloseButton(),
new podmanUI.ButtonNew(_('Import'), {
click: async () => {
await importForm.handleCreate();
await this.section.handleRefresh();
},
type: 'positive',
}).render(),
];
modal.render();
});
document.body.appendChild(fileInput);
fileInput.click();
},
async handleExport() {
const selected = this.section.getSelectedData();
if (selected.length === 0) {
return this.section.showNoneSelectedWarning();
}
let errors = 0;
for (const [index, volume] of selected.entries()) {
this.loading(_('Exporting selected volumes: %d/%d').format(index + 1, selected.length));
const volumeName = volume.getName();
try {
const blob = await fs.exec_direct('/usr/libexec/podman-api', ['volume_export', volumeName], 'blob');
const url = URL.createObjectURL(blob);
const a = E('a', { href: url, download: `${volumeName}.tar` });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (err) {
podmanUI.alert(_('Error: %s').format(err.message), 'error');
errors++;
}
}
ui.hideModal();
if (errors === 0)
this.success(_('All volumes exported successfully'));
},
});