luci-app-dockerman: links for port bindings

- handling of hostIP in ports
- retain during container clone
- make link clickable
- provide router host if ip is general
- provide container host if specific

Signed-off-by: Serhii Ivanov <icegood1980@gmail.com>
This commit is contained in:
Paul Donald
2026-05-14 15:08:50 +03:00
parent 9ef18ff055
commit f0ffc1cc4b
3 changed files with 89 additions and 17 deletions
@@ -250,8 +250,12 @@ return dm2.dv.extend({
if (!portBindings || typeof portBindings !== 'object') return [];
const ports = [];
for (const [containerPort, bindings] of Object.entries(portBindings)) {
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
ports.push(`${bindings[0].HostPort}:${containerPort}`);
if (Array.isArray(bindings)) {
for (const b of bindings) {
if (!b?.HostPort) continue;
const ip = (b.HostIp && b.HostIp !== '0.0.0.0' && b.HostIp !== '::') ? b.HostIp + ':' : '';
ports.push(`${ip}${b.HostPort}:${containerPort}`);
}
}
}
return ports;
@@ -138,9 +138,12 @@ return dm2.dv.extend({
publish: (() => {
const ports = [];
for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings || {})) {
if (Array.isArray(bindings) && bindings.length > 0 && bindings[0]?.HostPort) {
const hostPort = bindings[0].HostPort;
ports.push(hostPort + ':' + containerPort);
if (Array.isArray(bindings) && bindings.length > 0) {
for (const b of bindings) {
if (!b?.HostPort) continue;
const ip = (b.HostIp && b.HostIp !== '0.0.0.0' && b.HostIp !== '::') ? b.HostIp : '';
ports.push((ip ? ip + ':' : '') + b.HostPort + ':' + containerPort);
}
}
}
return ports;
@@ -816,8 +819,32 @@ return dm2.dv.extend({
(Array.isArray(publish) ? publish : [publish])
.filter(p => p && typeof p === 'string' && p.trim().length > 0)
.map(p => {
const m = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
if (m) return [`${m[2]}/${m[3]}`, [{ HostPort: m[1] }]];
// hostIp:hostPort:cPort/proto (e.g. 192.168.1.100:8080:80/tcp)
const m = p.match(/^([^:]+):(\d+):(\d+)\/(tcp|udp)$/);
if (m) {
const hostIp = m[1];
const hostPort = m[2];
const cPort = m[3];
const proto = m[4];
return [`${cPort}/${proto}`, [{ HostIp: hostIp, HostPort: hostPort }]];
}
// [ipv6]:hostPort:cPort/proto (e.g. [::1]:8080:80/tcp)
const m6 = p.match(/^\[([^\]]+)\]:(\d+):(\d+)\/(tcp|udp)$/);
if (m6) {
const hostIp = m6[1];
const hostPort = m6[2];
const cPort = m6[3];
const proto = m6[4];
return [`${cPort}/${proto}`, [{ HostIp: hostIp, HostPort: hostPort }]];
}
// hostPort:cPort/proto (e.g. 8080:80/tcp)
const m2 = p.match(/^(\d+):(\d+)\/(tcp|udp)$/);
if (m2) {
const hostPort = m2[1];
const cPort = m2[2];
const proto = m2[3];
return [`${cPort}/${proto}`, [{ HostPort: hostPort }]];
}
return null;
}).filter(Boolean)
) : undefined,
@@ -309,6 +309,56 @@ return dm2.dv.extend({
}, E('div', btns));
},
buildPortLinks(ports) {
// cont.Ports[] from GET /containers/json — flat array.
// Published ports have {IP, PrivatePort, PublicPort, Type}.
// Exposed-only ports omit IP and PublicPort: {PrivatePort, Type}.
if (!Array.isArray(ports) || ports.length === 0) return '';
const LOCAL_IPS = new Set(['0.0.0.0', '::']);
// Sort: published (has PublicPort) before exposed-only, then by PrivatePort
const sorted = [...ports].sort((a, b) => {
const aHasPub = a.PublicPort ? 1 : 0;
const bHasPub = b.PublicPort ? 1 : 0;
if (aHasPub !== bHasPub) return bHasPub - aHasPub;
return (a.PrivatePort || 0) - (b.PrivatePort || 0);
});
const lines = sorted.map(p => {
const ip = p.IP || '';
const pub = p.PublicPort || '';
const priv = p.PrivatePort || '';
const type = p.Type || '';
const isIPv6 = ip.includes(':');
const isLocal = LOCAL_IPS.has(ip);
const displayIp = isIPv6 ? `[${ip}]` : ip;
let label;
if (pub && ip) label = `${displayIp}:${pub}->${priv}/${type}`;
else if (pub) label = `${pub}->${priv}/${type}`;
else label = `${priv}/${type}`;
// Clickable link for published TCP ports only
if (type === 'tcp' && pub) {
const host = isLocal ? window.location.hostname : displayIp;
return E('div', {}, [
E('a', {
href: `http://${host}:${pub}`,
target: '_blank',
rel: 'noopener noreferrer',
title: _('Open in browser'),
}, [label]),
]);
}
return E('div', {}, [label]);
});
return E('div', {}, lines);
},
handleSave: null,
handleSaveApply: null,
handleReset: null,
@@ -343,16 +393,7 @@ return dm2.dv.extend({
_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 `${pub ? pub + ':' : ''}${priv}/${type}`;
// return `${ip ? ip + ':' : ''}${pub} -> ${priv} (${type})`;
}).join('<br/>')
: '',
Ports: this.buildPortLinks(cont.Ports),
});
}