Phase 5: multi-host agents (0.5.0)
- stackpilot-agent: slim token-guarded FastAPI (reuses compose_service) exposing stack CRUD/lifecycle/logs + system info; same image, different CMD. agent/ Dockerfile + compose + .env.example. - Central proxy: Agent model, agent_service (httpx ping/proxy + live status: online/offline/unauthorized + hostname/last_seen), routers/agents.py (CRUD + ping + proxied stacks/lifecycle/logs/system). - Frontend: Settings → Remote hosts (add/check/remove, connectivity dot); Stacks grouped by host; remote stack detail with lifecycle, live logs, compose/.env edit. Verified end-to-end: agent+main on a shared network — register (good/bad token), list/create/start/logs/delete remote stacks, offline detection (502). Remote backup destinations (SFTP/S3) deferred. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8d19b09abd
commit
59037f4287
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const color: Record<string, string> = {
|
||||
online: "bg-green-500",
|
||||
offline: "bg-red-500",
|
||||
unauthorized: "bg-amber-500",
|
||||
unknown: "bg-slate-400",
|
||||
};
|
||||
|
||||
export function HostDot({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block h-2.5 w-2.5 rounded-full", color[status] ?? color.unknown)}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Server } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import type { Agent } from "@/types";
|
||||
|
||||
export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const online = agent.status === "online";
|
||||
|
||||
const stacks = useQuery({
|
||||
queryKey: ["agent-stacks", agent.id],
|
||||
queryFn: () => agentsApi.stacks(agent.id),
|
||||
enabled: online,
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const run = async (action: string, label: string, id: string) => {
|
||||
setBusyId(id);
|
||||
const t = toast.loading(`${label} ${id} on ${agent.name}…`);
|
||||
try {
|
||||
await agentsApi.action(agent.id, id, action);
|
||||
toast.success(`${label} ${id} ✓`, { id: t });
|
||||
qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: t });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Server className="h-4 w-4" />
|
||||
{agent.name}
|
||||
<HostDot status={agent.status} />
|
||||
{agent.hostname && (
|
||||
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{!online ? (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
Host is {agent.status}. Check it under Settings → Remote hosts.
|
||||
</p>
|
||||
</Card>
|
||||
) : stacks.data && stacks.data.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stacks.data.map((s) => (
|
||||
<StackCard
|
||||
key={s.id}
|
||||
stack={s}
|
||||
isAdmin={isAdmin}
|
||||
busy={busyId === s.id}
|
||||
linkBase={`/hosts/${agent.id}/stacks`}
|
||||
showEdit={false}
|
||||
onStart={(id) => run("start", "Starting", id)}
|
||||
onStop={(id) => run("stop", "Stopping", id)}
|
||||
onRestart={(id) => run("restart", "Restarting", id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">No stacks on this host.</p>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ interface Props {
|
||||
onRestart: (id: string) => void;
|
||||
busy?: boolean;
|
||||
isAdmin?: boolean;
|
||||
linkBase?: string; // detail/edit route prefix, default "/stacks"
|
||||
showEdit?: boolean; // hide edit for remote stacks (no remote editor yet)
|
||||
}
|
||||
|
||||
export function StackCard({
|
||||
@@ -20,11 +22,13 @@ export function StackCard({
|
||||
onRestart,
|
||||
busy,
|
||||
isAdmin,
|
||||
linkBase = "/stacks",
|
||||
showEdit = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
||||
<div className="flex items-start justify-between">
|
||||
<Link to={`/stacks/${stack.id}`} className="min-w-0">
|
||||
<Link to={`${linkBase}/${stack.id}`} className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot status={stack.status} />
|
||||
<span className="truncate font-semibold hover:underline">
|
||||
@@ -59,13 +63,15 @@ export function StackCard({
|
||||
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
|
||||
<RotateCw className="h-4 w-4 text-sky-500" />
|
||||
</IconBtn>
|
||||
<Link
|
||||
to={`/stacks/${stack.id}/edit`}
|
||||
title="Edit"
|
||||
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-slate-500" />
|
||||
</Link>
|
||||
{showEdit && (
|
||||
<Link
|
||||
to={`${linkBase}/${stack.id}/edit`}
|
||||
title="Edit"
|
||||
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-slate-500" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user