add update context

This commit is contained in:
Eduard Gert
2026-05-11 17:21:38 +02:00
parent 1931a2c8a8
commit c8e18585c6
9 changed files with 122 additions and 75 deletions

View File

@@ -1,19 +1,20 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { Header } from "@/layouts/Header.tsx"; import { Header } from "@/layouts/Header.tsx";
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner.tsx"; import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
export const AppLayout = () => { export const AppLayout = () => {
return ( return (
<ProfileProvider> <div className={"relative flex h-full flex-col"}>
<DebugBundleProvider> <ProfileProvider>
<div className={"relative flex h-full flex-col"}> <DebugBundleProvider>
<Header /> <ClientVersionProvider>
<Outlet /> <Header />
<UpdateAvailableBanner /> <Outlet />
</div> </ClientVersionProvider>
</DebugBundleProvider> </DebugBundleProvider>
</ProfileProvider> </ProfileProvider>
</div>
); );
}; };

View File

@@ -1,19 +1,14 @@
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ArrowUpCircleIcon, SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import { ProfileSelector } from "@/components/ProfileSelector.tsx"; import { ProfileSelector } from "@/components/ProfileSelector.tsx";
import { IconButton } from "@/components/IconButton.tsx"; import { IconButton } from "@/components/IconButton.tsx";
import { Tooltip } from "@/components/Tooltip.tsx"; import { UpdateHeaderTrigger } from "@/modules/auto-update/UpdateHeaderTrigger.tsx";
import { useStatus } from "@/hooks/useStatus";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
export const Header = () => { export const Header = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const isSettingsPage = location.pathname.startsWith("/settings"); const isSettingsPage = location.pathname.startsWith("/settings");
const { status } = useStatus();
const updateAvailable = (status?.events ?? []).some((e) =>
Boolean(e.metadata?.["new_version_available"]),
);
return ( return (
<div <div
@@ -24,25 +19,7 @@ export const Header = () => {
<div className={"ml-20"}> <div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} /> <ProfileSelector email={"eduard@netbird.io"} />
</div> </div>
{updateAvailable && ( <UpdateHeaderTrigger />
<Tooltip content={"Update Available"}>
<div className={"relative h-11 w-11 flex items-center justify-center"}>
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
}
/>
<IconButton
icon={ArrowUpCircleIcon}
iconClassName={"text-netbird"}
onClick={() =>
navigate("/settings", { state: { tab: "about" } })
}
className={"absolute inset-0"}
/>
</div>
</Tooltip>
)}
<IconButton <IconButton
icon={SettingsIcon} icon={SettingsIcon}
onClick={() => navigate(isSettingsPage ? "/" : "/settings")} onClick={() => navigate(isSettingsPage ? "/" : "/settings")}

View File

@@ -0,0 +1,45 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Update as UpdateSvc } from "@bindings/services";
import { useStatus } from "@/hooks/useStatus";
import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner";
type ClientVersionContextValue = {
updateAvailable: boolean;
updateVersion: string | null;
triggerUpdate: () => void;
};
const ClientVersionContext = createContext<ClientVersionContextValue | null>(null);
export const useClientVersion = () => {
const ctx = useContext(ClientVersionContext);
if (!ctx) {
throw new Error("useClientVersion must be used inside ClientVersionProvider");
}
return ctx;
};
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
const { status } = useStatus();
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(() => {});
},
};
}, [status]);
return (
<ClientVersionContext.Provider value={value}>
{children}
<UpdateAvailableBanner />
</ClientVersionContext.Provider>
);
};

View File

