Phase 22: auto-update (Watchtower-style), local + agent (0.28.0)

Per-stack auto-update policy on the stack Overview tab. When the background
image-update check finds a newer registry digest for one of a stack's images,
the stack is pulled + redeployed (or just flagged, "notify only"). Only running
stacks are auto-redeployed; a stopped stack is skipped, never silently started.

- models/auto_update.py: AutoUpdate(stack_id, agent_id, enabled, redeploy,
  last_run/status/result) + schemas; registered in models/__init__.py.
- update_service: DB-free stack_images/stack_updates helpers (agent reuses
  them); agent GET /agent/stacks/{id}/updates.
- services/auto_update_service.py: run_due/run_policy (local pull+up via
  compose_service, remote via agent_service POST /agent/stacks/{id}/update,
  notify-only with per-transition dedup); lazy-called from
  update_service.background_loop. New stack_auto_updated notify event.
- routers: GET/PUT/run /api/stacks/{id}/auto-update and the
  /api/agents/{id}/stacks/{sid}/auto-update variants (policy stored centrally).
- frontend: api/autoUpdate.ts + AutoUpdatePanel (enable, redeploy|notify-only,
  Check now, last-run status) on StackDetail + RemoteStackDetail; EVENT_LABELS
  gains stack_auto_updated + backup_failed.

