mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-04 02:13:56 +08:00
💋 Sync 2026-10-04 04:16:09
This commit is contained in:
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')} ${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: <network-name>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('◼', {
|
||||
click: () => this.stopStream(),
|
||||
type: ' stop-button',
|
||||
tooltip: _('Stop stream'),
|
||||
}).render();
|
||||
|
||||
this.playButton = new podmanUI.ButtonNew('►', {
|
||||
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('►', ui.createHandlerFn(this, 'handleStart')).render(),
|
||||
// new podmanUI.Button('◼', ui.createHandlerFn(this, 'handleStop')).render(),
|
||||
// new podmanUI.Button('↻', 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('►', ui.createHandlerFn(this, 'handleStart')).render(),
|
||||
new podmanUI.Button('◼', ui.createHandlerFn(this, 'handleStop')).render(),
|
||||
new podmanUI.Button('↻', ui.createHandlerFn(this, 'handleRestart')).render(),
|
||||
new podmanUI.Button('❚❚', 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'));
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user