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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -6,6 +6,7 @@ import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates";
import { Settings } from "@/pages/Settings";
@@ -43,6 +44,7 @@ export default function App() {
<Route path="/stacks/new" element={<StackEditor />} />
<Route path="/stacks/:id" element={<StackDetail />} />
<Route path="/stacks/:id/edit" element={<StackEditor />} />
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} />
+30
View File
@@ -0,0 +1,30 @@
import api from "./client";
import type { Agent, StackDetail, StackSummary } from "@/types";
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
export const agentsApi = {
list: (refresh = true) =>
api.get<Agent[]>(`/api/agents?refresh=${refresh}`).then((r) => r.data),
create: (body: { name: string; url: string; token: string }) =>
api.post<Agent>("/api/agents", body).then((r) => r.data),
update: (id: number, body: { name?: string; url?: string; token?: string }) =>
api.put<Agent>(`/api/agents/${id}`, body).then((r) => r.data),
remove: (id: number) => api.delete(`/api/agents/${id}`).then((r) => r.data),
ping: (id: number) =>
api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data),
stacks: (id: number) =>
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
stack: (id: number, stackId: string) =>
api.get<RemoteStackDetail>(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data),
logs: (id: number, stackId: string, tail = 200) =>
api
.get<{ logs: string }>(`/api/agents/${id}/stacks/${stackId}/logs?tail=${tail}`)
.then((r) => r.data),
update_stack: (id: number, stackId: string, body: { yaml?: string; env?: string }) =>
api.put(`/api/agents/${id}/stacks/${stackId}`, body).then((r) => r.data),
action: (id: number, stackId: string, action: string) =>
api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data),
};
+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>
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, 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 { 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,
});
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>
</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} />}
{tab === "Logs" && <RemoteLogs agentId={aid} stackId={id} />}
{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 }: { containers: any[] }) {
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>
</div>
</Card>
))}
</div>
);
}
function RemoteLogs({ agentId, stackId }: { agentId: number; stackId: string }) {
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["agent-logs", agentId, stackId],
queryFn: () => agentsApi.logs(agentId, stackId, 400),
refetchInterval: 5000,
});
return (
<Card className="flex h-full flex-col overflow-hidden">
<div className="mb-2 flex justify-end">
<Button variant="ghost" onClick={() => refetch()} loading={isFetching}>
Refresh
</Button>
</div>
{isLoading ? (
<Spinner />
) : (
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-all bg-slate-950 p-3 font-mono text-xs text-slate-100">
{data?.logs || "No logs."}
</pre>
)}
</Card>
);
}
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>
);
}
+135 -1
View File
@@ -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 */
/* -------------------------------------------------------------------------- */
+42 -22
View File
@@ -1,11 +1,13 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input, Spinner, Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -23,6 +25,13 @@ export function Stacks() {
refetchInterval: 5000,
});
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
const filtered = useMemo(() => {
let list = (data ?? []).filter(
(s) =>
@@ -68,27 +77,38 @@ export function Stacks() {
)}
</div>
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
<section>
{hasAgents && (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<HardDrive className="h-4 w-4" /> This host
</h2>
)}
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
</section>
{agents.data?.map((agent) => (
<AgentStacksSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
</div>
);
}
+14
View File
@@ -15,6 +15,20 @@ export interface StackSummary {
running_count: number;
created_at: string;
updated_at: string;
// present on stacks proxied from a remote host
agent_id?: number;
agent_name?: string;
}
export interface Agent {
id: number;
name: string;
url: string;
status: "online" | "offline" | "unauthorized" | "unknown";
hostname?: string | null;
last_seen?: string | null;
created_at: string;
token_set: boolean;
}
export interface ContainerInfo {