diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 4f3b1b69..00494539 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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(); + }); +}); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 9b10a511..9b69285a 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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, diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 34433733..9901ea22 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -55,7 +55,7 @@ Below the action bar, the **CONTAINERS** section lists every container the stack | **Name** | The Docker container name; falls back to the first 12 characters of the container ID. | | **Uptime / state** | `up 2h 15m` for running containers, the raw state for everything else. | | **Healthcheck label** | `healthcheck passing`, `healthcheck failing`, or `healthcheck starting`, only when a healthcheck is defined. | -| **Port mapping** | The first detected web-UI port, formatted `host → container/proto`, with an inline **open** link that launches `http://:` in a new tab. | +| **Port mapping** | The first detected web-UI port, formatted `host → container/proto`. The mapping itself is a link that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. | | **Action buttons** | **View logs**, **Open bash shell** (admin only), **Service actions**. | | **Live stats** | CPU, memory, and net I/O with rolling sparklines. Only rendered while the container is running. Stats refresh on the same 1500 ms cadence as the dashboard. | diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 55ded9ce..51d15dd6 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -209,7 +209,7 @@ Each row includes: - **Health badge.** A colored glyph reports the Docker healthcheck state: `✓` green for passing, `✗` red for failing, `…` amber while the healthcheck start period is in flight. Containers without a `healthcheck:` block show a neutral `✓`. - **Container name** in mono. - **Meta line.** Uptime (`up 12 hours`) and the primary port mapping (`8989 → 8989/tcp`). -- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname. +- **Open link.** When the container publishes a port, the mapping itself is a link (`8989 → 8989/tcp ↗`) that opens the service in a new tab, with a **Copy URL** button beside it. The address uses the active node's host and switches to `https` for port 443. Recognised multi-port apps open their web path automatically (for example, Plex opens `/web`). - **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds. - **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the per-service kebab. @@ -398,3 +398,7 @@ Click the **Scan stacks folder** icon button to the right of **Create Stack**. S ### Service action returns a "service not found" error The service name used in the action must match the `services:` key in the stack's `compose.yaml`. This error occurs when no running containers match that service name, either because the service was never deployed or because the compose file defines a different name. Verify the service key in your compose file and ensure the stack has been deployed at least once so containers exist for that service. + +### A published port link opens but the service does not load + +The link targets the active node's host and the published host port. It cannot reach a service bound only to `127.0.0.1` on the node, or one published on a port your browser cannot route to. For a remote node, the link uses that node's host; if the service lives at a different address, open it there directly. A port that is not published to a host port has no link. diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 95cccff0..5f02f687 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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; diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx new file mode 100644 index 00000000..ab127ef1 --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -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( + , + ); +} + +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(); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index 45d71e73..56c6a47d 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -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(null); + const copiedUrlTimerRef = useRef(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 (
{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 ? ( <> · - {mainPort} → {mainPortPrivate}/{mainPortProto} - + {serviceUrl ? ( + <> + + {portLabel} + + + + ) : ( + {portLabel} + )} ) : null}
diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts new file mode 100644 index 00000000..98185497 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -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) { + 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[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); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 1d4e179b..8b8d4034 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -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; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index b99545d2..43966271 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -55,6 +55,7 @@ function makeStackListState(over: Partial = {}): 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; getLastDeployOutputLine?: (stackName: string) => string | undefined; hasUpdateGuard?: boolean; + activeNode?: Parameters[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[0]['activeNode'], + activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters[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[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[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[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); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 1848fe55..b7c667f0 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -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 = () => { diff --git a/frontend/src/components/StackAnatomyPanel.test.tsx b/frontend/src/components/StackAnatomyPanel.test.tsx index 6e5d8d97..ccb42051 100644 --- a/frontend/src/components/StackAnatomyPanel.test.tsx +++ b/frontend/src/components/StackAnatomyPanel.test.tsx @@ -12,8 +12,10 @@ vi.mock('./stack/StackActivityTimeline', () => ({ StackActivityTimeline: () =>
, })); // 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( + , + ); + } + + 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(); + }); +}); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 91e7cf1c..6045e8d2 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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({ {Object.keys(anatomy.ports).length > 0 ? 'exposed' : 'no ports'} - {primaryHostPort && ( - - - :{primaryHostPort} - + {primaryHostPort !== null && ( + primaryServiceUrl ? ( + + + :{primaryHostPort} + + ) : ( + + + :{primaryHostPort} + + ) )}
)} diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index 8420d2c3..4f98e269 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -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; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 7f23687b..84adc2eb 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -7,7 +7,7 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; function makeCtx(overrides: Partial = {}): 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 }))); diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 6dc7b39f..0d6d4446 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -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, diff --git a/frontend/src/lib/anatomy.test.ts b/frontend/src/lib/anatomy.test.ts index 5ce41814..a3a5a069 100644 --- a/frontend/src/lib/anatomy.test.ts +++ b/frontend/src/lib/anatomy.test.ts @@ -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', () => { diff --git a/frontend/src/lib/anatomy.ts b/frontend/src/lib/anatomy.ts index 811b79ea..f2b01b53 100644 --- a/frontend/src/lib/anatomy.ts +++ b/frontend/src/lib/anatomy.ts @@ -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; 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): 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 { const keys = new Set(); for (const raw of envText.split(/\r?\n/)) { diff --git a/frontend/src/lib/anatomyMarkdown.ts b/frontend/src/lib/anatomyMarkdown.ts index b1465d9a..5fab87de 100644 --- a/frontend/src/lib/anatomyMarkdown.ts +++ b/frontend/src/lib/anatomyMarkdown.ts @@ -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 { diff --git a/frontend/src/lib/serviceUrl.test.ts b/frontend/src/lib/serviceUrl.test.ts new file mode 100644 index 00000000..b3b7b8c4 --- /dev/null +++ b/frontend/src/lib/serviceUrl.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/serviceUrl.ts b/frontend/src/lib/serviceUrl.ts new file mode 100644 index 00000000..671eb3d0 --- /dev/null +++ b/frontend/src/lib/serviceUrl.ts @@ -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 = { + 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(); +}