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:
co-authored by
Claude Fable 5
parent
11effdc2ca
commit
a0dda120f5
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user