mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +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,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