Files
luci/applications/luci-app-dockerman/htdocs/luci-static/resources/view/dockerman/containers.js
Paul Donald baa0f16bb3 luci-app-dockerman: convert to JS
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>
2026-02-04 06:45:51 +01:00

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;
},
});