Live-verified all four paths (updated / update-available / up-to-date /
skipped) against real compose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-09 13:14:50 +00:00
co-authored by Claude Opus 4.8
parent be3568274f
commit 255c8441c6
17 changed files with 592 additions and 6 deletions
+14
View File
@@ -153,6 +153,20 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
container or connect any container on the host (`POST /api/networks/{id}/connect` container or connect any container on the host (`POST /api/networks/{id}/connect`
/ `/disconnect`). / `/disconnect`).
### Phase 22 — Auto-update (Watchtower-style)
- A per-stack **Auto-update** policy (on the stack Overview tab): when the
background image-update check finds a newer registry digest for one of the
stack's images, the stack is either **pulled + redeployed** or merely
**flagged** ("notify only"), with a **Check now** button for an on-demand run.
- Runs inside the existing image-update-check cycle (reuses the freshly-computed
digest cache, no extra registry calls). Only **running** stacks are
auto-redeployed — a stopped stack is never silently started ("skipped").
- New `stack_auto_updated` notification event. Works for **remote stacks** too
(policy stored centrally; the agent answers `/agent/stacks/{id}/updates` and
performs the redeploy). Last run + status (updated / up-to-date /
update-available / skipped / error) are shown inline.
### Phase 21 — Container terminal (web exec) ### Phase 21 — Container terminal (web exec)
- An **interactive terminal** into any running, compose-managed container, - An **interactive terminal** into any running, compose-managed container,
+9 -1
View File
@@ -83,7 +83,15 @@ root-equivalent).
--- ---
## Phase 22 — Auto-Update (Watchtower-style) ☐ NOT STARTED → target 0.28.0 ## Phase 22 — Auto-Update (Watchtower-style) ☑ DONE — shipped 0.28.0
**Result:** All four orchestration paths live-verified against real compose in
the 0.28.0 backend image (seeding `update_service._CACHE`): redeploy→`updated`
(pull+up, stack stays running), notify-only→`update-available`,
no-update→`up-to-date`, stopped-stack→`skipped`. Routes present (local +
agent + `/agent/stacks/{id}/updates`). Hooked into `update_service.background_loop`
(lazy import, no circular). Remote path reuses the proven `agent_service.call`;
not separately e2e'd against a live agent this round.
Per-stack policy: when an image used by the stack has a newer registry digest, Per-stack policy: when an image used by the stack has a newer registry digest,
either auto pull+redeploy or just notify. Builds on the existing either auto pull+redeploy or just notify. Builds on the existing
+7 -1
View File
@@ -64,7 +64,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.27.0" AGENT_VERSION = "0.28.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -369,6 +369,12 @@ async def stack_logs(stack_id: str, tail: int = Query(200, le=2000)) -> dict:
return {"logs": result.get("stdout", "") + result.get("stderr", "")} return {"logs": result.get("stdout", "") + result.get("stderr", "")}
@app.get("/agent/stacks/{stack_id}/updates", dependencies=[Depends(verify_token)])
async def stack_updates(stack_id: str, refresh: bool = Query(True)) -> dict:
"""Update status for this stack's images (used by central auto-update)."""
return await update_service.stack_updates(stack_id, refresh=refresh)
@app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)]) @app.get("/agent/stacks/{stack_id}/backup", dependencies=[Depends(verify_token)])
async def backup_stack( async def backup_stack(
stack_id: str, stack_id: str,
+1 -1
View File
@@ -56,7 +56,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.27.0", lifespan=lifespan) app = FastAPI(title="StackPilot", version="0.28.0", lifespan=lifespan)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+2 -1
View File
@@ -1,6 +1,7 @@
"""SQLModel table models. Importing this package registers all tables.""" """SQLModel table models. Importing this package registers all tables."""
from models.agent import Agent from models.agent import Agent
from models.audit import AuditLog from models.audit import AuditLog
from models.auto_update import AutoUpdate
from models.backup_destination import BackupDestination from models.backup_destination import BackupDestination
from models.backup_schedule import BackupSchedule from models.backup_schedule import BackupSchedule
from models.setting import Setting, Webhook from models.setting import Setting, Webhook
@@ -10,5 +11,5 @@ from models.user import User
__all__ = [ __all__ = [
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent", "User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
"BackupDestination", "BackupSchedule", "BackupDestination", "BackupSchedule", "AutoUpdate",
] ]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class AutoUpdate(SQLModel, table=True):
"""Per-stack auto-update policy (Watchtower-style).
When the background image-update check finds a newer registry digest for one
of the stack's images, the stack is either pulled + redeployed
(``redeploy=True``) or merely notified about (``redeploy=False``).
``agent_id`` None = local host, otherwise a remote agent's stack.
"""
id: Optional[int] = Field(default=None, primary_key=True)
stack_id: str
agent_id: Optional[int] = None
enabled: bool = True
redeploy: bool = True # True = pull + up -d; False = notify only
last_run: Optional[datetime] = None
last_status: Optional[str] = None # "updated" | "up-to-date" | "update-available" | "skipped" | "error"
last_result: Optional[str] = None # detail (stale images / error text)
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class AutoUpdateWrite(SQLModel):
enabled: bool = True
redeploy: bool = True
class AutoUpdateRead(SQLModel):
id: Optional[int]
stack_id: str
agent_id: Optional[int]
agent_name: Optional[str] = None
enabled: bool
redeploy: bool
last_run: Optional[datetime]
last_status: Optional[str]
last_result: Optional[str]
+2
View File
@@ -17,6 +17,7 @@ EVENT_STACK_STOP = "stack_stop"
EVENT_STACK_ERROR = "stack_error" EVENT_STACK_ERROR = "stack_error"
EVENT_PULL_FAILED = "pull_failed" EVENT_PULL_FAILED = "pull_failed"
EVENT_BACKUP_FAILED = "backup_failed" EVENT_BACKUP_FAILED = "backup_failed"
EVENT_STACK_AUTO_UPDATED = "stack_auto_updated"
ALL_EVENTS = [ ALL_EVENTS = [
EVENT_UPDATE_AVAILABLE, EVENT_UPDATE_AVAILABLE,
@@ -25,6 +26,7 @@ ALL_EVENTS = [
EVENT_STACK_ERROR, EVENT_STACK_ERROR,
EVENT_PULL_FAILED, EVENT_PULL_FAILED,
EVENT_BACKUP_FAILED, EVENT_BACKUP_FAILED,
EVENT_STACK_AUTO_UPDATED,
] ]
WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"] WEBHOOK_TYPES = ["ntfy", "discord", "slack", "gotify", "generic"]
+56
View File
@@ -19,9 +19,11 @@ from models.stack import StackCreate, StackUpdate
from models.user import User from models.user import User
from routers.files import NameBody, RenameBody, TransferBody, WriteBody from routers.files import NameBody, RenameBody, TransferBody, WriteBody
from routers.networks import ContainerRef, NetworkCreate from routers.networks import ContainerRef, NetworkCreate
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
from services import ( from services import (
agent_service, agent_service,
audit_service, audit_service,
auto_update_service,
backup_destination_service as dest_service, backup_destination_service as dest_service,
backup_service, backup_service,
compose_service, compose_service,
@@ -983,3 +985,57 @@ async def agent_container_action(
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request), target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
) )
return result return result
# --------------------------------------------------------------------------- #
# Auto-update policy (Watchtower-style) — remote stacks (stored centrally)
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
def agent_get_auto_update(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
_get_or_404(session, agent_id)
policy = auto_update_service.get_policy(session, stack_id, agent_id)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
@router.put("/{agent_id}/stacks/{stack_id}/auto-update", response_model=AutoUpdateRead)
def agent_set_auto_update(
agent_id: int,
stack_id: str,
body: AutoUpdateWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
policy = auto_update_service.upsert_policy(
session, stack_id, body.enabled, body.redeploy, agent_id=agent_id
)
audit_service.record(
session, user=user.username, action="agent.stack.auto_update",
target=f"{agent.name}/{stack_id}",
detail=f"enabled={body.enabled} redeploy={body.redeploy}", ip=_ip(request),
)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
@router.post("/{agent_id}/stacks/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
async def agent_run_auto_update(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
_get_or_404(session, agent_id)
policy = auto_update_service.get_policy(session, stack_id, agent_id)
if policy is None:
raise HTTPException(status_code=404, detail="No auto-update policy for this stack")
await auto_update_service.run_policy(session, policy)
session.refresh(policy)
return auto_update_service.to_read(session, policy, stack_id, agent_id)
+48 -1
View File
@@ -25,8 +25,9 @@ from models.setting import (
EVENT_STACK_START, EVENT_STACK_START,
EVENT_STACK_STOP, EVENT_STACK_STOP,
) )
from models.auto_update import AutoUpdateRead, AutoUpdateWrite
from models.user import User from models.user import User
from services import audit_service, compose_service, notify_service, stats_service from services import audit_service, auto_update_service, compose_service, notify_service, stats_service
from services.convert_service import convert_docker_run from services.convert_service import convert_docker_run
router = APIRouter(prefix="/api/stacks", tags=["stacks"]) router = APIRouter(prefix="/api/stacks", tags=["stacks"])
@@ -398,3 +399,49 @@ def convert(
return ConvertResponse(yaml=convert_docker_run(body.command)) return ConvertResponse(yaml=convert_docker_run(body.command))
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
# --------------------------------------------------------------------------- #
# Auto-update policy (Watchtower-style) — local stacks
# --------------------------------------------------------------------------- #
@router.get("/{stack_id}/auto-update", response_model=AutoUpdateRead)
def get_auto_update(
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
policy = auto_update_service.get_policy(session, stack_id)
return auto_update_service.to_read(session, policy, stack_id)
@router.put("/{stack_id}/auto-update", response_model=AutoUpdateRead)
def set_auto_update(
stack_id: str,
body: AutoUpdateWrite,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
policy = auto_update_service.upsert_policy(session, stack_id, body.enabled, body.redeploy)
audit_service.record(
session, user=user.username, action="stack.auto_update",
target=stack_id, detail=f"enabled={body.enabled} redeploy={body.redeploy}",
ip=_client_ip(request),
)
return auto_update_service.to_read(session, policy, stack_id)
@router.post("/{stack_id}/auto-update/run", response_model=AutoUpdateRead)
async def run_auto_update(
stack_id: str,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
policy = auto_update_service.get_policy(session, stack_id)
if policy is None:
raise HTTPException(status_code=404, detail="No auto-update policy for this stack")
await auto_update_service.run_policy(session, policy)
session.refresh(policy)
return auto_update_service.to_read(session, policy, stack_id)
+194
View File
@@ -0,0 +1,194 @@
"""Auto-update (Watchtower-style) orchestration.
Runs once per image-update-check cycle (called from
``update_service.background_loop`` so it reuses the freshly-populated digest
cache). For each enabled policy whose stack has a newer image available, either
pulls + redeploys the stack or just notifies, recording the outcome.
Central-only / DB-aware. Image resolution + digest comparison live in the
DB-free ``update_service`` so the agent can answer ``/agent/stacks/{id}/updates``
with the same logic.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from sqlmodel import Session, select
from database import engine
from models.agent import Agent
from models.auto_update import AutoUpdate
from models.setting import EVENT_PULL_FAILED, EVENT_STACK_AUTO_UPDATED
from services import agent_service, compose_service, notify_service, update_service
logger = logging.getLogger("stackpilot.autoupdate")
# Stack states we'll act on. We never auto-start a stopped stack.
_LIVE_STATES = {"running", "partial", "updating"}
def _now() -> datetime:
return datetime.now(timezone.utc)
# --------------------------------------------------------------------------- #
# Policy CRUD helpers (shared by the stacks + agents routers)
# --------------------------------------------------------------------------- #
def get_policy(session: Session, stack_id: str, agent_id: int | None = None) -> AutoUpdate | None:
stmt = select(AutoUpdate).where(AutoUpdate.stack_id == stack_id)
stmt = stmt.where(AutoUpdate.agent_id == agent_id) if agent_id is not None \
else stmt.where(AutoUpdate.agent_id.is_(None))
return session.exec(stmt).first()
def upsert_policy(
session: Session, stack_id: str, enabled: bool, redeploy: bool, agent_id: int | None = None
) -> AutoUpdate:
policy = get_policy(session, stack_id, agent_id)
if policy is None:
policy = AutoUpdate(stack_id=stack_id, agent_id=agent_id)
policy.enabled = enabled
policy.redeploy = redeploy
session.add(policy)
session.commit()
session.refresh(policy)
return policy
def to_read(session: Session, policy: AutoUpdate | None, stack_id: str, agent_id: int | None = None) -> dict:
"""Build an AutoUpdateRead-shaped dict, defaulting to disabled when absent."""
agent_name = None
if agent_id is not None:
agent = session.get(Agent, agent_id)
agent_name = agent.name if agent else None
if policy is None:
return {
"id": None, "stack_id": stack_id, "agent_id": agent_id, "agent_name": agent_name,
"enabled": False, "redeploy": True,
"last_run": None, "last_status": None, "last_result": None,
}
return {
"id": policy.id, "stack_id": policy.stack_id, "agent_id": policy.agent_id,
"agent_name": agent_name, "enabled": policy.enabled, "redeploy": policy.redeploy,
"last_run": policy.last_run, "last_status": policy.last_status, "last_result": policy.last_result,
}
def _record(session: Session, policy: AutoUpdate, status: str, result: str = "") -> None:
policy.last_run = _now()
policy.last_status = status
policy.last_result = result[:300] if result else None
session.add(policy)
session.commit()
async def _run_local(session: Session, policy: AutoUpdate) -> None:
stack_id = policy.stack_id
try:
state = compose_service.compute_status(stack_id)
except Exception: # noqa: BLE001
state = "unknown"
if state not in _LIVE_STATES:
_record(session, policy, "skipped", f"stack not running ({state})")
return
summary = await update_service.stack_updates(stack_id, refresh=False)
if not summary["update_available"]:
_record(session, policy, "up-to-date")
return
stale = ", ".join(summary["stale_images"])
prev = policy.last_status
if policy.redeploy:
try:
await compose_service.pull(stack_id)
await compose_service.up(stack_id)
except Exception as exc: # noqa: BLE001
_record(session, policy, "error", str(exc))
await _safe_notify(EVENT_PULL_FAILED, f"Auto-update of '{stack_id}' failed", str(exc), session)
return
_record(session, policy, "updated", stale)
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Stack '{stack_id}' auto-updated",
f"Pulled and redeployed: {stale}.", session,
)
else:
_record(session, policy, "update-available", stale)
if prev != "update-available": # notify once per transition, not every cycle
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Update available for '{stack_id}'",
f"Newer images: {stale} (auto-redeploy is off).", session,
)
async def _run_remote(session: Session, policy: AutoUpdate) -> None:
agent = session.get(Agent, policy.agent_id)
if not agent:
_record(session, policy, "error", "agent not found")
return
stack_id = policy.stack_id
try:
summary = await agent_service.call(
session, agent, "GET", f"/agent/stacks/{stack_id}/updates",
params={"refresh": "true"},
)
except Exception as exc: # noqa: BLE001
_record(session, policy, "error", f"agent check failed: {exc}")
return
if not summary or not summary.get("update_available"):
_record(session, policy, "up-to-date")
return
stale = ", ".join(summary.get("stale_images", []))
label = f"{agent.name}/{stack_id}"
prev = policy.last_status
if policy.redeploy:
try:
await agent_service.call(session, agent, "POST", f"/agent/stacks/{stack_id}/update")
except Exception as exc: # noqa: BLE001
_record(session, policy, "error", str(exc))
await _safe_notify(EVENT_PULL_FAILED, f"Auto-update of '{label}' failed", str(exc), session)
return
_record(session, policy, "updated", stale)
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Stack '{label}' auto-updated",
f"Pulled and redeployed: {stale}.", session,
)
else:
_record(session, policy, "update-available", stale)
if prev != "update-available":
await _safe_notify(
EVENT_STACK_AUTO_UPDATED, f"Update available for '{label}'",
f"Newer images: {stale} (auto-redeploy is off).", session,
)
async def _safe_notify(event: str, title: str, message: str, session: Session) -> None:
try:
await notify_service.notify(event, title, message, session)
except Exception as exc: # noqa: BLE001 - notifications are best-effort
logger.debug("auto-update notify failed: %s", exc)
async def run_policy(session: Session, policy: AutoUpdate) -> None:
if policy.agent_id is None:
await _run_local(session, policy)
else:
await _run_remote(session, policy)
async def run_due() -> None:
"""Process every enabled policy. Best-effort: one failure never aborts the rest."""
with Session(engine) as session:
policies = list(session.exec(select(AutoUpdate).where(AutoUpdate.enabled == True))) # noqa: E712
for policy in policies:
try:
with Session(engine) as session:
fresh = session.get(AutoUpdate, policy.id)
if fresh and fresh.enabled:
await run_policy(session, fresh)
except Exception as exc: # noqa: BLE001
logger.warning("Auto-update policy %s failed: %s", policy.id, exc)
+47
View File
@@ -198,6 +198,45 @@ def _all_running_images() -> set[str]:
return images return images
def stack_images(stack_id: str) -> set[str]:
"""Images used by the containers of one compose project (== stack id)."""
images: set[str] = set()
try:
client = get_client()
for c in safe_call(client.containers.list, all=True):
labels = c.labels or {}
if labels.get("com.docker.compose.project") != stack_id:
continue
cfg_image = c.attrs.get("Config", {}).get("Image")
if cfg_image:
images.add(cfg_image)
except DockerError:
pass
return images
async def stack_updates(stack_id: str, refresh: bool = True) -> dict:
"""Update status for one stack's images.
``refresh=True`` queries the registry now; ``False`` reads the cache the
background loop already populated (so the auto-update pass adds no extra
registry round-trips). DB-free, so the agent can reuse it verbatim.
"""
images = stack_images(stack_id)
result: dict[str, dict] = {}
for image in images:
status = await check_image(image) if refresh else _CACHE.get(image)
if status is not None:
result[image] = status.to_dict()
stale = [img for img, st in result.items() if st.get("update_available")]
return {
"stack_id": stack_id,
"update_available": bool(stale),
"stale_images": stale,
"images": result,
}
async def check_all() -> dict[str, dict]: async def check_all() -> dict[str, dict]:
images = _all_running_images() images = _all_running_images()
for image in images: for image in images:
@@ -218,6 +257,14 @@ async def background_loop():
logger.info("Image update check complete (%d images)", len(_CACHE)) logger.info("Image update check complete (%d images)", len(_CACHE))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Image update check failed: %s", exc) logger.warning("Image update check failed: %s", exc)
# Apply auto-update policies using the digest cache we just refreshed.
# Lazy import avoids a circular import (auto_update_service imports us).
try:
from services import auto_update_service
await auto_update_service.run_due()
except Exception as exc: # noqa: BLE001
logger.warning("Auto-update pass failed: %s", exc)
# Re-read the interval each cycle so Settings changes take effect. # Re-read the interval each cycle so Settings changes take effect.
interval = max(settings_service.get_update_interval(), 5) * 60 interval = max(settings_service.get_update_interval(), 5) * 60
await asyncio.sleep(interval) await asyncio.sleep(interval)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.27.0", "version": "0.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+28
View File
@@ -0,0 +1,28 @@
import api from "./client";
export interface AutoUpdatePolicy {
id: number | null;
stack_id: string;
agent_id: number | null;
agent_name: string | null;
enabled: boolean;
redeploy: boolean;
last_run: string | null;
last_status: string | null;
last_result: string | null;
}
// Local host, or a remote agent's stack when agentId is given.
const base = (stackId: string, agentId?: number) =>
agentId != null
? `/api/agents/${agentId}/stacks/${stackId}/auto-update`
: `/api/stacks/${stackId}/auto-update`;
export const autoUpdateApi = {
get: (stackId: string, agentId?: number) =>
api.get<AutoUpdatePolicy>(base(stackId, agentId)).then((r) => r.data),
set: (stackId: string, body: { enabled: boolean; redeploy: boolean }, agentId?: number) =>
api.put<AutoUpdatePolicy>(base(stackId, agentId), body).then((r) => r.data),
run: (stackId: string, agentId?: number) =>
api.post<AutoUpdatePolicy>(`${base(stackId, agentId)}/run`).then((r) => r.data),
};
@@ -0,0 +1,124 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { RefreshCw, ArrowUpCircle } from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card } from "@/components/ui";
import { autoUpdateApi, type AutoUpdatePolicy } from "@/api/autoUpdate";
import { apiErrorMessage } from "@/api/client";
const STATUS_TONE: Record<string, string> = {
updated: "text-green-500",
"up-to-date": "text-slate-500",
"update-available": "text-amber-500",
skipped: "text-slate-500",
error: "text-red-500",
};
/**
* 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
* 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 { data, isLoading } = useQuery({
queryKey: key,
queryFn: () => autoUpdateApi.get(stackId, agentId),
});
const save = useMutation({
mutationFn: (body: { enabled: boolean; redeploy: boolean }) =>
autoUpdateApi.set(stackId, body, agentId),
onSuccess: (p) => qc.setQueryData(key, p),
onError: (e) => toast.error(apiErrorMessage(e)),
});
const runNow = useMutation({
mutationFn: () => autoUpdateApi.run(stackId, agentId),
onSuccess: (p) => {
qc.setQueryData(key, p);
toast.success(`Auto-update: ${p.last_status ?? "done"}`);
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
if (isLoading || !data) return null;
const p: AutoUpdatePolicy = data;
return (
<Card className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ArrowUpCircle className="h-4 w-4 text-accent dark:text-accent-dark" />
<span className="font-medium">Auto-update</span>
{p.enabled ? (
<Badge>{p.redeploy ? "pull + redeploy" : "notify only"}</Badge>
) : (
<span className="text-xs text-slate-500">off</span>
)}
</div>
{isAdmin && (
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={p.enabled}
disabled={save.isPending}
onChange={(e) => save.mutate({ enabled: e.target.checked, redeploy: p.redeploy })}
/>
Enabled
</label>
)}
</div>
{p.enabled && isAdmin && (
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
checked={p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: true })}
/>
Pull &amp; redeploy
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={`mode-${agentId ?? "l"}-${stackId}`}
checked={!p.redeploy}
onChange={() => save.mutate({ enabled: true, redeploy: false })}
/>
Notify only
</label>
<Button
variant="outline"
className="ml-auto"
onClick={() => runNow.mutate()}
loading={runNow.isPending}
>
<RefreshCw className="h-4 w-4" /> Check now
</Button>
</div>
)}
{p.last_run && (
<p className="text-xs text-slate-500">
Last run {new Date(p.last_run).toLocaleString()} {" "}
<span className={STATUS_TONE[p.last_status ?? ""] ?? "text-slate-500"}>
{p.last_status}
</span>
{p.last_result ? ` (${p.last_result})` : ""}
</p>
)}
</Card>
);
}
+5
View File
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot"; import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer"; import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard"; import { ContainerCard } from "@/components/stacks/ContainerCard";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { BackupButton } from "@/components/stacks/BackupRestore"; import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
@@ -132,6 +133,7 @@ export function RemoteStackDetail() {
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{tab === "Overview" && ( {tab === "Overview" && (
<Overview <Overview
stackId={id}
containers={data.containers} containers={data.containers}
host={agentHost} host={agentHost}
agentId={aid} agentId={aid}
@@ -170,12 +172,14 @@ export function RemoteStackDetail() {
} }
function Overview({ function Overview({
stackId,
containers, containers,
host, host,
agentId, agentId,
isAdmin, isAdmin,
onChanged, onChanged,
}: { }: {
stackId: string;
containers: ContainerInfo[]; containers: ContainerInfo[];
host?: string; host?: string;
agentId: number; agentId: number;
@@ -184,6 +188,7 @@ function Overview({
}) { }) {
return ( return (
<div className="space-y-2 overflow-auto"> <div className="space-y-2 overflow-auto">
<AutoUpdatePanel stackId={stackId} agentId={agentId} isAdmin={isAdmin} />
{containers.length === 0 && ( {containers.length === 0 && (
<Card> <Card>
<p className="text-sm text-slate-500">No containers running.</p> <p className="text-sm text-slate-500">No containers running.</p>
+2
View File
@@ -686,6 +686,8 @@ const EVENT_LABELS: Record<string, string> = {
stack_stop: "Stack stopped", stack_stop: "Stack stopped",
stack_error: "Stack error", stack_error: "Stack error",
pull_failed: "Pull/update failed", pull_failed: "Pull/update failed",
backup_failed: "Backup failed",
stack_auto_updated: "Stack auto-updated",
}; };
function NotificationsSection() { function NotificationsSection() {
+2
View File
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer"; import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard"; import { ContainerCard } from "@/components/stacks/ContainerCard";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { BackupButton } from "@/components/stacks/BackupRestore"; import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
@@ -130,6 +131,7 @@ function Overview({
}) { }) {
return ( return (
<div className="space-y-2 overflow-auto"> <div className="space-y-2 overflow-auto">
<AutoUpdatePanel stackId={data.id} isAdmin={isAdmin} />
{data.containers.length === 0 && ( {data.containers.length === 0 && (
<Card> <Card>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">