df41465d5f
Signed-off-by: sbwml <admin@cooluc.com>
309 lines
10 KiB
JavaScript
309 lines
10 KiB
JavaScript
'use strict';
|
|
'require form';
|
|
'require fs';
|
|
'require uci';
|
|
'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
|
|
*/
|
|
|
|
/**
|
|
* Returns a Set of image IDs in use by containers
|
|
* @param {Array} containers - Array of container objects
|
|
* @returns {Set<string>} Set of image IDs
|
|
*/
|
|
function getImagesInUseByContainers(containers) {
|
|
const inUse = new Set();
|
|
for (const c of containers || []) {
|
|
if (c.ImageID) inUse.add(c.ImageID);
|
|
else if (c.Image) inUse.add(c.Image);
|
|
}
|
|
return inUse;
|
|
}
|
|
|
|
/**
|
|
* Returns a Set of network IDs in use by containers
|
|
* @param {Array} containers - Array of container objects
|
|
* @returns {Set<string>} Set of network IDs
|
|
*/
|
|
function getNetworksInUseByContainers(containers) {
|
|
const inUse = new Set();
|
|
for (const c of containers || []) {
|
|
const networks = c.NetworkSettings?.Networks;
|
|
if (networks && typeof networks === 'object') {
|
|
for (const netName in networks) {
|
|
const net = networks[netName];
|
|
if (net.NetworkID) inUse.add(net.NetworkID);
|
|
else if (netName) inUse.add(netName);
|
|
}
|
|
}
|
|
}
|
|
return inUse;
|
|
}
|
|
|
|
/**
|
|
* Returns a Set of volume mountpoints in use by containers
|
|
* @param {Array} containers - Array of container objects
|
|
* @returns {Set<string>} Set of volume names or mountpoints
|
|
*/
|
|
function getVolumesInUseByContainers(containers) {
|
|
const inUse = new Set();
|
|
for (const c of containers || []) {
|
|
const mounts = c.Mounts;
|
|
if (Array.isArray(mounts)) {
|
|
for (const m of mounts) {
|
|
if (m.Type === 'volume' && m.Name) inUse.add(m.Name);
|
|
}
|
|
}
|
|
}
|
|
return inUse;
|
|
}
|
|
|
|
|
|
return dm2.dv.extend({
|
|
load() {
|
|
// const now = Math.floor(Date.now() / 1000);
|
|
|
|
return Promise.all([
|
|
dm2.docker_version().catch(e => ({ body: { message: e.message }, error: e })),
|
|
dm2.docker_info().catch(e => ({ code: 500, body: { message: e.message }, error: e })),
|
|
// dm2.docker_df(), // takes > 20 seconds on large docker environments
|
|
dm2.container_list().then(r => r.body || []).catch(e => []),
|
|
dm2.image_list().then(r => r.body || []).catch(e => []),
|
|
dm2.network_list().then(r => r.body || []).catch(e => []),
|
|
dm2.volume_list().then(r => r.body || []).catch(e => ({ Volumes: [] })),
|
|
dm2.callMountPoints().catch(e => []),
|
|
]);
|
|
},
|
|
|
|
handleAction(name, action, ev) {
|
|
return dm2.callRcInit(name, action).then(function(ret) {
|
|
if (ret)
|
|
throw _('Command failed');
|
|
|
|
return true;
|
|
}).catch(function(e) {
|
|
L.ui.addTimeLimitedNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)), 5000, 'warning');
|
|
});
|
|
},
|
|
|
|
render([version_response,
|
|
info_response,
|
|
// df_response,
|
|
container_list,
|
|
image_list,
|
|
network_list,
|
|
volume_list,
|
|
mounts,
|
|
]) {
|
|
const version_headers = [];
|
|
const version_body = [];
|
|
const info_body = [];
|
|
// const df_body = [];
|
|
const docker_ep = uci.get('dockerd', 'globals', 'hosts');
|
|
let isLocal = false;
|
|
if (!docker_ep || docker_ep.length === 0 || docker_ep.map(e => e.includes('.sock')).filter(Boolean).length == 1)
|
|
isLocal = true;
|
|
|
|
if (info_response?.code !== 200) {
|
|
const mainContainer = E('div', { 'class': 'cbi-map' });
|
|
mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
|
|
_('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
|
|
]));
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-section-node' }, [
|
|
E('div', { 'class': 'cbi-value' }, [
|
|
E('em', { 'class': 'spinning' }, _('Docker daemon is not running or not reachable.')),
|
|
E('br'),
|
|
E('em', {}, info_response?.body?.message)
|
|
])
|
|
]));
|
|
|
|
if (isLocal) {
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
|
|
E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
|
|
E('button', {
|
|
'class': 'btn cbi-button-action positive',
|
|
'click': () => this.handleAction('dockerd', 'start').then(() => {
|
|
L.ui.showModal(_('Starting daemon...'), [
|
|
E('p', { 'class': 'spinning' }, _('The page will be reloaded in 5 seconds.'))
|
|
]);
|
|
setTimeout(() => window.location.reload(), 5000);
|
|
})
|
|
}, _('Start', 'daemon start action')),
|
|
])
|
|
]));
|
|
}
|
|
return mainContainer;
|
|
}
|
|
|
|
this.parseHeaders(version_response.headers, version_headers);
|
|
this.parseBody(version_response.body, version_body);
|
|
this.parseBody(info_response.body, info_body);
|
|
// this.parseBody(df_response.body, df_body);
|
|
const view = this;
|
|
const info = info_response.body;
|
|
|
|
this.concount = info?.Containers || 0;
|
|
this.conactivecount = info?.ContainersRunning || 0;
|
|
|
|
/* Because the df function that reconciles Volumes, Networks and Containers
|
|
is slow on large and busy dockerd endpoints, we do it here manually. It's fast. */
|
|
this.imgcount = image_list.length;
|
|
this.imgactivecount = getImagesInUseByContainers(container_list)?.size || 0;
|
|
|
|
this.netcount = network_list.length;
|
|
this.netactivecount = getNetworksInUseByContainers(container_list)?.size || 0;
|
|
|
|
this.volcount = volume_list?.Volumes?.length;
|
|
this.volactivecount = getVolumesInUseByContainers(container_list)?.size || 0;
|
|
|
|
this.freespace = isLocal ? mounts.find(m => m.mount === info?.DockerRootDir)?.avail || 0 : 0;
|
|
if (isLocal && this.freespace !== 0)
|
|
this.freespace = '(' + '%1024.2m'.format(this.freespace) + ' ' + _('Available') + ')';
|
|
|
|
const mainContainer = E('div', { 'class': 'cbi-map' });
|
|
|
|
// Add heading and description first
|
|
mainContainer.appendChild(E('h2', { 'class': 'section-title' }, [_('Docker - Overview')]));
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-map-descr' }, [
|
|
_('An overview with the relevant data is displayed here with which the LuCI docker client is connected.'),
|
|
E('br'),
|
|
E('a', { href: 'https://github.com/openwrt/luci/blob/master/applications/luci-app-dockerman/README.md' }, ['README'])
|
|
]));
|
|
|
|
if (isLocal)
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
|
|
E('div', { 'style': 'display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;' }, [
|
|
E('button', { 'class': 'btn cbi-button-action neutral', 'click': () => this.handleAction('dockerd', 'restart') }, _('Restart', 'daemon restart action')),
|
|
E('button', { 'class': 'btn cbi-button-action negative', 'click': () => this.handleAction('dockerd', 'stop') }, _('Stop', 'daemon stop action')),
|
|
])
|
|
]));
|
|
|
|
// Create the info table
|
|
const summaryTable = new L.ui.Table(
|
|
[_('Info'), ''],
|
|
{ id: 'containers-table', style: 'width: 100%; table-layout: auto;' },
|
|
[]
|
|
);
|
|
|
|
summaryTable.update([
|
|
[ _('Docker Version'), version_response.body.Version ],
|
|
[ _('Api Version'), version_response.body.ApiVersion ],
|
|
[ _('CPUs'), info_response.body.NCPU ],
|
|
[ _('Total Memory'), '%1024.2m'.format(info_response.body.MemTotal) ],
|
|
[ _('Docker Root Dir'), `${info_response.body.DockerRootDir} ${ (isLocal && this.freespace) ? this.freespace : '' }` ],
|
|
[ _('Index Server Address'), info_response.body.IndexServerAddress ],
|
|
[ _('Registry Mirrors'), info_response.body.RegistryConfig?.Mirrors || '-' ],
|
|
]);
|
|
|
|
// Wrap the table in a cbi-section
|
|
mainContainer.appendChild(E('div', { 'class': 'cbi-section' }, [
|
|
summaryTable.render()
|
|
]));
|
|
|
|
|
|
// Create a container div with grid layout for the status badges
|
|
let statusContainer = E('div', { style: 'display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-bottom: 20px;' }, [
|
|
this.overviewBadge(`${this.dockerman_url}/containers`,
|
|
E('img', {
|
|
src: L.resource('dockerman/containers.svg'),
|
|
style: 'width: 80px; height: 80px;'
|
|
}, []),
|
|
_('Containers'),
|
|
_('Total: '),
|
|
view.concount,
|
|
_('Running: '),
|
|
view.conactivecount),
|
|
this.overviewBadge(`${this.dockerman_url}/images`,
|
|
E('img', {
|
|
src: L.resource('dockerman/images.svg'),
|
|
style: 'width: 80px; height: 80px;'
|
|
}, []),
|
|
_('Images'),
|
|
_('Total: '),
|
|
view.imgcount,
|
|
view.imgactivecount ? _('In Use: ') : '',
|
|
view.imgactivecount ? view.imgactivecount : ''),
|
|
this.overviewBadge(`${this.dockerman_url}/networks`,
|
|
E('img', {
|
|
src: L.resource('dockerman/networks.svg'),
|
|
style: 'width: 80px; height: 80px;'
|
|
}, []),
|
|
_('Networks'),
|
|
_('Total: '),
|
|
view.netcount,
|
|
view.netactivecount ? _('In Use: ') : '',
|
|
view.netactivecount ? view.netactivecount : ''),
|
|
this.overviewBadge(`${this.dockerman_url}/volumes`,
|
|
E('img', {
|
|
src: L.resource('dockerman/volumes.svg'),
|
|
style: 'width: 80px; height: 80px;'
|
|
}, []),
|
|
_('Volumes'),
|
|
_('Total: '),
|
|
view.volcount,
|
|
view.volactivecount ? _('In Use: ') : '',
|
|
view.volactivecount ? view.volactivecount : ''),
|
|
]);
|
|
|
|
// Add badges section
|
|
mainContainer.appendChild(statusContainer);
|
|
|
|
const m = new form.JSONMap({
|
|
// df: df_body,
|
|
vb: version_body,
|
|
ib: info_body
|
|
});
|
|
m.readonly = true;
|
|
m.tabbed = false;
|
|
|
|
let s;
|
|
|
|
// Add Version and Environment tables
|
|
s = m.section(form.TableSection, 'vb', _('Version'));
|
|
s.anonymous = true;
|
|
|
|
s.option(form.DummyValue, 'entry', _('Name'));
|
|
s.option(form.DummyValue, 'value', _('Value'));
|
|
|
|
s = m.section(form.TableSection, 'ib', _('Environment'));
|
|
s.anonymous = true;
|
|
s.filterrow = true;
|
|
|
|
s.option(form.DummyValue, 'entry', _('Entry'));
|
|
s.option(form.DummyValue, 'value', _('Value'));
|
|
|
|
// Render the form sections and append them
|
|
return m.render()
|
|
.then(fe => {
|
|
mainContainer.appendChild(fe);
|
|
return mainContainer;
|
|
});
|
|
},
|
|
|
|
overviewBadge(url, resource_div, caption, total_caption, total_count, active_caption, active_count) {
|
|
return E('a', { href: url, style: 'text-decoration: none; cursor: pointer;', title: _('Go to relevant configuration page') }, [
|
|
E('div', { style: 'border: 1px solid #ddd; border-radius: 5px; padding: 15px; min-height: 120px; display: flex; align-items: center;' }, [
|
|
E('div', { style: 'flex: 0 0 auto; margin-right: 15px;' }, [
|
|
resource_div,
|
|
]),
|
|
E('div', { style: 'flex: 1;' }, [
|
|
E('div', { style: 'font-size: 20px; font-weight: bold; color: #333; margin-bottom: 8px;' }, caption),
|
|
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
|
|
E('span', { style: 'color: #666; margin-right: 10px;' }, [total_caption, E('strong', { style: 'color: #0066cc;' }, total_count)])
|
|
]),
|
|
E('div', { style: 'font-size: 16px; margin: 4px 0;' }, [
|
|
E('span', { style: 'color: #666;' }, [active_caption, E('strong', { style: 'color: #28a745;' }, active_count)])
|
|
])
|
|
])
|
|
])
|
|
])
|
|
|
|
}
|
|
});
|