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:
menzelj
2026-06-07 21:23:17 +00:00
co-authored by Claude Opus 4.8
parent 8d19b09abd
commit 59037f4287
21 changed files with 1380 additions and 39 deletions
+17
View File
@@ -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>
);
}
+14 -8
View File
@@ -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>