This commit is contained in:
Eduard Gert
2026-05-13 10:11:38 +02:00
parent c8e18585c6
commit 1c8a6e3798
24 changed files with 959 additions and 96 deletions
@@ -1,12 +1,45 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { Update as UpdateSvc } from "@bindings/services";
import { useStatus } from "@/hooks/useStatus";
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner";
import { UpdatingOverlay } from "@/modules/auto-update/UpdatingOverlay";
type ClientVersionContextValue = {
updateAvailable: boolean;
updateVersion: string | null;
triggerUpdate: () => void;
updating: boolean;
updateError: string | null;
dismissUpdateError: () => void;
};
// Dev toggles — flip to preview UI states without triggering real flows.
const FORCE_UPDATE_AVAILABLE = true;
const FORCE_UPDATING = false;
const FORCE_VERSION = "0.65.0";
// Hide all "update available" UI (header trigger, settings badge, banner)
// regardless of what the daemon reports.
const HIDE_UPDATE_AVAILABLE = false;
// FORCE_ERROR options:
// null → no error (loading state)
// "timeout" → "Update timed out" state
// "cancel" → "Update canceled" state
// "fail" → generic "Update failed" state (uses FORCE_ERROR_MSG)
type ForceError = "timeout" | "cancel" | "fail" | null;
const FORCE_ERROR = null as ForceError;
const FORCE_ERROR_MSG = "installer exited with code 1";
const forcedErrorMessage = (): string | null => {
switch (FORCE_ERROR) {
case "timeout":
return "update timed out after 15m";
case "cancel":
return "update canceled by user";
case "fail":
return FORCE_ERROR_MSG;
default:
return null;
}
};
const ClientVersionContext = createContext<ClientVersionContextValue | null>(null);
@@ -21,25 +54,60 @@ export const useClientVersion = () => {
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
const [updating, setUpdating] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const value = useMemo<ClientVersionContextValue>(() => {
const version = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
return {
updateAvailable: Boolean(version),
updateVersion: version ?? null,
triggerUpdate: () => {
UpdateSvc.Trigger().catch(() => {});
},
};
const updateVersion = useMemo(() => {
if (HIDE_UPDATE_AVAILABLE) return null;
if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) return FORCE_VERSION;
return (
(status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v)) ?? null
);
}, [status]);
const triggerUpdate = useCallback(() => {
setUpdateError(null);
setUpdating(true);
UpdateSvc.Trigger()
.then((result) => {
if (!result?.success) {
setUpdateError(result?.errorMsg || "Update failed");
setUpdating(false);
}
})
.catch((e: unknown) => {
setUpdateError(String(e));
setUpdating(false);
});
}, []);
const dismissUpdateError = useCallback(() => setUpdateError(null), []);
const value = useMemo<ClientVersionContextValue>(
() => ({
updateAvailable: Boolean(updateVersion),
updateVersion,
triggerUpdate,
updating,
updateError,
dismissUpdateError,
}),
[updateVersion, triggerUpdate, updating, updateError, dismissUpdateError],
);
return (
<ClientVersionContext.Provider value={value}>
{children}
<UpdateAvailableBanner />
{(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && (
<UpdatingOverlay
version={updateVersion}
error={updateError ?? forcedErrorMessage()}
onDismiss={dismissUpdateError}
/>
)}
</ClientVersionContext.Provider>
);
};
@@ -1,14 +1,21 @@
import { forwardRef, type HTMLAttributes } from "react";
import { ArrowUpCircleIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = {
type Props = HTMLAttributes<HTMLDivElement> & {
size?: number;
className?: string;
};
export const UpdateBadge = ({ size = 15, className }: Props) => {
export const UpdateBadge = forwardRef<HTMLDivElement, Props>(function UpdateBadge(
{ size = 15, className, ...rest },
ref,
) {
return (
<div className={cn("relative flex items-center justify-center", className)}>
<div
ref={ref}
className={cn("relative flex items-center justify-center", className)}
{...rest}
>
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
@@ -17,4 +24,4 @@ export const UpdateBadge = ({ size = 15, className }: Props) => {
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
</div>
);
};
});
@@ -0,0 +1,110 @@
import { Loader2, XCircle } from "lucide-react";
import { Button } from "@/components/Button";
type Props = {
version: string | null;
error: string | null;
onDismiss: () => void;
};
type Variant = {
title: string;
description: string;
message?: string;
};
function classifyError(msg: string, version: string | null): Variant {
const lower = msg.toLowerCase();
const target = version ? `v${version}` : "the new version";
if (lower.includes("timeout") || lower.includes("timed out")) {
return {
title: "Update Is Taking Too Long",
description: `Installing ${target} took too long and didn't finish.`,
};
}
if (lower.includes("cancel")) {
return {
title: "Update Was Stopped",
description: `The update to ${target} was canceled before it finished.`,
};
}
return {
title: "Couldn't Install the Update",
description: `${target} couldn't be installed.`,
message: msg || "unknown error",
};
}
export const UpdatingOverlay = ({ version, error, onDismiss }: Props) => {
const isError = Boolean(error);
const errorInfo = error ? classifyError(error, version) : null;
return (
<div
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950/85 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onPointerDown={(e) => {
if (isError) return;
e.preventDefault();
e.stopPropagation();
}}
onKeyDown={(e) => {
if (isError) return;
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
{isError ? (
<div
className={"h-9 w-9 rounded-md flex items-center justify-center bg-red-500"}
>
<XCircle className={"text-white"} size={18} />
</div>
) : (
<div
className={"h-9 w-9 rounded-md flex items-center justify-center bg-nb-gray-100"}
>
<Loader2 className={"animate-spin text-nb-gray-950"} size={16} />
</div>
)}
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{isError
? errorInfo!.title
: version
? `Updating NetBird to v${version}`
: "Updating NetBird"}
</p>
<p className={"text-sm text-nb-gray-300"}>
{isError ? (
<>
{errorInfo!.description}
{errorInfo!.message && (
<>
<br />
<span className={"first-letter:uppercase"}>
{errorInfo!.message}
</span>
</>
)}
</>
) : (
"A newer version is available and is being installed. NetBird will restart automatically once the update is finished."
)}
</p>
</div>
{isError && (
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={onDismiss}>
Close
</Button>
</div>
)}
</div>
</div>
);
};