feat(update): update banner and one-click upgrade/rollback in version badge
This commit is contained in:
@@ -49,6 +49,7 @@ import { MemoryPanel } from "@/app/components/MemoryPanel";
|
|||||||
import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
|
import { ScheduledTasksPanel } from "@/app/components/ScheduledTasksPanel";
|
||||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||||
import { VersionBadge } from "@/app/components/VersionBadge";
|
import { VersionBadge } from "@/app/components/VersionBadge";
|
||||||
|
import { UpdateBanner } from "@/app/components/UpdateBanner";
|
||||||
import { HealthIndicator } from "@/app/components/HealthIndicator";
|
import { HealthIndicator } from "@/app/components/HealthIndicator";
|
||||||
import { InspectorPanel } from "@/app/components/InspectorPanel";
|
import { InspectorPanel } from "@/app/components/InspectorPanel";
|
||||||
import { setThreadAutoApprove } from "@/lib/autoApprove";
|
import { setThreadAutoApprove } from "@/lib/autoApprove";
|
||||||
@@ -481,6 +482,8 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<UpdateBanner />
|
||||||
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
{sidebar && isDesktopLayout === false && (
|
{sidebar && isDesktopLayout === false && (
|
||||||
<div className="absolute inset-0 z-40 flex md:hidden">
|
<div className="absolute inset-0 z-40 flex md:hidden">
|
||||||
|
|||||||
@@ -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 & 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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import useSWR from "swr";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { errorToast } from "@/lib/errorReporter";
|
import { errorToast } from "@/lib/errorReporter";
|
||||||
import type { SystemVersionInfo } from "@/app/api/system/version/route";
|
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> {
|
async function fetchVersion(url: string): Promise<SystemVersionInfo> {
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
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;
|
return (await res.json()) as SystemVersionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateTargetKey = "backend" | "frontend";
|
||||||
|
|
||||||
export function VersionBadge() {
|
export function VersionBadge() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
const [downloaded, setDownloaded] =
|
const [updateStatus, setUpdateStatus] = useState<string | null>(null);
|
||||||
useState<SystemVersionDownloadResult | null>(null);
|
const [rollbackVersions, setRollbackVersions] = useState<
|
||||||
|
RollbackVersionsResult["versions"] | null
|
||||||
|
>(null);
|
||||||
const { data, error, mutate } = useSWR<SystemVersionInfo>(
|
const { data, error, mutate } = useSWR<SystemVersionInfo>(
|
||||||
"/api/system/version",
|
"/api/system/version",
|
||||||
fetchVersion,
|
fetchVersion,
|
||||||
{ revalidateOnFocus: false, shouldRetryOnError: false }
|
{ 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 () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
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 mutate(info, { revalidate: false });
|
||||||
|
await mutateSelf(selfInfo, { revalidate: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(
|
errorToast(
|
||||||
"version.refresh",
|
"version.refresh",
|
||||||
@@ -53,41 +153,28 @@ export function VersionBadge() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const loadRollbackVersions = async () => {
|
||||||
setDownloading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/system/version/download", {
|
const res = await fetch("/api/system/rollback-versions", { cache: "no-store" });
|
||||||
method: "POST",
|
if (!res.ok) return;
|
||||||
headers: { "Content-Type": "application/json" },
|
const body = (await res.json()) as RollbackVersionsResult;
|
||||||
body: "{}",
|
setRollbackVersions(body.versions);
|
||||||
});
|
} catch {
|
||||||
const body = await res.json().catch(() => null);
|
// The rollback list is best-effort; the upgrade path stays available.
|
||||||
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 handleOpenChange = (next: boolean) => {
|
const handleOpenChange = (next: boolean) => {
|
||||||
setOpen(next);
|
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 hasUpdate = data?.has_update === true;
|
||||||
|
const selfHasUpdate = selfData?.has_update === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
|
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||||
@@ -96,16 +183,17 @@ export function VersionBadge() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Version"
|
aria-label="Version"
|
||||||
|
id="version"
|
||||||
title={data ? `EvoScientist v${data.current_version}` : "Version"}
|
title={data ? `EvoScientist v${data.current_version}` : "Version"}
|
||||||
className={`relative h-8 gap-1 px-2 text-xs ${
|
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"
|
? "bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Tag className="size-3.5" aria-hidden="true" />
|
<Tag className="size-3.5" aria-hidden="true" />
|
||||||
{data ? `v${data.current_version}` : "…"}
|
{data ? `v${data.current_version}` : "…"}
|
||||||
{hasUpdate && (
|
{(hasUpdate || selfHasUpdate) && (
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
<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="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" />
|
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
|
||||||
@@ -114,88 +202,108 @@ export function VersionBadge() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
<div className="border-b border-border px-3 py-2 text-sm font-medium">Version</div>
|
||||||
Version
|
|
||||||
</div>
|
|
||||||
{error && !data ? (
|
{error && !data ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
Couldn't load version info.
|
Couldn't load version info.
|
||||||
</p>
|
</p>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading...</p>
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 px-3 py-3 text-sm">
|
<div className="space-y-3 px-3 py-3 text-sm">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="space-y-2">
|
||||||
<span className="text-muted-foreground">Installed</span>
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<span className="font-mono">v{data.current_version}</span>
|
Backend
|
||||||
</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.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
{data.warning && (
|
<span className="text-muted-foreground">Installed</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
<span className="font-mono">v{data.current_version}</span>
|
||||||
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user