From 3fec24dc2bb0db0f7c993df88b6828454ed56cfd Mon Sep 17 00:00:00 2001 From: m4 Date: Wed, 12 Aug 2026 17:16:39 +0800 Subject: [PATCH] feat(update): update banner and one-click upgrade/rollback in version badge --- src/app/[locale]/page.tsx | 3 + src/app/components/UpdateBanner.tsx | 101 +++++++++ src/app/components/VersionBadge.tsx | 324 ++++++++++++++++++---------- src/lib/updatePrompt.test.ts | 46 ++++ src/lib/updatePrompt.ts | 38 ++++ 5 files changed, 404 insertions(+), 108 deletions(-) create mode 100644 src/app/components/UpdateBanner.tsx create mode 100644 src/lib/updatePrompt.test.ts create mode 100644 src/lib/updatePrompt.ts diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 60d5337..e518b28 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -49,6 +49,7 @@ import { MemoryPanel } from "@/app/components/MemoryPanel"; import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel"; import { ThemeToggle } from "@/app/components/ThemeToggle"; import { VersionBadge } from "@/app/components/VersionBadge"; +import { UpdateBanner } from "@/app/components/UpdateBanner"; import { HealthIndicator } from "@/app/components/HealthIndicator"; import { InspectorPanel } from "@/app/components/InspectorPanel"; import { setThreadAutoApprove } from "@/lib/autoApprove"; @@ -481,6 +482,8 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) { + +
{sidebar && isDesktopLayout === false && (
diff --git a/src/app/components/UpdateBanner.tsx b/src/app/components/UpdateBanner.tsx new file mode 100644 index 0000000..ba153d0 --- /dev/null +++ b/src/app/components/UpdateBanner.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import useSWR from "swr"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { SystemVersionInfo } from "@/app/api/system/version/route"; +import { + closeForSession, + dismissVersion, + isDismissed, + isSessionClosed, + loadDismissed, +} from "@/lib/updatePrompt"; + +async function fetchVersion(url: string): Promise { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`version check failed (${res.status})`); + return (await res.json()) as SystemVersionInfo; +} + +export function UpdateBanner() { + const [, forceRender] = useState(0); + const backend = useSWR("/api/system/version", fetchVersion, { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + const frontend = useSWR("/api/system/self-version", fetchVersion, { + revalidateOnFocus: false, + shouldRetryOnError: false, + }); + + if (typeof window === "undefined") return null; + if (isSessionClosed(sessionStorage)) return null; + const dismissed = loadDismissed(localStorage); + + const rows: { key: "backend" | "frontend"; label: string; info: SystemVersionInfo }[] = []; + if ( + backend.data?.has_update && + !isDismissed(dismissed, "backend", backend.data.latest_version) + ) { + rows.push({ key: "backend", label: "Backend", info: backend.data }); + } + if ( + frontend.data?.has_update && + !isDismissed(dismissed, "frontend", frontend.data.latest_version) + ) { + rows.push({ key: "frontend", label: "Web UI", info: frontend.data }); + } + if (rows.length === 0) return null; + + return ( +
+
+ {rows.map((r) => ( +

+ {r.label} v{r.info.current_version} →{" "} + v{r.info.latest_version} available.{" "} + {r.info.release_url && ( + + Release notes + + )} +

+ ))} +

+ Upgrading briefly interrupts the service and disconnects all connected sessions. +

+
+ + + +
+ ); +} diff --git a/src/app/components/VersionBadge.tsx b/src/app/components/VersionBadge.tsx index ef23f66..97d7811 100644 --- a/src/app/components/VersionBadge.tsx +++ b/src/app/components/VersionBadge.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import useSWR from "swr"; -import { Copy, Download, ExternalLink, RefreshCw, Tag } from "lucide-react"; +import { ExternalLink, RefreshCw, RotateCcw, Tag, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -11,7 +11,7 @@ import { } from "@/components/ui/dropdown-menu"; import { errorToast } from "@/lib/errorReporter"; import type { SystemVersionInfo } from "@/app/api/system/version/route"; -import type { SystemVersionDownloadResult } from "@/app/api/system/version/download/route"; +import type { RollbackVersionsResult } from "@/app/api/system/rollback-versions/route"; async function fetchVersion(url: string): Promise { const res = await fetch(url, { cache: "no-store" }); @@ -26,23 +26,123 @@ async function fetchVersion(url: string): Promise { return (await res.json()) as SystemVersionInfo; } +type UpdateTargetKey = "backend" | "frontend"; + export function VersionBadge() { const [open, setOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); - const [downloading, setDownloading] = useState(false); - const [downloaded, setDownloaded] = - useState(null); + const [updating, setUpdating] = useState(false); + const [updateStatus, setUpdateStatus] = useState(null); + const [rollbackVersions, setRollbackVersions] = useState< + RollbackVersionsResult["versions"] | null + >(null); const { data, error, mutate } = useSWR( "/api/system/version", fetchVersion, { revalidateOnFocus: false, shouldRetryOnError: false } ); + const { data: selfData, mutate: mutateSelf } = useSWR( + "/api/system/self-version", + fetchVersion, + { revalidateOnFocus: false, shouldRetryOnError: false } + ); + + const pollStatus = () => { + let failures = 0; + const timer = setInterval(async () => { + try { + const res = await fetch("/api/system/update/status", { cache: "no-store" }); + if (!res.ok) throw new Error(String(res.status)); + const body = await res.json(); + failures = 0; + if (body.status === "success") { + clearInterval(timer); + setUpdateStatus("success — please reload the page"); + } else if (body.status === "failed" || body.status === "installed_restart_failed") { + clearInterval(timer); + setUpdateStatus(`failed: ${body.message}`); + } + } catch { + // The service being upgraded is restarting; failed polls are expected. + failures += 1; + if (failures > 36) { + // ~3 minutes at a 5s interval + clearInterval(timer); + setUpdateStatus("not recovering — check the terminal logs"); + } + } + }, 5000); + }; + + const startApply = async (url: string) => { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const body = await res.json().catch(() => null); + if (res.status === 202) { + setUpdateStatus("applying"); + pollStatus(); + return; + } + if (body?.code === "BREAKING_DB_CONFIRM_REQUIRED") { + if (window.confirm("This update contains breaking DB changes. Continue?")) { + await startApply(`${url}?confirm_breaking=true`); + } + return; + } + if (res.status === 200 && body?.status === "manual") { + setUpdateStatus(body.guidance ?? "manual update required"); + return; + } + throw new Error(body?.message ?? body?.code ?? `Update failed (${res.status}).`); + }; + + const handleUpgrade = async (target: UpdateTargetKey) => { + setUpdating(true); + setUpdateStatus(null); + try { + await startApply(target === "backend" ? "/api/system/update" : "/api/system/self-update"); + } catch (e) { + errorToast("version.update", e instanceof Error ? e.message : "Couldn't start the update."); + } finally { + setUpdating(false); + } + }; + + const handleRollback = async (version: string) => { + setUpdating(true); + setUpdateStatus(null); + try { + const res = await fetch("/api/system/update/rollback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version }), + }); + const body = await res.json().catch(() => null); + if (res.status === 202) { + setUpdateStatus("applying"); + pollStatus(); + return; + } + throw new Error(body?.message ?? body?.code ?? `Rollback failed (${res.status}).`); + } catch (e) { + errorToast("version.rollback", e instanceof Error ? e.message : "Couldn't start the rollback."); + } finally { + setUpdating(false); + } + }; const handleRefresh = async () => { setRefreshing(true); try { - const info = await fetchVersion("/api/system/version?force=true"); + const [info, selfInfo] = await Promise.all([ + fetchVersion("/api/system/version?force=true"), + fetchVersion("/api/system/self-version?force=true"), + ]); await mutate(info, { revalidate: false }); + await mutateSelf(selfInfo, { revalidate: false }); } catch (e) { errorToast( "version.refresh", @@ -53,41 +153,28 @@ export function VersionBadge() { } }; - const handleDownload = async () => { - setDownloading(true); + const loadRollbackVersions = async () => { try { - const res = await fetch("/api/system/version/download", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - const body = await res.json().catch(() => null); - if (!res.ok) { - throw new Error( - body && typeof body.message === "string" - ? body.message - : body && typeof body.error === "string" - ? body.error - : `Download failed (${res.status}).` - ); - } - setDownloaded(body as SystemVersionDownloadResult); - } catch (e) { - errorToast( - "version.download", - e instanceof Error ? e.message : "Couldn't download the update." - ); - } finally { - setDownloading(false); + const res = await fetch("/api/system/rollback-versions", { cache: "no-store" }); + if (!res.ok) return; + const body = (await res.json()) as RollbackVersionsResult; + setRollbackVersions(body.versions); + } catch { + // The rollback list is best-effort; the upgrade path stays available. } }; const handleOpenChange = (next: boolean) => { setOpen(next); - if (!next) setDownloaded(null); + if (next) { + setUpdateStatus(null); + setRollbackVersions(null); + if (data?.has_update) void loadRollbackVersions(); + } }; const hasUpdate = data?.has_update === true; + const selfHasUpdate = selfData?.has_update === true; return ( @@ -96,16 +183,17 @@ export function VersionBadge() { variant="ghost" size="sm" aria-label="Version" + id="version" title={data ? `EvoScientist v${data.current_version}` : "Version"} className={`relative h-8 gap-1 px-2 text-xs ${ - hasUpdate + hasUpdate || selfHasUpdate ? "bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400" : "text-muted-foreground" }`} >