0.36.0: per-stack image-update indicator on the Stacks overview

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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-16 18:52:51 +00:00
co-authored by Claude Opus 4.8
parent a4b1bbcdd1
commit cd15cdc75e
12 changed files with 115 additions and 9 deletions
+6
View File
@@ -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)
+10
View File
@@ -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,
+8 -1
View File
@@ -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,
+32
View File
@@ -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.
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.35.0"
APP_VERSION = "0.36.0"