mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +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:
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user