@@ -1,26 +1,16 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { useStatus } from "@/hooks/useStatus"; import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Update as UpdateSvc } from "@bindings/services";
// TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled // TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled
export const UpdateAvailableBanner = () => { export const UpdateAvailableBanner = () => {
const { status } = useStatus(); const { updateVersion, triggerUpdate } = useClientVersion();
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
if (import.meta.env.DEV) return null; if (import.meta.env.DEV) return null;
const updateVersion = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
if (!updateVersion || dismissed) return null; if (!updateVersion || dismissed) return null;
const triggerUpdate = () => {
UpdateSvc.Trigger().catch(() => {});
};
return ( return (
<div <div
className={cn( className={cn(

View File

@@ -0,0 +1,20 @@
import { ArrowUpCircleIcon } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = {
size?: number;
className?: string;
};
export const UpdateBadge = ({ size = 15, className }: Props) => {
return (
<div className={cn("relative flex items-center justify-center", className)}>
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
}
/>
<ArrowUpCircleIcon size={size} className={"text-netbird"} />
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { useNavigate } from "react-router-dom";
import { ArrowUpCircleIcon } from "lucide-react";
import { IconButton } from "@/components/IconButton.tsx";
import { Tooltip } from "@/components/Tooltip.tsx";
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
export const UpdateHeaderTrigger = () => {
const navigate = useNavigate();
const { updateAvailable } = useClientVersion();
if (!updateAvailable) return null;
return (
<Tooltip content={"Update Available"}>
<div className={"relative h-11 w-11 flex items-center justify-center"}>
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
}
/>
<IconButton
icon={ArrowUpCircleIcon}
iconClassName={"text-netbird"}
onClick={() =>
navigate("/settings", { state: { tab: "about" } })
}
className={"absolute inset-0"}
/>
</div>
</Tooltip>
);
};

View File

@@ -1,8 +1,7 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Browser } from "@wailsio/runtime"; import { Browser } from "@wailsio/runtime";
import { Update as UpdateSvc } from "@bindings/services";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { useStatus } from "@/hooks/useStatus"; import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
function openUrl(url: string) { function openUrl(url: string) {
@@ -18,15 +17,8 @@ function formatLastChecked(date: Date) {
}); });
} }
function triggerUpdate() { export function UpdateVersionCard() {
UpdateSvc.Trigger().catch(() => {}); const { updateVersion, triggerUpdate } = useClientVersion();
}
export function NetBirdVersionCard() {
const { status } = useStatus();
const updateVersion = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
if (updateVersion) { if (updateVersion) {
return ( return (

View File

@@ -2,7 +2,7 @@ import { Browser } from "@wailsio/runtime";
import netbirdFull from "@/assets/logos/netbird-full.svg"; import netbirdFull from "@/assets/logos/netbird-full.svg";
import pkg from "../../../package.json"; import pkg from "../../../package.json";
import { useStatus } from "@/hooks/useStatus"; import { useStatus } from "@/hooks/useStatus";
import { NetBirdVersionCard } from "@/components/NetBirdVersionCard"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
const LEGAL_LINKS: { label: string; url: string }[] = [ const LEGAL_LINKS: { label: string; url: string }[] = [
@@ -40,7 +40,7 @@ export function SettingsAbout() {
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p> <p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
</div> </div>
<NetBirdVersionCard /> <UpdateVersionCard />
<p className={"text-sm text-nb-gray-300 text-center"}> <p className={"text-sm text-nb-gray-300 text-center"}>
© {new Date().getFullYear()} NetBird. All Rights Reserved. © {new Date().getFullYear()} NetBird. All Rights Reserved.

View File

@@ -1,8 +1,8 @@
import { Tooltip } from "@/components/Tooltip.tsx"; import { Tooltip } from "@/components/Tooltip.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx"; import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { useStatus } from "@/hooks/useStatus"; import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext.tsx";
import { import {
ArrowUpCircleIcon,
BoltIcon, BoltIcon,
InfoIcon, InfoIcon,
LifeBuoyIcon, LifeBuoyIcon,
@@ -13,21 +13,11 @@ import {
} from "lucide-react"; } from "lucide-react";
export const SettingsNavigationTriggers = () => { export const SettingsNavigationTriggers = () => {
const { status } = useStatus(); const { updateAvailable } = useClientVersion();
const updateAvailable = (status?.events ?? []).some((e) =>
Boolean(e.metadata?.["new_version_available"]),
);
const aboutAdornment = updateAvailable ? ( const aboutAdornment = updateAvailable ? (
<Tooltip content={"Update Available"} side={"right"}> <Tooltip content={"Update Available"} side={"right"}>
<div className={"relative flex items-center justify-center"}> <UpdateBadge />
<span
className={
"animate-ping absolute inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20 pointer-events-none"
}
/>
<ArrowUpCircleIcon size={15} className={"text-netbird"} />
</div>
</Tooltip> </Tooltip>
) : undefined; ) : undefined;