feat(update): update banner and one-click upgrade/rollback in version badge
CI / macos-latest / Node 20 (push) Has been cancelled
CI / ubuntu-latest / Node 20 (push) Has been cancelled
CI / windows-latest / Node 20 (push) Has been cancelled

This commit is contained in:
m4
2026-08-12 17:16:39 +08:00
parent 046aab4247
commit 3fec24dc2b
5 changed files with 404 additions and 108 deletions
+3
View File
@@ -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) {
</div>
</header>
<UpdateBanner />
<div className="relative flex-1 overflow-hidden">
{sidebar && isDesktopLayout === false && (
<div className="absolute inset-0 z-40 flex md:hidden">
+101
View File
@@ -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<SystemVersionInfo> {
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<SystemVersionInfo>("/api/system/version", fetchVersion, {
revalidateOnFocus: false,
shouldRetryOnError: false,
});
const frontend = useSWR<SystemVersionInfo>("/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 (
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm">
<div className="flex-1 space-y-1">
{rows.map((r) => (
<p key={r.key}>
{r.label} <span className="font-mono">v{r.info.current_version}</span> →{" "}
<span className="font-mono font-medium">v{r.info.latest_version}</span> available.{" "}
{r.info.release_url && (
<a
className="underline"
href={r.info.release_url}
target="_blank"
rel="noreferrer"
>
Release notes
</a>
)}
</p>
))}
<p className="text-xs text-muted-foreground">
Upgrading briefly interrupts the service and disconnects all connected sessions.
</p>
</div>
<Button variant="outline" size="sm" asChild>
<a href="#version">Review &amp; upgrade</a>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
for (const r of rows) dismissVersion(localStorage, r.key, r.info.latest_version);
forceRender((n) => n + 1);
}}
>
Ignore
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Close"
onClick={() => {
closeForSession(sessionStorage);
forceRender((n) => n + 1);
}}
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
);
}
+216 -108
View File
@@ -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<SystemVersionInfo> {
const res = await fetch(url, { cache: "no-store" });
@@ -26,23 +26,123 @@ async function fetchVersion(url: string): Promise<SystemVersionInfo> {
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<SystemVersionDownloadResult | null>(null);
const [updating, setUpdating] = useState(false);
const [updateStatus, setUpdateStatus] = useState<string | null>(null);
const [rollbackVersions, setRollbackVersions] = useState<
RollbackVersionsResult["versions"] | null
>(null);
const { data, error, mutate } = useSWR<SystemVersionInfo>(
"/api/system/version",
fetchVersion,
{ revalidateOnFocus: false, shouldRetryOnError: false }
);
const { data: selfData, mutate: mutateSelf } = useSWR<SystemVersionInfo>(
"/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 (
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
@@ -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"
}`}
>
<Tag className="size-3.5" aria-hidden="true" />
{data ? `v${data.current_version}` : "…"}
{hasUpdate && (
{(hasUpdate || selfHasUpdate) && (
<span className="absolute -right-0.5 -top-0.5 flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
@@ -114,88 +202,108 @@ export function VersionBadge() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
Version
</div>
<div className="border-b border-border px-3 py-2 text-sm font-medium">Version</div>
{error && !data ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Couldn&apos;t load version info.
</p>
) : !data ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading...
</p>
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
) : (
<div className="space-y-2 px-3 py-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Installed</span>
<span className="font-mono">v{data.current_version}</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Latest</span>
<span
className={`font-mono ${
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
}`}
>
v{data.latest_version}
</span>
</div>
{hasUpdate && (
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
A new version of EvoScientist is available.
<div className="space-y-3 px-3 py-3 text-sm">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Backend
</p>
)}
{data.warning && (
<p className="text-xs text-muted-foreground">
Last check failed: {data.warning}
</p>
)}
{hasUpdate && !downloaded && (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={downloading}
onClick={() => void handleDownload()}
>
<Download
className={`mr-1 size-3.5 ${downloading ? "animate-bounce" : ""}`}
aria-hidden="true"
/>
{downloading
? "Downloading..."
: `Download v${data.latest_version}`}
</Button>
)}
{downloaded && (
<div className="rounded-md border border-border p-2 text-xs">
<p className="break-all font-mono text-muted-foreground">
{downloaded.path}
</p>
<div className="mt-1 flex items-start gap-1">
<code className="flex-1 break-all rounded bg-muted px-1.5 py-1">
{downloaded.suggested_command}
</code>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
aria-label="Copy install command"
title="Copy install command"
onClick={() =>
void navigator.clipboard.writeText(
downloaded.suggested_command
)
}
>
<Copy className="size-3.5" aria-hidden="true" />
</Button>
</div>
<p className="mt-1 text-muted-foreground">
Run this on the backend host, then restart it.
</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Installed</span>
<span className="font-mono">v{data.current_version}</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Latest</span>
<span
className={`font-mono ${
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
}`}
>
v{data.latest_version}
</span>
</div>
{data.warning && (
<p className="text-xs text-muted-foreground">
Last check failed: {data.warning}
</p>
)}
{hasUpdate && (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={updating}
onClick={() => void handleUpgrade("backend")}
>
<Upload className="mr-1 size-3.5" aria-hidden="true" />
{updating ? "Starting..." : `Upgrade to v${data.latest_version}`}
</Button>
)}
{rollbackVersions && rollbackVersions.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<RotateCcw className="size-3 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground">Roll back:</span>
{rollbackVersions.map((v) => (
<Button
key={v.version}
variant="ghost"
size="sm"
className="h-6 px-1.5 font-mono text-xs"
disabled={updating}
onClick={() => void handleRollback(v.version)}
>
v{v.version}
</Button>
))}
</div>
)}
</div>
{selfData && (
<div className="space-y-2 border-t border-border pt-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Web UI
</p>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Installed</span>
<span className="font-mono">v{selfData.current_version}</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Latest</span>
<span
className={`font-mono ${
selfHasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
}`}
>
v{selfData.latest_version}
</span>
</div>
{selfHasUpdate && (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={updating}
onClick={() => void handleUpgrade("frontend")}
>
<Upload className="mr-1 size-3.5" aria-hidden="true" />
{updating ? "Starting..." : `Upgrade Web UI to v${selfData.latest_version}`}
</Button>
)}
</div>
)}
{updateStatus && (
<p className="rounded-md bg-muted px-2 py-1.5 text-xs text-muted-foreground">
{updateStatus === "applying"
? "Updating — the service will briefly interrupt; this page may lose connection."
: updateStatus}
</p>
)}
</div>
)}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
closeForSession,
dismissVersion,
isDismissed,
isSessionClosed,
loadDismissed,
} from "./updatePrompt";
function fakeStorage(): Storage {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
clear: () => map.clear(),
key: () => null,
get length() {
return map.size;
},
} as Storage;
}
describe("updatePrompt storage", () => {
it("dismissed version stays dismissed until a newer one appears", () => {
const s = fakeStorage();
dismissVersion(s, "backend", "0.3.0");
const d = loadDismissed(s);
expect(isDismissed(d, "backend", "0.3.0")).toBe(true);
expect(isDismissed(d, "backend", "0.3.1")).toBe(false);
expect(isDismissed(d, "frontend", "0.3.0")).toBe(false);
});
it("session close is separate from dismiss", () => {
const s = fakeStorage();
expect(isSessionClosed(s)).toBe(false);
closeForSession(s);
expect(isSessionClosed(s)).toBe(true);
});
it("corrupt storage falls back to empty", () => {
const s = fakeStorage();
s.setItem("dismissedVersions", "{not json");
expect(loadDismissed(s)).toEqual({});
});
});
+38
View File
@@ -0,0 +1,38 @@
const DISMISS_KEY = "dismissedVersions";
const SESSION_KEY = "updateBannerClosed";
export type UpdateTarget = "backend" | "frontend";
export type DismissedVersions = Partial<Record<UpdateTarget, string>>;
export function loadDismissed(storage: Storage): DismissedVersions {
try {
const raw = storage.getItem(DISMISS_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as DismissedVersions;
return {};
} catch {
return {};
}
}
export function dismissVersion(storage: Storage, key: UpdateTarget, version: string): void {
const next = { ...loadDismissed(storage), [key]: version };
storage.setItem(DISMISS_KEY, JSON.stringify(next));
}
export function isDismissed(
dismissed: DismissedVersions,
key: UpdateTarget,
version: string
): boolean {
return dismissed[key] === version;
}
export function isSessionClosed(storage: Storage): boolean {
return storage.getItem(SESSION_KEY) === "1";
}
export function closeForSession(storage: Storage): void {
storage.setItem(SESSION_KEY, "1");
}