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
@@ -9,6 +9,8 @@ import {
|
||||
Users as UsersIcon,
|
||||
ShieldCheck,
|
||||
Power,
|
||||
Server,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
@@ -18,9 +20,11 @@ import {
|
||||
type Webhook,
|
||||
type WebhookInput,
|
||||
} from "@/api/settings";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { User } from "@/types";
|
||||
import type { Agent, User } from "@/types";
|
||||
|
||||
export function Settings() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
@@ -40,12 +44,142 @@ export function Settings() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<GeneralSection />
|
||||
<HostsSection />
|
||||
<NotificationsSection />
|
||||
<UsersSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Remote hosts (agents) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function HostsSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["agents"] });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={<Server className="h-4 w-4" />}>Remote hosts</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
data?.map((a) => <HostRow key={a.id} agent={a} onChange={invalidate} />)
|
||||
)}
|
||||
{data?.length === 0 && !adding && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
No remote hosts. Deploy <code>stackpilot-agent</code> on another host and
|
||||
add it here to manage its stacks from this dashboard.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{adding ? (
|
||||
<AddHostForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus className="h-4 w-4" /> Add host
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRow({ agent, onChange }: { agent: Agent; onChange: () => void }) {
|
||||
const ping = useMutation({
|
||||
mutationFn: () => agentsApi.ping(agent.id),
|
||||
onSuccess: (r) => {
|
||||
toast[r.status === "online" ? "success" : "error"](`Host is ${r.status}`);
|
||||
onChange();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => agentsApi.remove(agent.id),
|
||||
onSuccess: () => { toast.success("Host removed"); onChange(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<HostDot status={agent.status} />
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
<span className="text-xs text-slate-400">{agent.status}</span>
|
||||
{agent.hostname && (
|
||||
<span className="font-mono text-xs text-slate-400">({agent.hostname})</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-all font-mono text-xs text-slate-500">{agent.url}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => ping.mutate()} loading={ping.isPending}>
|
||||
<RefreshCw className="h-4 w-4" /> Check
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => agentsApi.create({ name, url, token }),
|
||||
onSuccess: (a) => {
|
||||
toast[a.status === "online" ? "success" : "error"](
|
||||
a.status === "online" ? "Host added and reachable" : `Host added but ${a.status}`
|
||||
);
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="nas" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Agent URL</span>
|
||||
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.0.5:5010" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Shared token (AGENT_TOKEN)</span>
|
||||
<Input type="password" value={token} onChange={(e) => setToken(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => create.mutate()}
|
||||
loading={create.isPending}
|
||||
disabled={!name.trim() || !url.trim() || !token}
|
||||
>
|
||||
Add host
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* General */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user