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
@@ -51,7 +51,7 @@ export interface ContainerInfo {
Service?: string;
State: string;
Status?: string;
Ports?: { PrivatePort: number; PublicPort: number }[];
Ports?: { PrivatePort: number; PublicPort: number; Type?: string }[];
healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none';
Image?: string;
ImageID?: string;
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('../../Terminal', () => ({ default: () => null }));
vi.mock('../../StructuredLogViewer', () => ({ default: () => null }));
vi.mock('../../ImageSourceMenu', () => ({ ImageSourceMenu: () => null }));
import { ContainersHealth } from '../editor-view-blocks';
import { copyToClipboard } from '@/lib/clipboard';
import type { ContainerInfo } from '../EditorView';
import type { Node } from '@/context/NodeContext';
const LOCAL_NODE = { id: 1, type: 'local' } as Node;
function container(ports: { PrivatePort: number; PublicPort: number; Type?: string }[]): ContainerInfo {
return {
Id: 'abc123def456',
Names: ['/web'],
State: 'running',
Status: 'Up 2 hours',
Image: 'nginx',
Ports: ports,
} as unknown as ContainerInfo;
}
function renderHealth(c: ContainerInfo, activeNode: Node | null = LOCAL_NODE) {
return render(
<ContainersHealth
safeContainers={[c]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={activeNode}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
/>,
);
}
describe('ContainersHealth published port link', () => {
beforeEach(() => {
vi.mocked(copyToClipboard).mockClear();
});
it('renders the port mapping as a real anchor with safe new-tab attributes', () => {
renderHealth(container([{ PrivatePort: 80, PublicPort: 8080 }]));
const link = screen.getByRole('link', { name: /8080/ });
expect(link).toHaveAttribute('href', 'http://localhost:8080');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('uses https when the container port is 443', () => {
renderHealth(container([{ PrivatePort: 443, PublicPort: 8443 }]));
expect(screen.getByRole('link', { name: /8443/ })).toHaveAttribute('href', 'https://localhost:8443');
});
it('appends the known service path for a recognised app, keyed by the container port', () => {
renderHealth(container([{ PrivatePort: 32400, PublicPort: 12345 }]));
expect(screen.getByRole('link', { name: /12345/ })).toHaveAttribute('href', 'http://localhost:12345/web');
});
it('copies the service URL from the row', async () => {
renderHealth(container([{ PrivatePort: 80, PublicPort: 8080 }]));
fireEvent.click(screen.getByRole('button', { name: 'Copy service URL' }));
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith('http://localhost:8080'));
});
it('does not render a link for a UDP-only published port', () => {
renderHealth(container([{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }]));
expect(screen.queryByRole('link', { name: /5353/ })).toBeNull();
expect(screen.queryByRole('button', { name: 'Copy service URL' })).toBeNull();
});
it('shows the port as plain text (no link) for a remote node with no reachable host', () => {
renderHealth(container([{ PrivatePort: 80, PublicPort: 8080 }]), { id: 2, type: 'remote', api_url: '' } as Node);
expect(screen.queryByRole('link', { name: /8080/ })).toBeNull();
expect(screen.getByText(/8080 → 80\/tcp/)).toBeInTheDocument();
});
});
@@ -17,6 +17,7 @@ import {
Copy,
CloudDownload,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
import { CardTitle } from '../ui/card';
import {
@@ -30,6 +31,7 @@ import { Sparkline } from '../ui/sparkline';
import { ImageSourceMenu } from '../ImageSourceMenu';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import { buildServiceUrl } from '@/lib/serviceUrl';
import ErrorBoundary from '../ErrorBoundary';
import TerminalComponent from '../Terminal';
import StructuredLogViewer from '../StructuredLogViewer';
@@ -329,6 +331,22 @@ export function ContainersHealth({
openBashModal,
serviceAction,
}: ContainersHealthProps) {
const [copiedUrlId, setCopiedUrlId] = useState<string | null>(null);
const copiedUrlTimerRef = useRef<number | null>(null);
useEffect(() => () => {
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
}, []);
const copyServiceUrl = useCallback((id: string | undefined, url: string) => {
void copyToClipboard(url).then(() => {
if (!id) return;
setCopiedUrlId(id);
if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current);
copiedUrlTimerRef.current = window.setTimeout(() => {
setCopiedUrlId(prev => (prev === id ? null : prev));
copiedUrlTimerRef.current = null;
}, 1500);
}).catch(() => { /* clipboard unavailable */ });
}, []);
return (
<div>
{containerStatsError && safeContainers.length > 0 && (
@@ -349,18 +367,27 @@ export function ContainersHealth({
let mainPort: number | undefined;
let mainPortPrivate: number | undefined;
let mainPortProto: string | undefined;
if (container.Ports && container.Ports.length > 0) {
// UDP ports are not browser-openable, so they never back a link.
const tcpPorts = (container.Ports ?? []).filter(p => p.Type !== 'udp');
if (tcpPorts.length > 0) {
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
const IGNORE_PORTS = [1900, 53, 22];
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
const chosen = match || container.Ports[0];
let match = tcpPorts.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = tcpPorts.find(p => WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = tcpPorts.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
const chosen = match || tcpPorts[0];
mainPort = chosen.PublicPort;
mainPortPrivate = chosen.PrivatePort;
mainPortProto = 'tcp';
}
const serviceUrl = mainPort && mainPortPrivate
? buildServiceUrl({ node: activeNode, publicPort: mainPort, privatePort: mainPortPrivate })
: null;
const portLabel = mainPort && mainPortPrivate
? `${mainPort}${mainPortPrivate}/${mainPortProto}`
: '';
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
const isActive = container.State === 'running' || container.State === 'paused';
const health = container.healthStatus;
@@ -392,19 +419,33 @@ export function ContainersHealth({
{mainPort && mainPortPrivate ? (
<>
<span>·</span>
<span>{mainPort} {mainPortPrivate}/{mainPortProto}</span>
<button
type="button"
onClick={() => {
const host = activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${mainPort}`, '_blank');
}}
className="inline-flex items-center gap-1 text-brand hover:underline"
>
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</button>
{serviceUrl ? (
<>
<a
href={serviceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-brand hover:underline"
>
{portLabel} <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</a>
<button
type="button"
aria-label={copiedUrlId === container?.Id ? 'Copied' : 'Copy service URL'}
title="Copy service URL"
onClick={() => copyServiceUrl(container?.Id, serviceUrl)}
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copiedUrlId === container?.Id ? (
<Check className="h-3 w-3" strokeWidth={2} />
) : (
<Copy className="h-3 w-3" strokeWidth={1.5} />
)}
</button>
</>
) : (
<span>{portLabel}</span>
)}
</>
) : null}
</div>
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useSidebarContextMenu } from './useSidebarContextMenu';
import type { Node } from '@/context/NodeContext';
// buildMenuCtx derives canOpenApp from the active node plus the stack's
// published port; only the fields it reads need to be real, the handler
// closures are never invoked here.
function makeOptions(activeNode: Node | null, stackPorts: Record<string, number | undefined>) {
const stackListState = {
stackStatuses: { 'web.yml': 'running' },
stackPorts,
isStackBusy: () => false,
isPinned: () => false,
labels: [],
stackLabelMap: {},
pin: vi.fn(),
unpin: vi.fn(),
refreshLabels: vi.fn(),
};
const stackActions = {
getStackMenuVisibility: () => ({ showDeploy: false, showStop: true, showRestart: true, showUpdate: false }),
checkUpdatesForStack: vi.fn(),
openStackApp: vi.fn(),
executeStackActionByFile: vi.fn(),
};
const overlayState = { openAlertSheet: vi.fn(), openAutoHeal: vi.fn(), openDeleteDialog: vi.fn() };
const navState = { handleOpenSettings: vi.fn(), setSchedulePrefill: vi.fn(), setActiveView: vi.fn() };
return {
stackListState,
navState,
overlayState,
stackActions,
activeNode,
isAdmin: true,
can: () => true,
} as unknown as Parameters<typeof useSidebarContextMenu>[0];
}
describe('useSidebarContextMenu canOpenApp', () => {
it('is true for a local node with a published port', () => {
const { result } = renderHook(() =>
useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, { 'web.yml': 8989 })));
expect(result.current('web.yml').canOpenApp).toBe(true);
});
it('is true for a remote node with an api_url host and a port', () => {
const { result } = renderHook(() =>
useSidebarContextMenu(makeOptions({ id: 4, type: 'remote', api_url: 'http://10.0.0.5:1852' } as Node, { 'web.yml': 8989 })));
expect(result.current('web.yml').canOpenApp).toBe(true);
});
it('is false for a remote pilot node (no api_url) even with a port', () => {
const { result } = renderHook(() =>
useSidebarContextMenu(makeOptions({ id: 2, type: 'remote', api_url: '' } as Node, { 'web.yml': 8989 })));
expect(result.current('web.yml').canOpenApp).toBe(false);
});
it('is false when the stack has no published port', () => {
const { result } = renderHook(() =>
useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {})));
expect(result.current('web.yml').canOpenApp).toBe(false);
});
});
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { buildServiceUrl } from '@/lib/serviceUrl';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import type { Label as StackLabel, LabelColor } from '../../label-types';
import type { OverlayState } from './useOverlayState';
@@ -34,9 +35,12 @@ export function useSidebarContextMenu({
}: UseSidebarContextMenuOptions) {
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
const sName = file.replace(/\.(yml|yaml)$/, '');
const mainPort = stackListState.stackPorts[file];
return {
stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown',
hasPort: Boolean(stackListState.stackPorts[file]),
// Only offer "Open App" when a browser-reachable URL can actually be built
// (a remote node with no API host, e.g. a pilot agent, yields none).
canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null,
isBusy: stackListState.isStackBusy(file),
isAdmin,
canDelete: can('stack:delete', 'stack', sName),
@@ -126,7 +130,7 @@ export function useSidebarContextMenu({
}, [
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
stackListState.pin, stackListState.unpin,
stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url,
]);
return buildMenuCtx;
@@ -55,6 +55,7 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
selectedFile: 'web.yml',
files: ['web.yml'],
stackStatuses: { 'web.yml': 'running' },
stackPorts: {},
setSelectedFile: vi.fn(),
setOptimisticStatus: vi.fn(),
setStackAction: vi.fn(),
@@ -102,6 +103,7 @@ function setup(over: {
stackList?: Partial<StackListState>;
getLastDeployOutputLine?: (stackName: string) => string | undefined;
hasUpdateGuard?: boolean;
activeNode?: Parameters<typeof useStackActions>[0]['activeNode'];
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState(over.stackList);
@@ -114,7 +116,7 @@ function setup(over: {
stackListState,
navState,
overlayState,
activeNode: { id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
@@ -689,3 +691,60 @@ describe('useStackActions recovery records', () => {
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
});
describe('useStackActions.openStackApp', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
function openAndCaptureHref(over: Parameters<typeof setup>[0]): {
href: string | undefined;
clickCount: number;
} {
let href: string | undefined;
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function (this: HTMLAnchorElement) {
href = this.href;
});
try {
const { result } = setup(over);
result.current.openStackApp('web.yml');
return { href, clickCount: click.mock.calls.length };
} finally {
click.mockRestore();
}
}
it('opens the published port on the browser host for a local node', () => {
const { href } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 8989 } } });
expect(href).toBe('http://localhost:8989/');
});
it('opens on the remote node host derived from its api_url', () => {
const { href } = openAndCaptureHref({
stackList: { stackPorts: { 'web.yml': 8989 } },
activeNode: { id: 2, type: 'remote', api_url: 'http://10.0.0.5:1852' } as Parameters<typeof useStackActions>[0]['activeNode'],
});
expect(href).toBe('http://10.0.0.5:8989/');
});
it('does nothing for a remote node with no api_url (pilot) and does not throw', () => {
const { href, clickCount } = openAndCaptureHref({
stackList: { stackPorts: { 'web.yml': 8989 } },
activeNode: { id: 3, type: 'remote', api_url: '' } as Parameters<typeof useStackActions>[0]['activeNode'],
});
expect(clickCount).toBe(0);
expect(href).toBeUndefined();
});
it('appends a known service path via the published port', () => {
const { href } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 32400 } } });
expect(href).toBe('http://localhost:32400/web');
});
it('does nothing when the stack has no published port', () => {
const { clickCount } = openAndCaptureHref({});
expect(clickCount).toBe(0);
});
});
@@ -1,6 +1,7 @@
import { useRef, useCallback, useEffect } from 'react';
import { apiFetch, withDeploySession } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
import type { useEditorViewState } from './useEditorViewState';
import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
@@ -236,11 +237,8 @@ export function useStackActions(options: UseStackActionsOptions) {
const openStackApp = (file: string) => {
const port = stackListState.stackPorts[file];
if (!port) return;
const host =
activeNode?.type === 'remote' && activeNode?.api_url
? new URL(activeNode.api_url).hostname
: window.location.hostname;
window.open(`http://${host}:${port}`, '_blank');
const url = buildServiceUrl({ node: activeNode, publicPort: port });
if (url) openServiceUrl(url);
};
const resetEditorState = () => {
@@ -12,8 +12,10 @@ vi.mock('./stack/StackActivityTimeline', () => ({
StackActivityTimeline: () => <div data-testid="activity-timeline" />,
}));
// This suite covers the update banner, not the Doctor tab; keep the capability
// off so the panel surface stays exactly what these tests assert against.
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) }));
// off so the panel surface stays exactly what these tests assert against. The
// active node is mutable so the footer-link tests can simulate a remote node.
const { nodeState } = vi.hoisted(() => ({ nodeState: { activeNode: { id: 1 } as unknown } }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: nodeState.activeNode, hasCapability: () => false }) }));
import { apiFetch } from '@/lib/api';
import StackAnatomyPanel from './StackAnatomyPanel';
@@ -46,6 +48,7 @@ const updatePreviewCalls = () =>
beforeEach(() => {
hasUpdate = true;
nodeState.activeNode = { id: 1 };
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -294,3 +297,43 @@ describe('StackAnatomyPanel update banner', () => {
expect(screen.queryByTestId('update-available-banner')).not.toBeInTheDocument();
});
});
describe('StackAnatomyPanel exposed footer', () => {
function renderWithPorts(content: string) {
return render(
<StackAnatomyPanel
stackName="web"
content={content}
envContent=""
selectedEnvFile=".env"
gitSourcePending={false}
onEditCompose={vi.fn()}
onOpenGitSource={vi.fn()}
onApplyUpdate={vi.fn()}
canEdit
applying={false}
/>,
);
}
it('renders the exposed port as a real link for a published port', async () => {
renderWithPorts('services:\n web:\n image: x\n ports:\n - "8989:8989"\n');
const link = await screen.findByRole('link', { name: /:8989/ });
expect(link).toHaveAttribute('href', 'http://localhost:8989');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('does not render a link for a container-only port', async () => {
renderWithPorts('services:\n web:\n image: x\n ports:\n - "80"\n');
await screen.findByText('exposed');
expect(screen.queryByRole('link', { name: /:\d+/ })).toBeNull();
});
it('shows the port as plain text (no link) on a remote node with no reachable host', async () => {
nodeState.activeNode = { id: 9, type: 'remote', api_url: '' };
renderWithPorts('services:\n web:\n image: x\n ports:\n - "8989:8989"\n');
expect(await screen.findByText(/:8989/)).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /:8989/ })).toBeNull();
});
});
+27 -14
View File
@@ -5,7 +5,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { type AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
import { parseAnatomy, parseEnvKeys, formatGitSource, type GitSourceInfo } from '@/lib/anatomy';
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
import { buildServiceUrl } from '@/lib/serviceUrl';
import { StackActivityTimeline } from './stack/StackActivityTimeline';
import StackDossierPanel from './stack/StackDossierPanel';
import DriftPanel from './stack/DriftPanel';
@@ -249,14 +250,14 @@ export default function StackAnatomyPanel({
// Only treat the fetched source as current when it belongs to the selected stack, so a
// slow /git-source response for a previously selected stack cannot render or be exported here.
const activeGitSource = gitSource?.stack === stackName ? gitSource.info : null;
const primaryHostPort = useMemo(() => {
if (!anatomy) return null;
for (const svc of anatomy.services) {
const rows = anatomy.ports[svc];
if (rows && rows.length > 0) return rows[0].host;
}
return null;
}, [anatomy]);
const primaryHostPort = useMemo(
() => (anatomy ? primaryPublishedHostPort(anatomy.ports) : null),
[anatomy],
);
const primaryServiceUrl = useMemo(
() => (primaryHostPort !== null ? buildServiceUrl({ node: activeNode, publicPort: primaryHostPort }) : null),
[primaryHostPort, activeNode],
);
// Assembled facts for this stack, passed to the Dossier tab for its read-only
// summary and Markdown export. Null until compose parses.
@@ -551,11 +552,23 @@ export default function StackAnatomyPanel({
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">
{Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'}
</span>
{primaryHostPort && (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
:{primaryHostPort}
</span>
{primaryHostPort !== null && (
primaryServiceUrl ? (
<a
href={primaryServiceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle hover:text-foreground"
>
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
:{primaryHostPort}
</a>
) : (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-stat-subtitle">
<ExternalLink className="h-3 w-3" strokeWidth={1.5} />
:{primaryHostPort}
</span>
)
)}
</div>
)}
@@ -23,7 +23,7 @@ export type StackLifecycleStatus = 'running' | 'exited' | 'unknown';
export interface StackMenuCtx {
stackStatus: StackLifecycleStatus;
hasPort: boolean;
canOpenApp: boolean;
isBusy: boolean;
isAdmin: boolean;
canDelete: boolean;
@@ -7,7 +7,7 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
return {
stackStatus: 'running',
hasPort: true,
canOpenApp: true,
isBusy: false,
isAdmin: true,
canDelete: true,
@@ -54,12 +54,24 @@ describe('useStackMenuItems', () => {
expect(inspect.items.find(i => i.id === 'auto-heal')).toBeDefined();
});
it('hides Open App unless running + hasPort', () => {
it('shows Open App when running and canOpenApp', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'open-app')).toBeDefined();
});
it('hides Open App when the stack is not running', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ stackStatus: 'exited' })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
});
it('hides Open App when no reachable URL can be built (canOpenApp false)', () => {
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canOpenApp: false })));
const inspect = result.current.find(g => g.id === 'inspect')!;
expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
});
it('toggles Pin / Unpin label based on isPinned', () => {
const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true })));
const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false })));
+3 -3
View File
@@ -18,7 +18,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, hasPort, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
stackStatus, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility, openScheduleTask,
@@ -33,7 +33,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
{ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal },
];
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
if (stackStatus === 'running' && hasPort) {
if (stackStatus === 'running' && canOpenApp) {
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
}
groups.push({ id: 'inspect', items: inspect });
@@ -78,7 +78,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
return groups;
}, [
stackStatus, hasPort, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
stackStatus, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
+55 -5
View File
@@ -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', () => {
+22 -4
View File
@@ -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/)) {
+6
View File
@@ -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 {
+122
View File
@@ -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();
});
});
+114
View File
@@ -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();
}