Remove the remote-host (agent) integration (0.48.0)
CI / check (push) Successful in 7m17s
CI / build-and-push (push) Successful in 1m45s

StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.

Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.

Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.

Two things the removal exposed as dead weight rather than merely unused:

compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.

The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.

Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.

Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.

CI no longer builds or pushes stackpilot-agent.

735 tests pass, ruff and tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
menzelj
2026-08-31 14:11:54 +02:00
co-authored by Claude Opus 5
parent 09bed274eb
commit 51d1998307
85 changed files with 653 additions and 4717 deletions
@@ -3,7 +3,6 @@ import {
AlertTriangle,
AlertCircle,
CheckCircle2,
ServerOff,
HeartPulse,
ArrowUpCircle,
HardDrive,
@@ -15,7 +14,6 @@ import type { AttentionItem } from "@/api/dashboard";
import { cn } from "@/lib/utils";
const KIND_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
agent_offline: ServerOff,
unhealthy: HeartPulse,
stack_error: AlertTriangle,
stack_partial: AlertCircle,
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { Server, Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react";
import { Boxes, Container, HeartPulse, ArrowUpCircle, Archive } from "lucide-react";
import type { FleetKpis } from "@/api/dashboard";
import { cn } from "@/lib/utils";
@@ -47,14 +47,7 @@ function Kpi({
export function FleetKpiRow({ kpis }: { kpis: FleetKpis }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Kpi
icon={<Server />}
label="Hosts"
value={`${kpis.hosts_online}/${kpis.hosts_total}`}
sub="online"
tone={kpis.hosts_online < kpis.hosts_total ? "error" : "default"}
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Kpi
icon={<Boxes />}
label="Stacks"
@@ -32,7 +32,7 @@ export function HostResourceTable({ hosts }: { hosts: FleetHost[] }) {
return (
<div className="sp-card overflow-hidden p-0">
<div className="border-b border-sp-border px-4 py-2.5">
<h2 className="sp-label">Hosts</h2>
<h2 className="sp-label">Host resources</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
-17
View File
@@ -1,17 +0,0 @@
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}
/>
);
}
@@ -1,30 +0,0 @@
import type { ReactNode } from "react";
import { HardDrive, Server } from "lucide-react";
import { HostDot } from "@/components/hosts/HostDot";
import type { Agent } from "@/types";
/**
* Section header for a host (local or a remote agent). `children` is rendered
* on the right for per-host action buttons.
*/
export function HostHeader({
agent,
children,
}: {
agent?: Agent;
children?: ReactNode;
}) {
return (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
{agent ? <Server className="h-4 w-4" /> : <HardDrive className="h-4 w-4" />}
{agent ? agent.name : "This host"}
{agent && <HostDot status={agent.status} />}
{agent?.hostname && (
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
)}
</h2>
<div className="flex flex-wrap gap-2">{children}</div>
</div>
);
}
-25
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
LayoutDashboard,
Boxes,
@@ -21,7 +20,6 @@ import {
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents";
import { VersionBadge } from "./VersionBadge";
type NavItem = {
@@ -91,14 +89,6 @@ export function TopNav() {
const signOutEverywhere = useAuthStore((s) => s.signOutEverywhere);
const { theme, toggle } = useThemeStore();
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 30000,
});
const agentCount = agents.data?.length ?? 0;
const agentsOnline = agents.data?.filter((a) => a.status === "online").length ?? 0;
// Close avatar menu on outside click.
useEffect(() => {
if (!menuOpen) return;
@@ -159,21 +149,6 @@ export function TopNav() {
{/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
<VersionBadge />
{agentCount > 0 && (
<button
onClick={() => navigate("/settings")}
className="hidden items-center gap-1.5 rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 text-xs font-medium text-sp-text-2 hover:text-sp-text-1 sm:flex"
title={`${agentsOnline}/${agentCount} remote hosts online`}
>
<span
className={cn(
"h-2 w-2 rounded-full",
agentsOnline === agentCount ? "bg-sp-green" : "bg-sp-amber"
)}
/>
{agentsOnline}/{agentCount}
</button>
)}
<button
onClick={toggle}
className="rounded-pill border border-sp-border bg-sp-surface p-2 text-sp-text-2 hover:text-sp-text-1"
@@ -1,98 +0,0 @@
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 { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
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 stats = useQuery({
queryKey: ["agent-stack-stats", agent.id],
queryFn: () => agentsApi.stackStats(agent.id),
enabled: online,
refetchInterval: 5000,
});
const sys = useQuery({
queryKey: ["agent-system", agent.id],
queryFn: () => agentsApi.system(agent.id),
enabled: online,
refetchInterval: 30000,
});
const updates = useQuery({
queryKey: ["agent-stack-updates", agent.id],
queryFn: () => agentsApi.stackUpdates(agent.id),
enabled: online,
refetchInterval: 60000,
});
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] });
qc.invalidateQueries({ queryKey: ["agent-stack-updates", agent.id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusyId(null);
}
};
return (
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="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>
{isAdmin && online && <RestoreButton agentId={agent.id} />}
</div>
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : (
<StacksTable
stacks={stacks.data}
stats={stats.data}
updates={updates.data}
hostCpus={sys.data?.cpu_cores ?? 0}
hostMem={sys.data?.mem_total ?? 0}
isAdmin={isAdmin}
isBusy={(id) => busyId === id}
loading={stacks.isLoading}
linkBase={`/hosts/${agent.id}/stacks`}
onStart={(id) => run("start", "Starting", id)}
onStop={(id) => run("stop", "Stopping", id)}
onRestart={(id) => run("restart", "Restarting", id)}
onUpdate={isAdmin ? (id) => run("update", "Updating", id) : undefined}
emptyText="No stacks on this host."
/>
)}
</section>
);
}
@@ -14,36 +14,34 @@ const STATUS_TONE: Record<string, string> = {
};
/**
* Watchtower-style auto-update control for one stack (local or, with agentId,
* a remote agent's stack). When a newer image digest is found by the background
* Watchtower-style auto-update control for one stack. When a newer image
* digest is found by the background
* check, the stack is pulled + redeployed or merely flagged, per the policy.
*/
export function AutoUpdatePanel({
stackId,
agentId,
isAdmin,
}: {
stackId: string;
agentId?: number;
isAdmin: boolean;
}) {
const qc = useQueryClient();
const key = ["auto-update", agentId ?? "local", stackId];
const key = ["auto-update", stackId];
const { data, isLoading } = useQuery({
queryKey: key,
queryFn: () => autoUpdateApi.get(stackId, agentId),
queryFn: () => autoUpdateApi.get(stackId),
});
const save = useMutation({
mutationFn: (body: { enabled: boolean; redeploy: boolean }) =>
autoUpdateApi.set(stackId, body, agentId),
autoUpdateApi.set(stackId, body),
onSuccess: (p) => qc.setQueryData(key, p),
onError: (e) => toast.error(apiErrorMessage(e)),
});
const runNow = useMutation({
mutationFn: () => autoUpdateApi.run(stackId, agentId),
mutationFn: () => autoUpdateApi.run(stackId),
onSuccess: (p) => {
qc.setQueryData(key, p);
toast.success(`Auto-update: ${p.last_status ?? "done"}`);
@@ -84,7 +82,7 @@ export function AutoUpdatePanel({
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
name={`mode-${stackId}`}
checked={p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: true })}
/>
@@ -93,7 +91,7 @@ export function AutoUpdatePanel({
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
name={`mode-${stackId}`}
checked={!p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: false })}
/>
@@ -5,7 +5,6 @@ import { toast } from "sonner";
import { Button } from "@/components/ui";
import { backupsApi, destinationsApi } from "@/api/backups";
import type { BackupReport } from "@/api/backups";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { formatBytes } from "@/lib/utils";
@@ -117,13 +116,7 @@ function AssetRow({
);
}
export function BackupButton({
stackId,
agentId,
}: {
stackId: string;
agentId?: number;
}) {
export function BackupButton({ stackId }: { stackId: string }) {
const [open, setOpen] = useState(false);
const [stopFirst, setStopFirst] = useState(true);
const [target, setTarget] = useState("download"); // "download" | destination id
@@ -138,11 +131,8 @@ export function BackupButton({
enabled: open,
});
const inventory = useQuery({
queryKey: ["backup-inventory", agentId ?? "local", stackId],
queryFn: () =>
agentId != null
? agentsApi.backupInventory(agentId, stackId)
: backupsApi.inventory(stackId),
queryKey: ["backup-inventory", stackId],
queryFn: () => backupsApi.inventory(stackId),
enabled: open,
});
@@ -175,10 +165,7 @@ export function BackupButton({
volumes: volSel.length ? volSel : undefined,
};
if (target === "download") {
const report =
agentId != null
? await agentsApi.backupDownload(agentId, stackId, opts)
: await backupsApi.download(stackId, opts);
const report = await backupsApi.download(stackId, opts);
toast.success(describe(report), { id: tid });
} else {
const body = {
@@ -189,10 +176,7 @@ export function BackupButton({
binds: opts.binds,
volumes: opts.volumes,
};
const res =
agentId != null
? await agentsApi.backupPush(agentId, stackId, body)
: await backupsApi.push(stackId, body);
const res = await backupsApi.push(stackId, body);
toast.success(`Pushed to ${res.destination}`, { id: tid });
}
setOpen(false);
@@ -310,7 +294,7 @@ export function BackupButton({
);
}
export function RestoreButton({ agentId }: { agentId?: number }) {
export function RestoreButton() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"upload" | "destination">("upload");
@@ -351,10 +335,7 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
restoreVolumes,
restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreUpload(agentId, file, opts)
: await backupsApi.restore(file, opts);
res = await backupsApi.restore(file, opts);
} else {
if (!destId || !remoteName) {
toast.error("Pick a destination and a backup", { id: tid });
@@ -369,15 +350,12 @@ export function RestoreButton({ agentId }: { agentId?: number }) {
restore_volumes: restoreVolumes,
restore_binds: restoreBinds,
};
res =
agentId != null
? await agentsApi.restoreFrom(agentId, body)
: await backupsApi.restoreFrom(body);
res = await backupsApi.restoreFrom(body);
}
const bits = [`${res.volumes_restored} volume(s)`];
if (res.binds_restored) bits.push(`${res.binds_restored} folder(s)`);
toast.success(`Restored '${res.stack_id}' — ${bits.join(", ")}`, { id: tid });
qc.invalidateQueries({ queryKey: agentId != null ? ["agent-stacks", agentId] : ["stacks"] });
qc.invalidateQueries({ queryKey: ["stacks"] });
setOpen(false);
setFile(null);
setTargetId("");
@@ -11,13 +11,11 @@ import type { ContainerInfo } from "@/types";
export function ContainerCard({
container,
agentId,
host,
isAdmin,
onChanged,
}: {
container: ContainerInfo;
agentId?: number;
host?: string;
isAdmin: boolean;
onChanged?: () => void;
@@ -28,8 +26,8 @@ export function ContainerCard({
const running = container.state === "running";
const detail = useQuery({
queryKey: ["container", agentId ?? "local", container.id],
queryFn: () => containersApi.inspect(container.id, agentId),
queryKey: ["container", container.id],
queryFn: () => containersApi.inspect(container.id),
enabled: open,
});
@@ -37,7 +35,7 @@ export function ContainerCard({
setBusy(true);
const t = toast.loading(`${action} ${container.service}`);
try {
await containersApi.action(container.id, action, agentId);
await containersApi.action(container.id, action);
toast.success(`${container.service}: ${action} ok`, { id: t });
onChanged?.();
if (open) detail.refetch();
@@ -153,7 +151,6 @@ export function ContainerCard({
<ContainerTerminal
containerId={container.id}
service={container.service}
agentId={agentId}
onClose={() => setTermOpen(false)}
/>
)}
@@ -8,7 +8,7 @@ export type ContainerPort = {
const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]);
/** Pick the host to link to: an explicit override (remote agent), the bound
/** Pick the host to link to: an explicit override, the bound
* host IP if it's a concrete address, otherwise the host we're viewing from. */
function linkHost(hostIp: string | undefined, override?: string): string {
if (override) return override;
@@ -25,7 +25,7 @@ function scheme(hostPort: string, container: string): "http" | "https" {
}
/** Clickable chips for a container's published ports. `host` overrides the
* link target (used for remote agent stacks). Renders nothing if unpublished. */
* link target. Renders nothing if unpublished. */
export function ContainerPorts({
ports,
host,
@@ -12,19 +12,17 @@ const SHELLS = ["/bin/sh", "/bin/bash", "/bin/ash"];
/**
* Interactive terminal modal: opens an exec session into a compose-managed
* container over `/ws/exec/{id}` (or `/ws/agent-exec/{aid}/{id}` for a remote
* agent) and wires it to an xterm.js terminal. Admin-only on the backend; a
* container over `/ws/exec/{id}` and wires it to an xterm.js terminal.
* Admin-only on the backend; a
* 4403 close surfaces as an "admin only" error.
*/
export function ContainerTerminal({
containerId,
service,
agentId,
onClose,
}: {
containerId: string;
service: string;
agentId?: number;
onClose: () => void;
}) {
const token = useAuthStore((s) => s.accessToken);
@@ -50,12 +48,8 @@ export function ContainerTerminal({
fit.fit();
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-exec/${agentId}/${containerId}`
: `/ws/exec/${containerId}`;
const url =
`${proto}://${window.location.host}${path}` +
`${proto}://${window.location.host}/ws/exec/${containerId}` +
`?token=${encodeURIComponent(token)}&cmd=${encodeURIComponent(shell)}`;
const ws = new WebSocket(url);
@@ -120,7 +114,7 @@ export function ContainerTerminal({
ws.close();
term.dispose();
};
}, [containerId, agentId, shell, token]);
}, [containerId, shell, token]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@@ -29,11 +29,9 @@ const EMPTY_STATS: DeployStats = {
*/
export function DeployConsole({
stackId,
agentId,
onClose,
}: {
stackId: string;
agentId?: number;
onClose: () => void;
}) {
const [lines, setLines] = useState<LogLine[]>([]);
@@ -89,11 +87,7 @@ export function DeployConsole({
};
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-deploy/${agentId}/${stackId}`
: `/ws/deploy/${stackId}`;
const url = `${proto}://${window.location.host}${path}?token=${token}`;
const url = `${proto}://${window.location.host}/ws/deploy/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onmessage = (ev) => {
try {
@@ -136,7 +130,7 @@ export function DeployConsole({
window.clearInterval(timer);
ws.close();
};
}, [stackId, agentId, token, tracker]);
}, [stackId, token, tracker]);
useEffect(() => {
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
+5 -9
View File
@@ -87,7 +87,7 @@ type LogLine = {
level: Level;
};
export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: number }) {
export function LogViewer({ stackId }: { stackId: string }) {
const [lines, setLines] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
@@ -102,18 +102,14 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
setError(null);
let gotError = false;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const path =
agentId != null
? `/ws/agent-logs/${agentId}/${stackId}`
: `/ws/logs/${stackId}`;
const url = `${proto}://${window.location.host}${path}?token=${token}`;
const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = (ev) => {
setConnected(false);
// Auth rejection from the proxy/agent (JWT or agent token) closes 4401.
// An expired or revoked session closes 4401.
if (!gotError && ev.code === 4401) {
setError("Not authorized to stream logs (session or agent token).");
setError("Not authorized to stream logs sign in again.");
}
};
ws.onmessage = (ev) => {
@@ -137,7 +133,7 @@ export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: num
}
};
return () => ws.close();
}, [stackId, agentId, token]);
}, [stackId, token]);
// Unique services seen so far, for the container filter.
const services = useMemo(() => {
@@ -15,26 +15,24 @@ import { apiErrorMessage } from "@/api/client";
export function SecretsPanel({
stackId,
yaml,
agentId,
isAdmin,
onChanged,
}: {
stackId: string;
yaml: string;
agentId?: number;
isAdmin: boolean;
onChanged?: () => void;
}) {
const qc = useQueryClient();
const key = ["secrets", agentId ?? "local", stackId];
const key = ["secrets", stackId];
const list = useQuery({
queryKey: key,
queryFn: () => secretsApi.list(stackId, agentId),
queryFn: () => secretsApi.list(stackId),
enabled: isAdmin,
});
const services = useQuery({
queryKey: ["editor-services", stackId, agentId, yaml.length],
queryKey: ["editor-services", stackId, yaml.length],
queryFn: () => editorApi.services(yaml),
enabled: isAdmin,
});
@@ -46,7 +44,7 @@ export function SecretsPanel({
const invalidate = () => qc.invalidateQueries({ queryKey: key });
const create = useMutation({
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }, agentId),
mutationFn: () => secretsApi.write(stackId, { kind, name: name.trim(), content }),
onSuccess: () => {
toast.success(`${kind} "${name}" saved`);
setName(""); setContent("");
@@ -56,21 +54,21 @@ export function SecretsPanel({
});
const remove = useMutation({
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name, agentId),
mutationFn: (s: SecretEntry) => secretsApi.remove(stackId, s.kind, s.name),
onSuccess: () => { toast.success("Deleted"); invalidate(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const attach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string; target?: string }) =>
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }, agentId),
secretsApi.attach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service, target: v.target }),
onSuccess: () => { toast.success("Attached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const detach = useMutation({
mutationFn: (v: { s: SecretEntry; service: string }) =>
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }, agentId),
secretsApi.detach(stackId, { kind: v.s.kind, name: v.s.name, service: v.service }),
onSuccess: () => { toast.success("Detached — redeploy the stack to apply"); onChanged?.(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
@@ -25,7 +25,6 @@ export function StacksTable({
statusFor,
onDismissStatus,
loading,
linkBase = "/stacks",
showEdit = false,
showDelete = false,
onStart,
@@ -45,7 +44,6 @@ export function StacksTable({
statusFor?: (id: string) => StackActionStatus | undefined;
onDismissStatus?: (id: string) => void;
loading: boolean;
linkBase?: string;
showEdit?: boolean;
showDelete?: boolean;
onStart: (id: string) => void;
@@ -86,7 +84,6 @@ export function StacksTable({
busy={isBusy(s.id)}
status={statusFor?.(s.id)}
onDismissStatus={onDismissStatus}
linkBase={linkBase}
showEdit={showEdit}
showDelete={showDelete}
onStart={onStart}
@@ -111,7 +108,6 @@ function StackRow({
busy,
status,
onDismissStatus,
linkBase,
showEdit,
showDelete,
onStart,
@@ -128,7 +124,6 @@ function StackRow({
busy: boolean;
status?: StackActionStatus;
onDismissStatus?: (id: string) => void;
linkBase: string;
showEdit: boolean;
showDelete: boolean;
onStart: (id: string) => void;
@@ -139,7 +134,7 @@ function StackRow({
const qc = useQueryClient();
const running = stack.running_count > 0;
const updateAvailable = update?.update_available ?? false;
const canDelete = showDelete && !stack.agent_id;
const canDelete = showDelete;
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -165,7 +160,7 @@ function StackRow({
to the CPU column, so the row never changes height. */}
<div className="flex items-center gap-2">
<Link
to={`${linkBase}/${stack.id}`}
to={`/stacks/${stack.id}`}
className="flex shrink-0 items-center gap-2"
>
<StatusDot status={stack.status} />
@@ -259,7 +254,7 @@ function StackRow({
)}
{showEdit && (
<Link
to={`${linkBase}/${stack.id}/edit`}
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>