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