Files
stackpilot/frontend/src/pages/RemoteStackDetail.tsx
T
menzeljandClaude Opus 4.8 e69c1fa065 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>
2026-06-08 08:05:22 +00:00

270 lines
8.7 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Power,
ArrowLeft,
Save,
} from "lucide-react";
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";
import { useAuthStore } from "@/store/auth";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
export function RemoteStackDetail() {
const { agentId = "", id = "" } = useParams();
const aid = Number(agentId);
const qc = useQueryClient();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["agent-stack", aid, id],
queryFn: () => agentsApi.stack(aid, id),
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}…`);
try {
await agentsApi.action(aid, id, action);
toast.success(`${label} ${id} ✓`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusy(false);
}
};
if (isLoading || !data) return <Spinner />;
return (
<div className="flex h-full flex-col space-y-4">
<Link
to="/stacks"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<ArrowLeft className="h-4 w-4" /> All stacks
</Link>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-slate-500">
on <span className="font-medium">{data.agent_name}</span>
<HostDot status="online" />
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => run("start", "Starting")} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => run("stop", "Stopping")} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => run("restart", "Restarting")} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => run("pull", "Pulling")} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => run("update", "Updating")} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => run("down", "Tearing down")} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<BackupButton stackId={id} agentId={aid} />
</div>
)}
</div>
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview containers={data.containers} host={agentHost} />}
{tab === "Logs" && (
<Card className="h-full overflow-hidden">
<LogViewer stackId={id} agentId={aid} />
</Card>
)}
{tab === "Environment" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="env"
value={data.env}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
{tab === "Compose" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="yaml"
value={data.yaml}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
</div>
</div>
);
}
function Overview({ containers, host }: { containers: any[]; host?: string }) {
return (
<div className="space-y-2 overflow-auto">
{containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">No containers running.</p>
</Card>
)}
{containers.map((c) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<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>
))}
</div>
);
}
function RemoteEditor({
agentId,
stackId,
field,
value,
canEdit,
queryKey,
}: {
agentId: number;
stackId: string;
field: "yaml" | "env";
value: string;
canEdit: boolean;
queryKey: unknown[];
}) {
const qc = useQueryClient();
const [text, setText] = useState(value);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) setText(value);
}, [value, editing]);
const save = async () => {
setSaving(true);
try {
const body = field === "yaml" ? { yaml: text } : { env: text };
await agentsApi.update_stack(agentId, stackId, body);
toast.success("Saved. Restart or update the stack to apply.");
setEditing(false);
qc.invalidateQueries({ queryKey });
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setSaving(false);
}
};
if (!editing) {
return (
<Card className="flex h-full flex-col overflow-hidden">
{canEdit && (
<div className="mb-2 flex justify-end">
<Button variant="outline" onClick={() => setEditing(true)}>
Edit
</Button>
</div>
)}
{value ? (
<pre className="flex-1 overflow-auto whitespace-pre-wrap font-mono text-xs">{value}</pre>
) : (
<p className="text-sm text-slate-500">
{field === "env" ? "No .env file for this stack." : "Empty compose file."}
</p>
)}
</Card>
);
}
return (
<Card className="flex h-full flex-col gap-2 overflow-hidden">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs outline-none focus:border-accent dark:border-slate-600 dark:bg-slate-900"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button onClick={save} loading={saving}>
<Save className="h-4 w-4" /> Save
</Button>
</div>
</Card>
);
}