0.32.0: StackPilot self-update (check on page load + one-click update)

- backend/version.py is now the single version source (main.py, agent).
- GET /api/system/update: reads the version tags of the backend's own image
  repo (anonymous v2 token flow, https→http fallback for insecure
  registries), compares the highest semver tag against APP_VERSION; reports
  update_supported from the container's compose labels. 10 min cache.
- POST /api/system/update (admin, audited): spawns a detached helper
  container from the current backend image that runs docker compose pull &&
  up -d on StackPilot's own compose project (project name, working dir and
  config files resolved from its own container labels) — the helper
  outlives the backend being recreated. Non-compose installs get a 400.
- /api/health now returns the version so the UI can detect the switchover.
- TopNav version badge: queries the update status on page load; when a
  newer release exists an amber pill shows the version — one click (admin)
  confirms, triggers the update and overlays a wait screen that polls
  /api/health and reloads once the new version answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 09:01:08 +00:00
co-authored by Claude Fable 5
parent 11effdc2ca
commit a0dda120f5
10 changed files with 407 additions and 10 deletions
+2 -3
View File
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents";
import { VersionBadge } from "./VersionBadge";
export const NAV_ITEMS = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
@@ -145,9 +146,7 @@ export function TopNav() {
{/* Right cluster */}
<div className="ml-auto flex shrink-0 items-center gap-2 lg:ml-0">
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
v{__APP_VERSION__}
</span>
<VersionBadge />
{agentCount > 0 && (
<button
onClick={() => navigate("/settings")}
@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpCircle } from "lucide-react";
import { toast } from "sonner";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { systemApi } from "@/api/system";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
const POLL_MS = 3000;
const TIMEOUT_MS = 4 * 60 * 1000;
/** Version pill in the top bar. Checks the registry for a newer StackPilot
* release on page load and offers a one-click in-place update (admins). */
export function VersionBadge() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [confirming, setConfirming] = useState(false);
const [updating, setUpdating] = useState(false);
const [busy, setBusy] = useState(false);
const { data } = useQuery({
queryKey: ["self-update"],
queryFn: () => systemApi.selfUpdate(),
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
const startUpdate = async () => {
setBusy(true);
try {
await systemApi.applySelfUpdate();
setConfirming(false);
setUpdating(true);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
const updateAvailable = data?.update_available ?? false;
const canClick = isAdmin && updateAvailable && (data?.update_supported ?? false);
return (
<>
<span className="sp-label hidden rounded-pill border border-sp-border bg-sp-surface px-2.5 py-1 sm:inline">
v{__APP_VERSION__}
</span>
{updateAvailable && (
<button
onClick={() => canClick && setConfirming(true)}
disabled={!canClick}
className="hidden items-center gap-1.5 rounded-pill border border-sp-amber/40 bg-sp-amber/10 px-2.5 py-1 text-xs font-semibold text-sp-amber hover:bg-sp-amber/20 disabled:cursor-default sm:flex"
title={
canClick
? `Update StackPilot to ${data?.latest_version}`
: data?.update_supported
? "A newer StackPilot is available (ask an admin to update)"
: "A newer StackPilot is available — this install isn't compose-managed, update it manually"
}
>
<ArrowUpCircle className="h-3.5 w-3.5" />
{data?.latest_version ?? "update"}
</button>
)}
{confirming && (
<ConfirmDialog
title={`Update StackPilot to ${data?.latest_version}?`}
message="Pulls the new images and recreates the StackPilot containers in place. The UI will be briefly unavailable and reloads automatically."
confirmLabel="Update now"
busy={busy}
onConfirm={startUpdate}
onCancel={() => setConfirming(false)}
/>
)}
{updating && <UpdatingOverlay fromVersion={data?.current_version ?? __APP_VERSION__} />}
</>
);
}
/** Full-screen wait state while the helper recreates the containers:
* polls /api/health until a different version answers, then reloads. */
function UpdatingOverlay({ fromVersion }: { fromVersion: string }) {
const [failed, setFailed] = useState(false);
const started = useRef(Date.now());
useEffect(() => {
const timer = setInterval(async () => {
if (Date.now() - started.current > TIMEOUT_MS) {
clearInterval(timer);
setFailed(true);
return;
}
try {
// Raw fetch: no auth/interceptors, and the backend may be mid-restart.
const res = await fetch("/api/health", { cache: "no-store" });
if (!res.ok) return;
const body = (await res.json()) as { version?: string };
if (body.version && body.version !== fromVersion) {
clearInterval(timer);
window.location.reload();
}
} catch {
/* backend restarting — keep polling */
}
}, POLL_MS);
return () => clearInterval(timer);
}, [fromVersion]);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-sm rounded-xl border border-slate-200 bg-card p-6 text-center shadow-xl dark:border-slate-700 dark:bg-card-dark">
{failed ? (
<>
<p className="text-sm font-semibold">Still on v{fromVersion}</p>
<p className="mt-2 text-sm text-slate-500">
The update didn&apos;t finish within a few minutes. Check the host with{" "}
<code className="font-mono text-xs">docker ps</code> / the compose logs, then reload.
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded-pill border border-sp-border px-4 py-1.5 text-sm font-medium"
>
Reload
</button>
</>
) : (
<>
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-sp-border border-t-transparent" />
<p className="mt-4 text-sm font-semibold">Updating StackPilot</p>
<p className="mt-1 text-sm text-slate-500">
Pulling images and recreating containers. This page reloads automatically.
</p>
</>
)}
</div>
</div>
);
}