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:
co-authored by
Claude Opus 4.8
parent
1931500c24
commit
e69c1fa065
@@ -40,7 +40,7 @@ from services import backup_service, compose_service
|
|||||||
|
|
||||||
logger = logging.getLogger("stackpilot.agent")
|
logger = logging.getLogger("stackpilot.agent")
|
||||||
|
|
||||||
AGENT_VERSION = "0.11.0"
|
AGENT_VERSION = "0.11.1"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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 (
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
{published.map((p) => {
|
||||||
|
const url = `${scheme(p.host_port!, p.container)}://${linkHost(
|
||||||
|
p.host_ip,
|
||||||
|
host
|
||||||
|
)}:${p.host_port}`;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={p.host_port}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => 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}
|
||||||
|
<span className="text-slate-400">→{p.container}</span>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import { toast } from "sonner";
|
|||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
import { HostDot } from "@/components/hosts/HostDot";
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
|
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -37,6 +38,18 @@ export function RemoteStackDetail() {
|
|||||||
refetchInterval: 5000,
|
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) => {
|
const run = async (action: string, label: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const t = toast.loading(`${label} ${id}…`);
|
const t = toast.loading(`${label} ${id}…`);
|
||||||
@@ -116,7 +129,7 @@ export function RemoteStackDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{tab === "Overview" && <Overview containers={data.containers} />}
|
{tab === "Overview" && <Overview containers={data.containers} host={agentHost} />}
|
||||||
{tab === "Logs" && (
|
{tab === "Logs" && (
|
||||||
<Card className="h-full overflow-hidden">
|
<Card className="h-full overflow-hidden">
|
||||||
<LogViewer stackId={id} agentId={aid} />
|
<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 (
|
return (
|
||||||
<div className="space-y-2 overflow-auto">
|
<div className="space-y-2 overflow-auto">
|
||||||
{containers.length === 0 && (
|
{containers.length === 0 && (
|
||||||
@@ -167,6 +180,7 @@ function Overview({ containers }: { containers: any[] }) {
|
|||||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||||
{c.health && <Badge>{c.health}</Badge>}
|
{c.health && <Badge>{c.health}</Badge>}
|
||||||
<span>{c.status}</span>
|
<span>{c.status}</span>
|
||||||
|
<ContainerPorts ports={c.ports ?? []} host={host} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { toast } from "sonner";
|
|||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
|
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
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">
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||||
{c.health && <Badge>{c.health}</Badge>}
|
{c.health && <Badge>{c.health}</Badge>}
|
||||||
<span>{c.status}</span>
|
<span>{c.status}</span>
|
||||||
{c.ports.length > 0 && (
|
<ContainerPorts ports={c.ports} />
|
||||||
<span className="font-mono">
|
|
||||||
{c.ports
|
|
||||||
.filter((p: any) => p.host_port)
|
|
||||||
.map((p: any) => `${p.host_port}→${p.container}`)
|
|
||||||
.join(", ")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user