mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 19:01:56 +00:00
This is a complete rewrite of the original Lua dockerman in ECMAScript and ucode. Now with most of the Lua gone, we can rename LuCI to JUCI. JavaScript ucode Configuration Interface :) Docker manager basically saw no updates or bug fixes due to the Lua update embargo and transition to ECMAScript in the luci repo. But now that the app is rewritten, updates should come readily from the community. Expect a few bugs in this, although it has seen lots of testing - it's also seen lots of development in different directions. Networking scenarios might require some additions and fixes to the GUI. Swarm functionality is not implemented in this client and is left as an exercise to the community and those with time. All functionality found in the original Lua version is present in this one, except for container "upgrade". Some minor differences are introduced to improve layout and logic. There is no "remote endpoint" any longer since sockets are the main method of connecting to dockerd - and sockets accept remote connections. Docker manager and dockerd on the same host are a remote connection. Buuut, dockerd removes listening on any IP without --tls* options after v27. There is no encryption between docker manager and the API endpoint, or the container consoles when using the standard /var/run/docker.sock. See: https://github.com/openwrt/luci/issues/7310 TODO: handle image update ("Upgrade") for a container Signed-off-by: Paul Donald <newtwen+github@gmail.com>
361 lines
11 KiB
JavaScript
361 lines
11 KiB
JavaScript
'use strict';
|
|
'require form';
|
|
'require fs';
|
|
'require poll';
|
|
'require ui';
|
|
'require dockerman.common as dm2';
|
|
|
|
/*
|
|
Copyright 2026
|
|
Docker manager JS for Luci by Paul Donald <newtwen+github@gmail.com>
|
|
Based on Docker Lua by lisaac <https://github.com/lisaac/luci-app-dockerman>
|
|
LICENSE: GPLv2.0
|
|
*/
|
|
|
|
/* API v1.52:
|
|
|
|
GET /containers/{id}/json: the NetworkSettings no longer returns the deprecated
|
|
Bridge, HairpinMode, LinkLocalIPv6Address, LinkLocalIPv6PrefixLen,
|
|
SecondaryIPAddresses, SecondaryIPv6Addresses, EndpointID, Gateway,
|
|
GlobalIPv6Address, GlobalIPv6PrefixLen, IPAddress, IPPrefixLen, IPv6Gateway,
|
|
and MacAddress fields. These fields were deprecated in API v1.21 (docker
|
|
v1.9.0) but kept around for backward compatibility.
|
|
|
|
*/
|
|
|
|
return dm2.dv.extend({
|
|
load() {
|
|
return Promise.all([
|
|
dm2.container_list({query: {all: true}}),
|
|
dm2.image_list({query: {all: true}}),
|
|
dm2.network_list({query: {all: true}}),
|
|
]);
|
|
},
|
|
|
|
render([containers, images, networks]) {
|
|
if (containers?.code !== 200) {
|
|
return E('div', {}, [ containers?.body?.message ]);
|
|
}
|
|
|
|
let container_list = containers.body;
|
|
let network_list = networks.body;
|
|
let image_list = images.body;
|
|
|
|
const view = this;
|
|
let containerTable;
|
|
|
|
|
|
const m = new form.JSONMap({container: view.getContainersTable(container_list, image_list, network_list), prune: {}},
|
|
_('Docker - Containers'),
|
|
_('This page displays all docker Containers that have been created on the connected docker host.') + '<br />' +
|
|
_('Note: docker provides no container import facility.'));
|
|
m.submit = false;
|
|
m.reset = false;
|
|
|
|
let s, o;
|
|
|
|
|
|
let pollPending = null;
|
|
let conSec = null;
|
|
const calculateTotals = () => {
|
|
return {
|
|
running_total: Array.isArray(container_list) ?
|
|
container_list.filter(c => c?.State === 'running').length : 0,
|
|
paused_total: Array.isArray(container_list) ?
|
|
container_list.filter(c => c?.State === 'paused').length : 0,
|
|
stopped_total: Array.isArray(container_list) ?
|
|
container_list.filter(c => ['exited', 'created'].includes(c?.State)).length : 0
|
|
};
|
|
};
|
|
|
|
const refresh = () => {
|
|
if (pollPending) return pollPending;
|
|
pollPending = view.load().then(([containers2, images2, networks2]) => {
|
|
image_list = images2.body;
|
|
container_list = containers2.body;
|
|
network_list = networks2.body;
|
|
m.data = new m.data.constructor({ container: view.getContainersTable(container_list, image_list, network_list), prune: {} });
|
|
|
|
const totals = calculateTotals();
|
|
if (conSec) {
|
|
conSec.footer = [
|
|
`${_('Total')} ${container_list.length}`,
|
|
[
|
|
`${_('Running')} ${totals.running_total}`,
|
|
E('br'),
|
|
`${_('Paused')} ${totals.paused_total}`,
|
|
E('br'),
|
|
`${_('Stopped')} ${totals.stopped_total}`,
|
|
],
|
|
'',
|
|
'',
|
|
];
|
|
}
|
|
|
|
return m.render();
|
|
}).catch((err) => { console.warn(err) }).finally(() => { pollPending = null });
|
|
return pollPending;
|
|
};
|
|
|
|
s = m.section(form.TableSection, 'prune', _('Containers overview'), null);
|
|
s.addremove = false;
|
|
s.anonymous = true;
|
|
|
|
const prune = s.option(form.Button, '_prune', null);
|
|
prune.inputtitle = `${dm2.ActionTypes['prune'].i18n} ${dm2.ActionTypes['prune'].e}`;
|
|
prune.inputstyle = 'negative';
|
|
prune.onclick = L.bind(function(section_id, ev) {
|
|
return this.super('handleXHRTransfer', [{
|
|
q_params: { },
|
|
commandCPath: '/containers/prune',
|
|
commandDPath: '/containers/prune',
|
|
commandTitle: dm2.ActionTypes['prune'].i18n,
|
|
onUpdate: (msg) => {
|
|
try {
|
|
if(msg.error)
|
|
ui.addTimeLimitedNotification(dm2.ActionTypes['prune'].i18n, msg.error, 7000, 'error');
|
|
|
|
const output = JSON.stringify(msg, null, 2) + '\n';
|
|
view.insertOutput(output);
|
|
} catch { }
|
|
},
|
|
noFileUpload: true,
|
|
}]);
|
|
}, this);
|
|
|
|
const totals = calculateTotals();
|
|
let running_total = totals.running_total;
|
|
let paused_total = totals.paused_total;
|
|
let stopped_total = totals.stopped_total;
|
|
|
|
conSec = m.section(form.TableSection, 'container');
|
|
conSec.anonymous = true;
|
|
conSec.nodescriptions = true;
|
|
conSec.addremove = true;
|
|
conSec.sortable = true;
|
|
conSec.filterrow = true;
|
|
conSec.addbtntitle = `${dm2.ActionTypes['create'].i18n} ${dm2.ActionTypes['create'].e}`;
|
|
conSec.footer = [
|
|
`${_('Total')} ${container_list.length}`,
|
|
[
|
|
`${_('Running')} ${running_total}`,
|
|
E('br'),
|
|
`${_('Paused')} ${paused_total}`,
|
|
E('br'),
|
|
`${_('Stopped')} ${stopped_total}`,
|
|
],
|
|
'',
|
|
'',
|
|
];
|
|
|
|
conSec.handleAdd = function(section_id, ev) {
|
|
window.location.href = `${view.dockerman_url}/container_new`;
|
|
};
|
|
|
|
conSec.renderRowActions = function(sid) {
|
|
const cont = this.map.data.data[sid];
|
|
return view.buildContainerActions(cont);
|
|
}
|
|
|
|
o = conSec.option(form.DummyValue, 'cid', _('Container'));
|
|
o = conSec.option(form.DummyValue, 'State', _('State'));
|
|
o = conSec.option(form.DummyValue, 'Networks', _('Networks'));
|
|
o.rawhtml = true;
|
|
// o = conSec.option(form.DummyValue, 'Ports', _('Ports'));
|
|
// o.rawhtml = true;
|
|
o = conSec.option(form.DummyValue, 'Command', _('Command'));
|
|
o.width = 200;
|
|
o = conSec.option(form.DummyValue, 'Created', _('Created'));
|
|
|
|
poll.add(L.bind(() => { refresh(); }, this), 10);
|
|
|
|
this.insertOutputFrame(conSec, m);
|
|
return m.render();
|
|
|
|
},
|
|
|
|
buildContainerActions(cont, idx) {
|
|
const view = this;
|
|
const isRunning = cont?.State === 'running';
|
|
const isPaused = cont?.State === 'paused';
|
|
const btns = [
|
|
E('button', {
|
|
'class': 'cbi-button view',
|
|
'title': dm2.ActionTypes['inspect'].i18n,
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_inspect,
|
|
{id: cont.Id},
|
|
dm2.ActionTypes['inspect'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
)
|
|
}, [dm2.ActionTypes['inspect'].e]),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-positive edit',
|
|
'title': _('Edit this container'),
|
|
'click': () => window.location.href = `${view.dockerman_url}/container/${cont?.Id}`
|
|
}, [dm2.ActionTypes['edit'].e]),
|
|
|
|
(() => {
|
|
const icon = isRunning
|
|
? dm2.Types['container'].sub['pause'].e
|
|
: (isPaused
|
|
? dm2.Types['container'].sub['unpause'].e
|
|
: dm2.Types['container'].sub['start'].e);
|
|
const title = isRunning
|
|
? _('Pause this container')
|
|
: (isPaused ? _('Unpause this container') : _('Start this container'));
|
|
const handler = isRunning
|
|
? () => view.executeDockerAction(
|
|
dm2.container_pause,
|
|
{id: cont.Id},
|
|
dm2.Types['container'].sub['pause'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
)
|
|
: (isPaused ? () => view.executeDockerAction(
|
|
dm2.container_unpause,
|
|
{id: cont.Id},
|
|
dm2.Types['container'].sub['unpause'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
) : () => view.executeDockerAction(
|
|
dm2.container_start,
|
|
{id: cont.Id},
|
|
dm2.Types['container'].sub['start'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
));
|
|
const btnClass = isRunning ? 'cbi-button cbi-button-neutral' : 'cbi-button cbi-button-positive start';
|
|
|
|
return E('button', {
|
|
'class': btnClass,
|
|
'title': title,
|
|
'click': handler,
|
|
}, [icon]);
|
|
})(),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-neutral restart',
|
|
'title': _('Restart this container'),
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_restart,
|
|
{id: cont.Id},
|
|
_('Restart'),
|
|
{showOutput: true, showSuccess: false}
|
|
)
|
|
}, [dm2.Types['container'].sub['restart'].e]),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-neutral stop',
|
|
'title': _('Stop this container'),
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_stop,
|
|
{id: cont.Id},
|
|
dm2.Types['container'].sub['stop'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
),
|
|
'disabled' : !(isRunning || isPaused) ? true : null
|
|
}, [dm2.Types['container'].sub['stop'].e]),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-negative kill',
|
|
'title': _('Kill this container'),
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_kill,
|
|
{id: cont.Id},
|
|
dm2.Types['container'].sub['kill'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
),
|
|
'disabled' : !(isRunning || isPaused) ? true : null
|
|
}, [dm2.Types['container'].sub['kill'].e]),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-neutral export',
|
|
'title': _('Export this container'),
|
|
'click': () => {
|
|
window.location.href = `${view.dockerman_url}/container/export/${cont.Id}`;
|
|
}
|
|
}, [dm2.Types['container'].sub['export'].e]),
|
|
|
|
E('div', {
|
|
'style': 'width: 20px',
|
|
// Some safety margin for mis-clicks
|
|
}, [' ']),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-negative remove',
|
|
'title': dm2.ActionTypes['remove'].i18n,
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_remove,
|
|
{id: cont.Id, query: { force: false }},
|
|
dm2.ActionTypes['remove'].i18n,
|
|
{showOutput: true, showSuccess: false}
|
|
)
|
|
}, [dm2.ActionTypes['remove'].e]),
|
|
|
|
E('button', {
|
|
'class': 'cbi-button cbi-button-negative important remove',
|
|
'title': dm2.ActionTypes['force_remove'].i18n,
|
|
'click': () => view.executeDockerAction(
|
|
dm2.container_remove,
|
|
{id: cont.Id, query: { force: true }},
|
|
_('Force Remove'),
|
|
{showOutput: true, showSuccess: false}
|
|
)
|
|
}, [dm2.ActionTypes['force_remove'].e]),
|
|
];
|
|
|
|
return E('td', {
|
|
'class': 'td',
|
|
}, E('div', btns));
|
|
},
|
|
|
|
handleSave: null,
|
|
handleSaveApply: null,
|
|
handleReset: null,
|
|
|
|
getContainersTable(containers, image_list, network_list) {
|
|
const data = [];
|
|
|
|
for (const cont of Array.isArray(containers) ? containers : []) {
|
|
|
|
// build Container ID: xxxxxxx image: xxxx
|
|
const names = Array.isArray(cont?.Names) ? cont.Names : [];
|
|
const cleanedNames = names
|
|
.map(n => (typeof n === 'string' ? n.substring(1) : ''))
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
const statusColorName = this.wrapStatusText(cleanedNames, cont.State, 'font-weight:600;');
|
|
const imageName = this.getImageFirstTag(image_list, cont.ImageID);
|
|
const shortId = (cont?.Id || '').substring(0, 12);
|
|
|
|
const cid = E('div', {}, [
|
|
E('a', { href: `container/${cont.Id}`, title: dm2.ActionTypes['edit'].i18n }, [
|
|
statusColorName,
|
|
E('div', { 'style': 'font-size: 0.9em; font-family: monospace; ' }, [`ID: ${shortId}`]),
|
|
]),
|
|
E('div', { 'style': 'font-size: 0.85em;' }, [`${dm2.Types['image'].i18n}: ${imageName}`]),
|
|
])
|
|
|
|
// Just push plain data objects without UCI metadata
|
|
data.push({
|
|
...cont,
|
|
cid: cid,
|
|
_shortId: (cont?.Id || '').substring(0, 12),
|
|
Networks: this.parseNetworkLinksForContainer(network_list, cont?.NetworkSettings?.Networks || {}, true),
|
|
Created: this.buildTimeString(cont?.Created) || '',
|
|
Ports: (Array.isArray(cont.Ports) && cont.Ports.length > 0)
|
|
? cont.Ports.map(p => {
|
|
const ip = p.IP || '';
|
|
const pub = p.PublicPort || '';
|
|
const priv = p.PrivatePort || '';
|
|
const type = p.Type || '';
|
|
return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
|
|
}).join('<br/>')
|
|
: '',
|
|
});
|
|
}
|
|
|
|
return data;
|
|
},
|
|
|
|
});
|