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>
270 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|