Clickable container port links (0.11.1)

Published container ports on the stack Overview (local + remote) render as
clickable chips that open the service at the host's address + port in a new
tab. New ContainerPorts component: links to the bound host IP when concrete,
else the host you're viewing from; remote stacks link to the agent host
(derived from the agent URL). http by default, https for 443/8443.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 08:05:22 +00:00
co-authored by Claude Opus 4.8
parent 1931500c24
commit e69c1fa065
6 changed files with 91 additions and 14 deletions
+17 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
@@ -37,6 +38,18 @@ export function RemoteStackDetail() {
refetchInterval: 5000,
});
// Port links on a remote stack must target the agent host, not this host.
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(false) });
const agentHost = useMemo(() => {
const a = agents.data?.find((x) => x.id === aid);
if (!a) return undefined;
try {
return new URL(a.url).hostname;
} catch {
return undefined;
}
}, [agents.data, aid]);
const run = async (action: string, label: string) => {
setBusy(true);
const t = toast.loading(`${label} ${id}`);
@@ -116,7 +129,7 @@ export function RemoteStackDetail() {
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview containers={data.containers} />}
{tab === "Overview" && <Overview containers={data.containers} host={agentHost} />}
{tab === "Logs" && (
<Card className="h-full overflow-hidden">
<LogViewer stackId={id} agentId={aid} />
@@ -147,7 +160,7 @@ export function RemoteStackDetail() {
);
}
function Overview({ containers }: { containers: any[] }) {
function Overview({ containers, host }: { containers: any[]; host?: string }) {
return (
<div className="space-y-2 overflow-auto">
{containers.length === 0 && (
@@ -167,6 +180,7 @@ function Overview({ containers }: { containers: any[] }) {
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
<ContainerPorts ports={c.ports ?? []} host={host} />
</div>
</Card>
))}
+2 -8
View File
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client";
@@ -132,14 +133,7 @@ function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
{c.ports.length > 0 && (
<span className="font-mono">
{c.ports
.filter((p: any) => p.host_port)
.map((p: any) => `${p.host_port}${p.container}`)
.join(", ")}
</span>
)}
<ContainerPorts ports={c.ports} />
</div>
</Card>
))}