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
@@ -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>
);
}