diff --git a/backend/agent_app.py b/backend/agent_app.py
index cecdd55..abf1eea 100644
--- a/backend/agent_app.py
+++ b/backend/agent_app.py
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
-AGENT_VERSION = "0.21.3"
+AGENT_VERSION = "0.21.4"
# --------------------------------------------------------------------------- #
diff --git a/backend/main.py b/backend/main.py
index 163cfd4..3364377 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
-app = FastAPI(title="StackPilot", version="0.21.3", lifespan=lifespan)
+app = FastAPI(title="StackPilot", version="0.21.4", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
diff --git a/frontend/package.json b/frontend/package.json
index 23ba9f4..f0c4e2f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
- "version": "0.21.3",
+ "version": "0.21.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/stacks/AgentStacksSection.tsx b/frontend/src/components/stacks/AgentStacksSection.tsx
index a4e14e2..14d655e 100644
--- a/frontend/src/components/stacks/AgentStacksSection.tsx
+++ b/frontend/src/components/stacks/AgentStacksSection.tsx
@@ -3,7 +3,7 @@ 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 { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { HostDot } from "@/components/hosts/HostDot";
import { agentsApi } from "@/api/agents";
@@ -21,6 +21,18 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
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 run = async (action: string, label: string, id: string) => {
setBusyId(id);
@@ -56,26 +68,21 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin:
Host is {agent.status}. Check it under Settings → Remote hosts.
- ) : stacks.data && stacks.data.length > 0 ? (
-
- {stacks.data.map((s) => (
- run("start", "Starting", id)}
- onStop={(id) => run("stop", "Stopping", id)}
- onRestart={(id) => run("restart", "Restarting", id)}
- />
- ))}
-
) : (
-
- No stacks on this host.
-
+ run("start", "Starting", id)}
+ onStop={(id) => run("stop", "Stopping", id)}
+ onRestart={(id) => run("restart", "Restarting", id)}
+ emptyText="No stacks on this host."
+ />
)}
);
diff --git a/frontend/src/components/stacks/StackCard.tsx b/frontend/src/components/stacks/StackCard.tsx
deleted file mode 100644
index f82a0ee..0000000
--- a/frontend/src/components/stacks/StackCard.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useState } from "react";
-import { Link } from "react-router-dom";
-import { useQueryClient } from "@tanstack/react-query";
-import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react";
-import { toast } from "sonner";
-import { Card, StatusDot, Badge } from "@/components/ui";
-import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
-import { cn, relativeTime } from "@/lib/utils";
-import { stacksApi } from "@/api/stacks";
-import { apiErrorMessage } from "@/api/client";
-import type { StackSummary } from "@/types";
-
-interface Props {
- stack: StackSummary;
- onStart: (id: string) => void;
- onStop: (id: string) => void;
- 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({
- stack,
- onStart,
- onStop,
- onRestart,
- busy,
- isAdmin,
- linkBase = "/stacks",
- showEdit = true,
-}: Props) {
- const qc = useQueryClient();
- const [confirming, setConfirming] = useState(false);
- const [deleting, setDeleting] = useState(false);
- const isLocal = !stack.agent_id;
-
- const remove = async () => {
- setDeleting(true);
- const t = toast.loading(`Deleting ${stack.id}…`);
- try {
- await stacksApi.remove(stack.id, true);
- toast.success(`Deleted ${stack.id}`, { id: t });
- qc.invalidateQueries({ queryKey: ["stacks"] });
- } catch (e) {
- toast.error(apiErrorMessage(e), { id: t });
- } finally {
- setDeleting(false);
- setConfirming(false);
- }
- };
-
- return (
-
-
-
-
-
-
- {stack.name}
-
-
- {stack.description && (
-
- {stack.description}
-
- )}
-
-
{stack.status}
-
-
-
-
- {stack.running_count}/{stack.service_count} services
-
- ·
- updated {relativeTime(stack.updated_at)}
-
-
- {isAdmin && (
-
-
onStart(stack.id)} disabled={busy}>
-
-
-
onStop(stack.id)} disabled={busy}>
-
-
-
onRestart(stack.id)} disabled={busy}>
-
-
- {showEdit && (
-
-
-
- )}
- {isLocal && (
-
setConfirming(true)}
- className={showEdit ? "" : "ml-auto"}
- >
-
-
- )}
-
- )}
-
- {confirming && (
- setConfirming(false)}
- />
- )}
-
- );
-}
-
-function IconBtn({
- children,
- title,
- onClick,
- disabled,
- className,
-}: {
- children: React.ReactNode;
- title: string;
- onClick: () => void;
- disabled?: boolean;
- className?: string;
-}) {
- return (
-
- );
-}
diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx
index c0821bd..2ab1134 100644
--- a/frontend/src/components/stacks/StacksTable.tsx
+++ b/frontend/src/components/stacks/StacksTable.tsx
@@ -1,7 +1,13 @@
+import { useState } from "react";
import { Link } from "react-router-dom";
-import { Play, Square, RotateCw } from "lucide-react";
+import { useQueryClient } from "@tanstack/react-query";
+import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react";
+import { toast } from "sonner";
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
+import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { formatBytes } from "@/lib/utils";
+import { stacksApi } from "@/api/stacks";
+import { apiErrorMessage } from "@/api/client";
import type { StackStats, StackSummary } from "@/types";
/** Stack list rendered as a table with live CPU/memory usage meters and inline
@@ -15,6 +21,8 @@ export function StacksTable({
busyId,
loading,
linkBase = "/stacks",
+ showEdit = false,
+ showDelete = false,
onStart,
onStop,
onRestart,
@@ -28,6 +36,8 @@ export function StacksTable({
busyId: string | null;
loading: boolean;
linkBase?: string;
+ showEdit?: boolean;
+ showDelete?: boolean;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
@@ -63,6 +73,8 @@ export function StacksTable({
isAdmin={isAdmin}
busy={busyId === s.id}
linkBase={linkBase}
+ showEdit={showEdit}
+ showDelete={showDelete}
onStart={onStart}
onStop={onStop}
onRestart={onRestart}
@@ -82,6 +94,8 @@ function StackRow({
isAdmin,
busy,
linkBase,
+ showEdit,
+ showDelete,
onStart,
onStop,
onRestart,
@@ -93,11 +107,32 @@ function StackRow({
isAdmin: boolean;
busy: boolean;
linkBase: string;
+ showEdit: boolean;
+ showDelete: boolean;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
}) {
+ const qc = useQueryClient();
const running = stack.running_count > 0;
+ const canDelete = showDelete && !stack.agent_id;
+ const [confirming, setConfirming] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+
+ const remove = async () => {
+ setDeleting(true);
+ const t = toast.loading(`Deleting ${stack.id}…`);
+ try {
+ await stacksApi.remove(stack.id, true);
+ toast.success(`Deleted ${stack.id}`, { id: t });
+ qc.invalidateQueries({ queryKey: ["stacks"] });
+ } catch (e) {
+ toast.error(apiErrorMessage(e), { id: t });
+ } finally {
+ setDeleting(false);
+ setConfirming(false);
+ }
+ };
return (
@@ -160,7 +195,32 @@ function StackRow({
)}
+ {showEdit && (
+
+
+
+ )}
+ {canDelete && (
+ setConfirming(true)} disabled={busy}>
+
+
+ )}
+ {confirming && (
+ setConfirming(false)}
+ />
+ )}
)}
diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx
index 48c937a..3873822 100644
--- a/frontend/src/pages/Stacks.tsx
+++ b/frontend/src/pages/Stacks.tsx
@@ -2,11 +2,12 @@ import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react";
-import { Button, Input, Spinner, Card } from "@/components/ui";
-import { StackCard } from "@/components/stacks/StackCard";
+import { Button, Input } from "@/components/ui";
+import { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks";
+import { systemApi } from "@/api/system";
import { agentsApi } from "@/api/agents";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -25,6 +26,17 @@ export function Stacks() {
refetchInterval: 5000,
});
+ const stats = useQuery({
+ queryKey: ["stack-stats"],
+ queryFn: stacksApi.stats,
+ refetchInterval: 5000,
+ });
+ const info = useQuery({
+ queryKey: ["system"],
+ queryFn: systemApi.info,
+ refetchInterval: 5000,
+ });
+
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
@@ -83,27 +95,21 @@ export function Stacks() {
This host
)}
- {isLoading ? (
-
- ) : filtered.length > 0 ? (
-
- {filtered.map((s) => (
-
- ))}
-
- ) : (
-
- No stacks match your search.
-
- )}
+
{agents.data?.map((agent) => (