mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource } from './anatomy';
|
||||
import { assembleAnatomyInput, parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort } from './anatomy';
|
||||
|
||||
const COMPOSE = `services:
|
||||
plex:
|
||||
@@ -22,8 +22,8 @@ describe('parseAnatomy', () => {
|
||||
const a = parseAnatomy(COMPOSE)!;
|
||||
expect(a.services).toEqual(['plex']);
|
||||
expect(a.ports.plex).toEqual([
|
||||
{ host: '32400', container: '32400', proto: 'tcp' },
|
||||
{ host: '1900', container: '1900', proto: 'udp' },
|
||||
{ host: '32400', container: '32400', proto: 'tcp', published: true },
|
||||
{ host: '1900', container: '1900', proto: 'udp', published: true },
|
||||
]);
|
||||
expect(a.volumes.plex).toEqual([{ host: './config', container: '/config' }]);
|
||||
expect(a.restart).toBe('unless-stopped');
|
||||
@@ -39,14 +39,14 @@ describe('parseAnatomy', () => {
|
||||
|
||||
it('parses the 3-part bind-IP port form, picking host and container', () => {
|
||||
const a = parseAnatomy('services:\n web:\n image: x\n ports:\n - "127.0.0.1:8080:80"\n')!;
|
||||
expect(a.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp' }]);
|
||||
expect(a.ports.web).toEqual([{ host: '8080', container: '80', proto: 'tcp', published: true }]);
|
||||
});
|
||||
|
||||
it('parses long-syntax object ports and volumes', () => {
|
||||
const a = parseAnatomy(
|
||||
'services:\n app:\n image: x\n ports:\n - target: 80\n published: 8080\n protocol: udp\n volumes:\n - type: bind\n source: ./data\n target: /data\n',
|
||||
)!;
|
||||
expect(a.ports.app).toEqual([{ host: '8080', container: '80', proto: 'udp' }]);
|
||||
expect(a.ports.app).toEqual([{ host: '8080', container: '80', proto: 'udp', published: true }]);
|
||||
expect(a.volumes.app).toEqual([{ host: './data', container: '/data' }]);
|
||||
});
|
||||
|
||||
@@ -55,6 +55,56 @@ describe('parseAnatomy', () => {
|
||||
expect(a.referencedVars).toContain('NEEDED');
|
||||
expect(a.referencedVars).not.toContain('HAS');
|
||||
});
|
||||
|
||||
it('marks container-only short-form ports as not published', () => {
|
||||
const a = parseAnatomy('services:\n web:\n image: x\n ports:\n - "80"\n')!;
|
||||
expect(a.ports.web).toEqual([{ host: '80', container: '80', proto: 'tcp', published: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('primaryPublishedHostPort', () => {
|
||||
const portsFrom = (ports: string) =>
|
||||
parseAnatomy(`services:\n web:\n image: x\n ports:\n${ports}`)!.ports;
|
||||
|
||||
it('returns the first published TCP host port as a number', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "8443:443"\n'))).toBe(8443);
|
||||
});
|
||||
|
||||
it('picks the host port from the 3-part bind-IP form', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "127.0.0.1:8080:80"\n'))).toBe(8080);
|
||||
});
|
||||
|
||||
it('returns null for a container-only short-form port', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "80"\n'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unresolved variable port', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "${PORT}:80"\n'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a port range', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "8000-8002:8000-8002"\n'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range host port', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "70000:80"\n'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a UDP published port (short and long form)', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "5353:53/udp"\n'))).toBeNull();
|
||||
const longUdp = parseAnatomy(
|
||||
'services:\n web:\n image: x\n ports:\n - target: 53\n published: 5353\n protocol: udp\n',
|
||||
)!.ports;
|
||||
expect(primaryPublishedHostPort(longUdp)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips a non-published row and returns the next published TCP port', () => {
|
||||
expect(primaryPublishedHostPort(portsFrom(' - "80"\n - "8443:443"\n'))).toBe(8443);
|
||||
});
|
||||
|
||||
it('returns null when there are no ports', () => {
|
||||
expect(primaryPublishedHostPort({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnvKeys', () => {
|
||||
|
||||
@@ -38,16 +38,16 @@ function parsePortMapping(raw: unknown): PortRow | null {
|
||||
const proto = protoMatch ? protoMatch[1].toLowerCase() : 'tcp';
|
||||
const body = proto ? s.replace(/\/(tcp|udp)$/i, '') : s;
|
||||
const parts = body.split(':');
|
||||
if (parts.length === 2) return { host: parts[0], container: parts[1], proto };
|
||||
if (parts.length === 3) return { host: parts[1], container: parts[2], proto };
|
||||
return { host: body, container: body, proto };
|
||||
if (parts.length === 2) return { host: parts[0], container: parts[1], proto, published: true };
|
||||
if (parts.length === 3) return { host: parts[1], container: parts[2], proto, published: true };
|
||||
return { host: body, container: body, proto, published: false };
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const host = obj.published !== undefined ? String(obj.published) : '';
|
||||
const container = obj.target !== undefined ? String(obj.target) : '';
|
||||
const proto = obj.protocol ? String(obj.protocol) : 'tcp';
|
||||
if (host && container) return { host, container, proto };
|
||||
if (host && container) return { host, container, proto, published: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -154,6 +154,24 @@ export function parseAnatomy(yamlText: string): Anatomy | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The first genuinely host-published TCP port across all services, as a number,
|
||||
* or null when none qualifies. Used to turn the anatomy footer into a real link.
|
||||
* Skips container-only short syntax, UDP, ranges, `${VAR}`, and out-of-range
|
||||
* values: none of those yield a browser-openable host:port.
|
||||
*/
|
||||
export function primaryPublishedHostPort(ports: Record<string, PortRow[]>): number | null {
|
||||
for (const rows of Object.values(ports)) {
|
||||
for (const r of rows) {
|
||||
if (!r.published || r.proto !== 'tcp') continue;
|
||||
if (!/^\d+$/.test(r.host)) continue;
|
||||
const port = Number(r.host);
|
||||
if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseEnvKeys(envText: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const raw of envText.split(/\r?\n/)) {
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface PortRow {
|
||||
host: string;
|
||||
container: string;
|
||||
proto: string;
|
||||
/**
|
||||
* True when the mapping publishes a host port (short `host:container` form or
|
||||
* long syntax with `published:`/`target:`), false for container-only short
|
||||
* syntax (a single value with no host port).
|
||||
*/
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
export interface VolumeRow {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildServiceUrl, openServiceUrl } from './serviceUrl';
|
||||
|
||||
describe('buildServiceUrl', () => {
|
||||
it('uses the browser host for a local node', () => {
|
||||
expect(
|
||||
buildServiceUrl({ node: { type: 'local' }, publicPort: 8080, browserHost: 'box.lan' }),
|
||||
).toBe('http://box.lan:8080');
|
||||
});
|
||||
|
||||
it('uses the browser host when no node is given', () => {
|
||||
expect(buildServiceUrl({ publicPort: 3000, browserHost: 'localhost' })).toBe(
|
||||
'http://localhost:3000',
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the host from a remote node api_url', () => {
|
||||
expect(
|
||||
buildServiceUrl({
|
||||
node: { type: 'remote', api_url: 'http://192.168.1.50:1852' },
|
||||
publicPort: 8989,
|
||||
browserHost: 'box.lan',
|
||||
}),
|
||||
).toBe('http://192.168.1.50:8989');
|
||||
});
|
||||
|
||||
it('prefers an explicit public host over the node host', () => {
|
||||
expect(
|
||||
buildServiceUrl({
|
||||
node: { type: 'remote', api_url: 'http://192.168.1.50:1852' },
|
||||
publicPort: 8080,
|
||||
publicHost: 'apps.example.com',
|
||||
}),
|
||||
).toBe('http://apps.example.com:8080');
|
||||
});
|
||||
|
||||
it('normalizes a public host given as a full URL', () => {
|
||||
expect(
|
||||
buildServiceUrl({ publicPort: 8080, publicHost: 'https://apps.example.com:9999' }),
|
||||
).toBe('http://apps.example.com:8080');
|
||||
});
|
||||
|
||||
it('defaults to http', () => {
|
||||
expect(buildServiceUrl({ publicPort: 8080, browserHost: 'host' })).toBe('http://host:8080');
|
||||
});
|
||||
|
||||
it('uses https when the published port is 443', () => {
|
||||
expect(buildServiceUrl({ publicPort: 443, browserHost: 'host' })).toBe('https://host:443');
|
||||
});
|
||||
|
||||
it('uses https when the container port is 443 but the host port differs', () => {
|
||||
expect(
|
||||
buildServiceUrl({ publicPort: 8443, privatePort: 443, browserHost: 'host' }),
|
||||
).toBe('https://host:8443');
|
||||
});
|
||||
|
||||
it('returns null for a remote node with no api_url (pilot agent)', () => {
|
||||
expect(
|
||||
buildServiceUrl({ node: { type: 'remote', api_url: '' }, publicPort: 8080, browserHost: 'host' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a remote node with a malformed api_url', () => {
|
||||
expect(
|
||||
buildServiceUrl({ node: { type: 'remote', api_url: 'not a url' }, publicPort: 8080, browserHost: 'host' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range port', () => {
|
||||
expect(buildServiceUrl({ publicPort: 0, browserHost: 'host' })).toBeNull();
|
||||
expect(buildServiceUrl({ publicPort: 65536, browserHost: 'host' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an IPv6 api_url host bracketed', () => {
|
||||
expect(
|
||||
buildServiceUrl({ node: { type: 'remote', api_url: 'http://[::1]:1852' }, publicPort: 8080 }),
|
||||
).toBe('http://[::1]:8080');
|
||||
});
|
||||
|
||||
it('appends a known service path keyed by the container port', () => {
|
||||
expect(
|
||||
buildServiceUrl({ publicPort: 12345, privatePort: 32400, browserHost: 'host' }),
|
||||
).toBe('http://host:12345/web');
|
||||
});
|
||||
|
||||
it('appends a known service path keyed by the published port', () => {
|
||||
expect(buildServiceUrl({ publicPort: 32400, browserHost: 'host' })).toBe(
|
||||
'http://host:32400/web',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends no path for a port not in the registry', () => {
|
||||
expect(buildServiceUrl({ publicPort: 8080, privatePort: 80, browserHost: 'host' })).toBe(
|
||||
'http://host:8080',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not borrow a path from the published port when the container port is known', () => {
|
||||
// 32400 is registered as Plex's container port; a non-Plex service whose
|
||||
// container port (80) happens to be published on host port 32400 must not
|
||||
// inherit /web.
|
||||
expect(buildServiceUrl({ publicPort: 32400, privatePort: 80, browserHost: 'host' })).toBe(
|
||||
'http://host:32400',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openServiceUrl', () => {
|
||||
it('clicks a transient anchor with safe new-tab attributes', () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
expect(this.target).toBe('_blank');
|
||||
expect(this.rel).toBe('noopener noreferrer');
|
||||
expect(this.href).toBe('http://host:8080/');
|
||||
});
|
||||
openServiceUrl('http://host:8080');
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
expect(document.querySelector('a')).toBeNull();
|
||||
click.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Build and open the browser URL for a container's published service port.
|
||||
*
|
||||
* Centralizes the host, protocol, and path logic that the container card, the
|
||||
* stack "Open App" menu, and the stack anatomy footer all need, so a published
|
||||
* port renders as a real, reliable link instead of an ad hoc
|
||||
* `window.open('http://host:port')`.
|
||||
*/
|
||||
|
||||
// Some multi-port apps serve their UI at a sub-path, so a bare host:port does
|
||||
// not land on the working page. Keyed by the app's container (private) port.
|
||||
const KNOWN_SERVICE_PATHS: Record<number, string> = {
|
||||
32400: '/web', // Plex
|
||||
};
|
||||
|
||||
function isValidPort(port: number): boolean {
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
||||
}
|
||||
|
||||
// Accepts a bare host or a full URL (a future configured public host may be
|
||||
// pasted either way) and returns just the hostname, or '' when unusable.
|
||||
function normalizeHost(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.includes('://')) {
|
||||
try {
|
||||
return new URL(trimmed).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export interface ServiceUrlOptions {
|
||||
/** Active node; a remote node resolves to its own host, never the browser host. */
|
||||
node?: { type?: 'local' | 'remote'; api_url?: string } | null;
|
||||
/** Published host port (the browser-reachable one). */
|
||||
publicPort: number;
|
||||
/** Container port, used for protocol/path inference only. */
|
||||
privatePort?: number;
|
||||
/** Configured public/service host that overrides the node host. Reserved for future use. */
|
||||
publicHost?: string | null;
|
||||
/** Browser hostname; defaults to window.location.hostname. Injectable for tests. */
|
||||
browserHost?: string;
|
||||
/** Explicit protocol override. Reserved for future use. */
|
||||
protocol?: 'http' | 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the browser URL for the published port, or null when no
|
||||
* browser-reachable host can be resolved (e.g. a remote node with no API URL,
|
||||
* such as a pilot-agent node) or the port is out of range. Callers render a
|
||||
* link only when this is non-null.
|
||||
*/
|
||||
export function buildServiceUrl(opts: ServiceUrlOptions): string | null {
|
||||
const { node, publicPort, privatePort, publicHost, browserHost, protocol } = opts;
|
||||
if (!isValidPort(publicPort)) return null;
|
||||
|
||||
const host = resolveHost(node, publicHost, browserHost);
|
||||
if (!host) return null;
|
||||
|
||||
const scheme = protocol ?? (publicPort === 443 || privatePort === 443 ? 'https' : 'http');
|
||||
// When the container port is known, only it decides the app path. The
|
||||
// published-port lookup is a fallback for callers that do not know it (the
|
||||
// "Open App" menu and anatomy footer), where the host port is the best signal.
|
||||
const path =
|
||||
privatePort !== undefined
|
||||
? (KNOWN_SERVICE_PATHS[privatePort] ?? '')
|
||||
: (KNOWN_SERVICE_PATHS[publicPort] ?? '');
|
||||
return `${scheme}://${host}:${publicPort}${path}`;
|
||||
}
|
||||
|
||||
function resolveHost(
|
||||
node: ServiceUrlOptions['node'],
|
||||
publicHost: string | null | undefined,
|
||||
browserHost: string | undefined,
|
||||
): string | null {
|
||||
if (publicHost) {
|
||||
const normalized = normalizeHost(publicHost);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
if (node?.type === 'remote') {
|
||||
// A remote node must resolve to its own reachable host. Never fall back to
|
||||
// the browser host: that points at the control instance, not the remote.
|
||||
if (node.api_url) {
|
||||
try {
|
||||
return new URL(node.api_url).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Local or no node: the browser is talking to the instance that runs the
|
||||
// stacks, so its hostname is the right target.
|
||||
if (browserHost) return browserHost;
|
||||
return typeof window !== 'undefined' ? window.location.hostname : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in a new tab via a transient anchor click. More reliable than
|
||||
* window.open on mobile browsers, which may block programmatic popups.
|
||||
*/
|
||||
export function openServiceUrl(url: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
Reference in New Issue
Block a user