fix: make published port links open reliably (#1359)

* fix: make published port links open reliably

Container published-port links now render as real anchors that open on
desktop and mobile, replacing ad hoc window.open calls. A shared service
URL builder centralizes host resolution (configured host, remote node
API host, or the browser host, with no browser fallback for unreachable
remote nodes), protocol selection (HTTPS for port 443), and known app
sub-paths (Plex opens its web path). The container port mapping itself is
the link, with a Copy URL action beside it. The stack Open App menu and
the anatomy panel footer use the same builder, and the menu only offers
Open App when a reachable URL can be built.

* fix: skip UDP ports and scope known-app paths to the container port

Two follow-ups to the published-port links:

- The known-app path (Plex web sub-path) was borrowed from the published
  host port even when the container port was known and unregistered, so a
  non-Plex service published on host port 32400 wrongly inherited it. The
  container-port lookup now wins when known; the published-port lookup stays
  a fallback for the menu and anatomy footer, which only have the host port.

- UDP ports could surface as HTTP links. The backend now carries the port
  protocol through both container-mapping paths and skips UDP when choosing
  the main web port (extracted as selectMainWebPort), and the container card
  filters UDP before selecting a port to link.
This commit is contained in:
Anso
2026-06-11 16:37:53 -04:00
committed by GitHub
parent e3944295f2
commit f253276303
21 changed files with 782 additions and 76 deletions
@@ -43,7 +43,7 @@ vi.mock('util', () => ({
promisify: () => vi.fn(),
}));
import DockerController from '../services/DockerController';
import DockerController, { selectMainWebPort } from '../services/DockerController';
import { CacheService } from '../services/CacheService';
beforeEach(() => {
@@ -1301,3 +1301,66 @@ describe('DockerController - getBulkStackStatuses uptime', () => {
expect(second['ca-stack'].runningSince).toBe(startedUnix);
});
});
describe('DockerController - getBulkStackStatuses mainPort', () => {
beforeEach(() => {
CacheService.getInstance().flush();
});
const withPorts = (project: string, ports: { PrivatePort: number; PublicPort: number; Type?: string }[]) => ({
Id: `${project}-c1`, Names: [`/${project}-c1`], State: 'running', Status: 'Up',
Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, Ports: ports,
});
it('does not set mainPort for a UDP-only published port', async () => {
mockDocker.listContainers.mockResolvedValue([withPorts('udp-stack', [{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])]);
mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });
const result = await DockerController.getInstance(1).getBulkStackStatuses(['udp-stack']);
expect(result['udp-stack'].status).toBe('running');
expect(result['udp-stack'].mainPort).toBeUndefined();
});
it('selects a TCP web port as mainPort', async () => {
mockDocker.listContainers.mockResolvedValue([withPorts('tcp-stack', [
{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
{ PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
])]);
mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });
const result = await DockerController.getInstance(1).getBulkStackStatuses(['tcp-stack']);
expect(result['tcp-stack'].mainPort).toBe(8080);
});
});
describe('selectMainWebPort', () => {
it('prefers a known web-UI container port', () => {
expect(selectMainWebPort([
{ PrivatePort: 5432, PublicPort: 5432, Type: 'tcp' },
{ PrivatePort: 8080, PublicPort: 18080, Type: 'tcp' },
])).toBe(18080);
});
it('skips UDP ports and picks the TCP one', () => {
expect(selectMainWebPort([
{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
{ PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
])).toBe(8080);
});
it('returns undefined for a UDP-only published port', () => {
expect(selectMainWebPort([{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])).toBeUndefined();
});
it('treats a missing protocol as TCP', () => {
expect(selectMainWebPort([{ PrivatePort: 80, PublicPort: 8080 }])).toBe(8080);
});
it('falls back to the first TCP port when none match the web-UI list', () => {
expect(selectMainWebPort([{ PrivatePort: 12000, PublicPort: 12000, Type: 'tcp' }])).toBe(12000);
});
it('returns undefined when there are no ports', () => {
expect(selectMainWebPort([])).toBeUndefined();
});
});
+28 -15
View File
@@ -33,6 +33,26 @@ const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
/** Ports that should never be treated as the main app port. */
const IGNORE_PORTS = [1900, 53, 22];
/**
* Pick the published host port most likely to be a web UI, in priority order,
* skipping UDP (not browser-openable) and deprioritizing system ports, falling
* back to the first TCP port when nothing better matches. Returns the chosen
* PublicPort, or undefined when no TCP port qualifies.
*/
export function selectMainWebPort(
ports: { PrivatePort?: number; PublicPort?: number; Type?: string }[],
): number | undefined {
const tcp = ports.filter(p => p.Type !== 'udp');
let match = tcp.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = tcp.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = tcp.find(p =>
(!p.PrivatePort || !IGNORE_PORTS.includes(p.PrivatePort)) &&
(!p.PublicPort || !IGNORE_PORTS.includes(p.PublicPort)),
);
const chosen = match || tcp[0];
return chosen?.PublicPort;
}
export interface BulkStackInfo {
status: 'running' | 'exited' | 'unknown';
mainPort?: number;
@@ -1142,17 +1162,10 @@ class DockerController {
// Detect main web port (first running container with a matchable port wins)
if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) {
const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[];
let match = ports.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = ports.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = ports.find(p =>
(!p.PrivatePort || !IGNORE_PORTS.includes(p.PrivatePort)) &&
(!p.PublicPort || !IGNORE_PORTS.includes(p.PublicPort))
const mainPort = selectMainWebPort(
container.Ports as { PrivatePort?: number; PublicPort?: number; Type?: string }[],
);
const chosen = match || ports[0];
if (chosen?.PublicPort) {
result[stackDir].mainPort = chosen.PublicPort;
}
if (mainPort) result[stackDir].mainPort = mainPort;
}
} else if (result[stackDir].status !== 'running') {
result[stackDir].status = 'exited';
@@ -1283,7 +1296,7 @@ class DockerController {
Service?: string;
State?: string;
Status?: string;
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[];
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number, Protocol?: string }[];
}
let containers: ComposeContainer[] = [];
@@ -1313,11 +1326,11 @@ class DockerController {
// Note: docker compose ps returns Name (singular), but frontend expects Names (array)
// Dockerode returns Names with leading slash, so we add it for compatibility
const mapped = containers.map((c) => {
let Ports: { PrivatePort: number, PublicPort: number }[] = [];
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Publishers && Array.isArray(c.Publishers)) {
Ports = c.Publishers
.filter(p => typeof p.PublishedPort === 'number' && p.PublishedPort > 0)
.map(p => ({ PrivatePort: (p.TargetPort || 0) as number, PublicPort: p.PublishedPort as number }));
.map(p => ({ PrivatePort: (p.TargetPort || 0) as number, PublicPort: p.PublishedPort as number, Type: p.Protocol?.toLowerCase() }));
}
return {
Id: c.ID || '',
@@ -1430,11 +1443,11 @@ class DockerController {
// 5. Map to the frontend interface
return fallbackContainers.map(c => {
let Ports: { PrivatePort: number, PublicPort: number }[] = [];
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Ports && Array.isArray(c.Ports)) {
Ports = c.Ports
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number }));
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined }));
}
return {
Id: c.Id,