From e69c1fa0657fdd658e501586c22718f4e375fbd1 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 08:05:22 +0000 Subject: [PATCH] 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 --- backend/agent_app.py | 2 +- backend/main.py | 2 +- frontend/package.json | 2 +- .../src/components/stacks/ContainerPorts.tsx | 69 +++++++++++++++++++ frontend/src/pages/RemoteStackDetail.tsx | 20 +++++- frontend/src/pages/StackDetail.tsx | 10 +-- 6 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/stacks/ContainerPorts.tsx diff --git a/backend/agent_app.py b/backend/agent_app.py index 93a9822..7f42f34 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -40,7 +40,7 @@ from services import backup_service, compose_service logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.11.0" +AGENT_VERSION = "0.11.1" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 38b748a..f696b37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -54,7 +54,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.11.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.11.1", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/frontend/package.json b/frontend/package.json index 149f4c1..77cb0b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.11.0", + "version": "0.11.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/ContainerPorts.tsx b/frontend/src/components/stacks/ContainerPorts.tsx new file mode 100644 index 0000000..c0eee5c --- /dev/null +++ b/frontend/src/components/stacks/ContainerPorts.tsx @@ -0,0 +1,69 @@ +import { ExternalLink } from "lucide-react"; + +export type ContainerPort = { + container: string; + host_ip?: string; + host_port?: string | null; +}; + +const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]); + +/** Pick the host to link to: an explicit override (remote agent), the bound + * host IP if it's a concrete address, otherwise the host we're viewing from. */ +function linkHost(hostIp: string | undefined, override?: string): string { + if (override) return override; + if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp; + return window.location.hostname; +} + +/** Best-effort scheme guess — most homelab services are http; common TLS + * ports get https. */ +function scheme(hostPort: string, container: string): "http" | "https" { + if (hostPort === "443" || hostPort === "8443") return "https"; + if (container.startsWith("443/")) return "https"; + return "http"; +} + +/** Clickable chips for a container's published ports. `host` overrides the + * link target (used for remote agent stacks). Renders nothing if unpublished. */ +export function ContainerPorts({ + ports, + host, +}: { + ports: ContainerPort[]; + host?: string; +}) { + const seen = new Set(); + const published = ports.filter((p) => { + if (!p.host_port || seen.has(p.host_port)) return false; + seen.add(p.host_port); + return true; + }); + if (published.length === 0) return null; + + return ( + + {published.map((p) => { + const url = `${scheme(p.host_port!, p.container)}://${linkHost( + p.host_ip, + host + )}:${p.host_port}`; + return ( + e.stopPropagation()} + title={`Open ${url}`} + className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-accent hover:underline dark:bg-slate-700 dark:text-accent-dark" + > + {p.host_port} + →{p.container} + + + ); + })} + + ); +} diff --git a/frontend/src/pages/RemoteStackDetail.tsx b/frontend/src/pages/RemoteStackDetail.tsx index 8436205..3f66752 100644 --- a/frontend/src/pages/RemoteStackDetail.tsx +++ b/frontend/src/pages/RemoteStackDetail.tsx @@ -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() {
- {tab === "Overview" && } + {tab === "Overview" && } {tab === "Logs" && ( @@ -147,7 +160,7 @@ export function RemoteStackDetail() { ); } -function Overview({ containers }: { containers: any[] }) { +function Overview({ containers, host }: { containers: any[]; host?: string }) { return (
{containers.length === 0 && ( @@ -167,6 +180,7 @@ function Overview({ containers }: { containers: any[] }) {
{c.health && {c.health}} {c.status} +
))} diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index c85d102..bc5bcae 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -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 & any }) {
{c.health && {c.health}} {c.status} - {c.ports.length > 0 && ( - - {c.ports - .filter((p: any) => p.host_port) - .map((p: any) => `${p.host_port}→${p.container}`) - .join(", ")} - - )} +
))}