Phase 13: multi-host networks & images (0.14.0)
Networks and Images are now per-host, rendered as a section for the local host
plus one per registered agent (like the Stacks page).
- agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/
create/delete/prune) and /agent/images (list/updates/check), reusing
network_service and a new image_service; DockerError mapped to HTTP status
(forbidden -> 400 so the proxy doesn't treat it as a token failure).
- routers/agents.py: proxy routes at /api/agents/{id}/networks/* and
/api/agents/{id}/images/*, audit-logging mutations.
- services/image_service.py: extracted the image-listing logic so the central
router and the agent share it.
- Frontend: networksApi/imagesApi take an optional agentId; Networks/Images
pages render NetworksSection/ImagesSection per host with a shared HostHeader.
Remote "Prune unused" networks resolves the address-pool-exhaustion deploy
error from the UI.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4bc0fb8901
commit
012614f5fb
@@ -1,12 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Card, Spinner } from "@/components/ui";
|
||||
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||
import { imagesApi, type ImageRow } from "@/api/images";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent } from "@/types";
|
||||
|
||||
function UpdateBadge({ row }: { row: ImageRow }) {
|
||||
const u = row.update;
|
||||
@@ -27,16 +30,48 @@ function UpdateBadge({ row }: { row: ImageRow }) {
|
||||
|
||||
export function Images() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const agents = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => agentsApi.list(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const hasAgents = (agents.data?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ImagesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
|
||||
{agents.data?.map((agent) => (
|
||||
<ImagesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImagesSection({
|
||||
agent,
|
||||
isAdmin,
|
||||
showHostLabel,
|
||||
}: {
|
||||
agent?: Agent;
|
||||
isAdmin: boolean;
|
||||
showHostLabel: boolean;
|
||||
}) {
|
||||
const agentId = agent?.id;
|
||||
const online = !agent || agent.status === "online";
|
||||
const qc = useQueryClient();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["images"], queryFn: imagesApi.list });
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["images", agentId ?? "local"],
|
||||
queryFn: () => imagesApi.list(agentId),
|
||||
enabled: online,
|
||||
});
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
const t = toast.loading("Checking for updates…");
|
||||
try {
|
||||
await imagesApi.check();
|
||||
await qc.invalidateQueries({ queryKey: ["images"] });
|
||||
await imagesApi.check(agentId);
|
||||
await qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] });
|
||||
toast.success("Update check complete", { id: t });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: t });
|
||||
@@ -45,47 +80,63 @@ export function Images() {
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
|
||||
{isAdmin && (
|
||||
<Button onClick={check} loading={checking}>
|
||||
<RefreshCw className="h-4 w-4" /> Check updates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<section>
|
||||
{(showHostLabel || (isAdmin && online)) && (
|
||||
<HostHeader agent={agent}>
|
||||
{isAdmin && online && (
|
||||
<Button onClick={check} loading={checking}>
|
||||
<RefreshCw className="h-4 w-4" /> Check updates
|
||||
</Button>
|
||||
)}
|
||||
</HostHeader>
|
||||
)}
|
||||
|
||||
<Card className="overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
||||
<tr>
|
||||
<th className="p-3">Image</th>
|
||||
<th className="p-3">Used by</th>
|
||||
<th className="p-3">Size</th>
|
||||
<th className="p-3">Created</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((row) => (
|
||||
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.created ? relativeTime(row.created) : "—"}
|
||||
</td>
|
||||
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||
{!online ? (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
Host is {agent?.status}. Check it under Settings → Remote hosts.
|
||||
</p>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Card className="overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
||||
<tr>
|
||||
<th className="p-3">Image</th>
|
||||
<th className="p-3">Used by</th>
|
||||
<th className="p-3">Size</th>
|
||||
<th className="p-3">Created</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((row) => (
|
||||
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.created ? relativeTime(row.created) : "—"}
|
||||
</td>
|
||||
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-6 text-center text-sm text-slate-500">
|
||||
No images.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user