From cd15cdc75e7fbed6fee2a907110f9f913207e369 Mon Sep 17 00:00:00 2001 From: menzelj Date: Tue, 16 Jun 2026 18:52:51 +0000 Subject: [PATCH] 0.36.0: per-stack image-update indicator on the Stacks overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows an amber "Update" pill next to a stack's status (and highlights the inline Update button) when any of the stack's images has a newer digest in the registry. Reuses the existing background image-update check — a new update_service.stacks_update_summary() reads the cached digests in a single container sweep (no extra registry calls), exposed as GET /api/stacks/updates and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page and each remote-host section poll it every 60s. Co-Authored-By: Claude Opus 4.8 --- backend/agent_app.py | 6 ++++ backend/routers/agents.py | 10 ++++++ backend/routers/stacks.py | 9 ++++- backend/services/update_service.py | 32 +++++++++++++++++ backend/version.py | 2 +- frontend/package.json | 2 +- frontend/src/api/agents.ts | 6 +++- frontend/src/api/stacks.ts | 4 ++- .../components/stacks/AgentStacksSection.tsx | 7 ++++ .../src/components/stacks/StacksTable.tsx | 35 ++++++++++++++++--- frontend/src/pages/Stacks.tsx | 6 ++++ frontend/src/types/index.ts | 5 +++ 12 files changed, 115 insertions(+), 9 deletions(-) diff --git a/backend/agent_app.py b/backend/agent_app.py index 497c7ae..755a0b4 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -280,6 +280,12 @@ def stacks_stats() -> dict: return stats_service.stack_stats() +@app.get("/agent/stacks/updates", dependencies=[Depends(verify_token)]) +def stacks_updates() -> dict: + """Per-stack image-update availability from the cached digests.""" + return update_service.stacks_update_summary() + + @app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)]) def get_stack(stack_id: str) -> dict: directory = compose_service.stack_dir(stack_id) diff --git a/backend/routers/agents.py b/backend/routers/agents.py index fe3f105..4468942 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -226,6 +226,16 @@ async def agent_stacks_stats( return await _proxy(session, agent, "GET", "/agent/stacks/stats") +@router.get("/{agent_id}/stacks/updates") +async def agent_stacks_updates( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/stacks/updates") + + @router.get("/{agent_id}/stacks/{stack_id}") async def agent_stack_detail( agent_id: int, diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py index f085e62..164f654 100644 --- a/backend/routers/stacks.py +++ b/backend/routers/stacks.py @@ -27,7 +27,7 @@ from models.setting import ( ) from models.auto_update import AutoUpdateRead, AutoUpdateWrite from models.user import User -from services import audit_service, auto_update_service, compose_service, notify_service, stats_service +from services import audit_service, auto_update_service, compose_service, notify_service, stats_service, update_service from services.convert_service import convert_docker_run router = APIRouter(prefix="/api/stacks", tags=["stacks"]) @@ -144,6 +144,13 @@ def stacks_stats(_user: User = Depends(get_current_user)) -> dict: return stats_service.stack_stats() +@router.get("/updates") +def stacks_updates(_user: User = Depends(get_current_user)) -> dict: + """Per-stack image-update availability, read from the cached registry + digests (no live registry calls — safe for the list to poll).""" + return update_service.stacks_update_summary() + + @router.get("/{stack_id}") def get_stack( stack_id: str, diff --git a/backend/services/update_service.py b/backend/services/update_service.py index 078cd51..447f85b 100644 --- a/backend/services/update_service.py +++ b/backend/services/update_service.py @@ -215,6 +215,38 @@ def stack_images(stack_id: str) -> set[str]: return images +def stacks_update_summary() -> dict[str, dict]: + """Per-stack image-update status for every running compose project, read + from the digest cache the background loop maintains — no registry calls, so + it's cheap enough for the stacks list to poll. Stacks with no cached image + yet are simply absent (treated as "no update" by the UI).""" + by_stack: dict[str, set[str]] = {} + try: + client = get_client() + for c in safe_call(client.containers.list, all=True): + project = (c.labels or {}).get("com.docker.compose.project") + if not project: + continue + cfg_image = c.attrs.get("Config", {}).get("Image") + if cfg_image: + by_stack.setdefault(project, set()).add(cfg_image) + except DockerError: + return {} + + summary: dict[str, dict] = {} + for stack_id, images in by_stack.items(): + stale = [ + img + for img in images + if (st := _CACHE.get(img)) is not None and st.update_available + ] + summary[stack_id] = { + "update_available": bool(stale), + "stale_images": stale, + } + return summary + + async def stack_updates(stack_id: str, refresh: bool = True) -> dict: """Update status for one stack's images. diff --git a/backend/version.py b/backend/version.py index 48f9192..aa99936 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.35.0" +APP_VERSION = "0.36.0" diff --git a/frontend/package.json b/frontend/package.json index 51c5a79..fbe21f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.35.0", + "version": "0.36.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 7f293bf..f99b16e 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -1,5 +1,5 @@ import api from "./client"; -import type { Agent, StackDetail, StackStats, StackSummary } from "@/types"; +import type { Agent, StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types"; export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string }; export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string }; @@ -35,6 +35,10 @@ export const agentsApi = { api.get(`/api/agents/${id}/stacks`).then((r) => r.data), stackStats: (id: number) => api.get>(`/api/agents/${id}/stacks/stats`).then((r) => r.data), + stackUpdates: (id: number) => + api + .get>(`/api/agents/${id}/stacks/updates`) + .then((r) => r.data), createStack: (id: number, body: { name: string; yaml: string; env?: string }) => api .post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body) diff --git a/frontend/src/api/stacks.ts b/frontend/src/api/stacks.ts index 3889c43..90a8c09 100644 --- a/frontend/src/api/stacks.ts +++ b/frontend/src/api/stacks.ts @@ -1,9 +1,11 @@ import api from "./client"; -import type { StackDetail, StackStats, StackSummary } from "@/types"; +import type { StackDetail, StackStats, StackSummary, StackUpdateInfo } from "@/types"; export const stacksApi = { list: () => api.get("/api/stacks").then((r) => r.data), stats: () => api.get>("/api/stacks/stats").then((r) => r.data), + updates: () => + api.get>("/api/stacks/updates").then((r) => r.data), get: (id: string) => api.get(`/api/stacks/${id}`).then((r) => r.data), create: (body: { name: string; description?: string; yaml?: string; env?: string }) => diff --git a/frontend/src/components/stacks/AgentStacksSection.tsx b/frontend/src/components/stacks/AgentStacksSection.tsx index 88d2e5e..3c2c5e2 100644 --- a/frontend/src/components/stacks/AgentStacksSection.tsx +++ b/frontend/src/components/stacks/AgentStacksSection.tsx @@ -33,6 +33,12 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: 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); @@ -72,6 +78,7 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: | undefined; + updates?: Record; hostCpus: number; hostMem: number; isAdmin: boolean; @@ -70,6 +72,7 @@ export function StacksTable({ key={s.id} stack={s} stats={stats?.[s.id]} + update={updates?.[s.id]} hostCpus={hostCpus} hostMem={hostMem} isAdmin={isAdmin} @@ -92,6 +95,7 @@ export function StacksTable({ function StackRow({ stack, stats, + update, hostCpus, hostMem, isAdmin, @@ -106,6 +110,7 @@ function StackRow({ }: { stack: StackSummary; stats?: StackStats; + update?: StackUpdateInfo; hostCpus: number; hostMem: number; isAdmin: boolean; @@ -120,6 +125,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 [confirming, setConfirming] = useState(false); const [deleting, setDeleting] = useState(false); @@ -149,6 +155,18 @@ function StackRow({ {stack.running_count}/{stack.service_count} svc + {updateAvailable && ( + + Update + + )} @@ -201,13 +219,22 @@ function StackRow({ )} {onUpdate && ( - onUpdate(stack.id)} disabled={busy} + className={ + updateAvailable + ? "rounded-lg p-1.5 text-amber-600 hover:bg-amber-100 disabled:opacity-40 dark:text-amber-400 dark:hover:bg-amber-500/20" + : "rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700" + } > - + )} {showEdit && (