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:
co-authored by
Claude Opus 4.8
parent
be3568274f
commit
255c8441c6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -132,6 +133,7 @@ export function RemoteStackDetail() {
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tab === "Overview" && (
|
||||
<Overview
|
||||
stackId={id}
|
||||
containers={data.containers}
|
||||
host={agentHost}
|
||||
agentId={aid}
|
||||
@@ -170,12 +172,14 @@ export function RemoteStackDetail() {
|
||||
}
|
||||
|
||||
function Overview({
|
||||
stackId,
|
||||
containers,
|
||||
host,
|
||||
agentId,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
stackId: string;
|
||||
containers: ContainerInfo[];
|
||||
host?: string;
|
||||
agentId: number;
|
||||
@@ -184,6 +188,7 @@ function Overview({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 overflow-auto">
|
||||
<AutoUpdatePanel stackId={stackId} agentId={agentId} isAdmin={isAdmin} />
|
||||
{containers.length === 0 && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">No containers running.</p>
|
||||
|
||||
@@ -686,6 +686,8 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
stack_stop: "Stack stopped",
|
||||
stack_error: "Stack error",
|
||||
pull_failed: "Pull/update failed",
|
||||
backup_failed: "Backup failed",
|
||||
stack_auto_updated: "Stack auto-updated",
|
||||
};
|
||||
|
||||
function NotificationsSection() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -130,6 +131,7 @@ function Overview({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 overflow-auto">
|
||||
<AutoUpdatePanel stackId={data.id} isAdmin={isAdmin} />
|
||||
{data.containers.length === 0 && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
|
||||
Reference in New Issue
Block a user