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

View File

@@ -0,0 +1,94 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export type AppearanceView = "default" | "advanced";
export type ConnectionLayout = "default" | "switch";
export type AppearanceState = {
view: AppearanceView;
connectionLayout: ConnectionLayout;
expanded: boolean;
showPeersNav: boolean;
showResourcesNav: boolean;
showExitNodeNav: boolean;
showProfileSelector: boolean;
showSettingsButton: boolean;
};
const STORAGE_KEY = "netbird:appearance";
const DEFAULTS: AppearanceState = {
view: "default",
connectionLayout: "default",
expanded: true,
showPeersNav: true,
showResourcesNav: true,
showExitNodeNav: true,
showProfileSelector: true,
showSettingsButton: true,
};
const readStored = (): AppearanceState => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
const parsed = JSON.parse(raw) as Partial<AppearanceState>;
return { ...DEFAULTS, ...parsed };
} catch {
return DEFAULTS;
}
};
type AppearanceContextValue = AppearanceState & {
setView: (v: AppearanceView) => void;
setField: <K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => void;
};
const AppearanceContext = createContext<AppearanceContextValue | null>(null);
export const useAppearance = () => {
const ctx = useContext(AppearanceContext);
if (!ctx) {
throw new Error("useAppearance must be used inside AppearanceProvider");
}
return ctx;
};
export const AppearanceProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<AppearanceState>(() => readStored());
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// ignore quota / unavailable storage
}
}, [state]);
const setField = useCallback(
<K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => {
setState((s) => ({ ...s, [k]: v }));
},
[],
);
const setView = useCallback((v: AppearanceView) => {
setState((s) => ({ ...s, view: v }));
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({ ...state, setView, setField }),
[state, setView, setField],
);
return (
<AppearanceContext.Provider value={value}>{children}</AppearanceContext.Provider>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};
});

View File

@@ -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>
);
};

View File

@@ -7,6 +7,7 @@ import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsAppearance } from "@/modules/settings/SettingsAppearance.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
@@ -14,15 +15,35 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
const LAST_TAB_KEY = "netbird:settings:lastTab";
const readLastTab = () => {
try {
return localStorage.getItem(LAST_TAB_KEY);
} catch {
return null;
}
};
export const Settings = () => {
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const [active, setActive] = useState(navState?.tab ?? "general");
const [active, setActive] = useState(
() => navState?.tab ?? readLastTab() ?? "general",
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
}, [navState?.tab, location.key]);
useEffect(() => {
try {
localStorage.setItem(LAST_TAB_KEY, active);
} catch {
// ignore quota / unavailable storage
}
}, [active]);
return (
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
<SettingsNavigationTriggers />
@@ -37,6 +58,9 @@ export const Settings = () => {
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"appearance"}>
<SettingsAppearance />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>

View File

@@ -0,0 +1,88 @@
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { CardSelect } from "@/components/CardSelect.tsx";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import {
useAppearance,
type AppearanceView,
} from "@/modules/appearance/AppearanceContext.tsx";
import simpleScreen from "@/assets/screens/simple.png";
import advancedScreen from "@/assets/screens/advanced.png";
const ScreenPreview = ({ src, alt }: { src: string; alt: string }) => (
<img
src={src}
alt={alt}
draggable={false}
className={"h-full w-full object-contain select-none"}
/>
);
export function SettingsAppearance() {
const {
view,
setView,
showPeersNav,
showResourcesNav,
showExitNodeNav,
showProfileSelector,
showSettingsButton,
setField,
} = useAppearance();
return (
<>
<SectionGroup title={"View"}>
<CardSelect
value={view}
onChange={(v) => setView(v as AppearanceView)}
>
<CardSelect.Option
value={"default"}
title={"Simple"}
description={"Streamlined view with essential controls."}
preview={<ScreenPreview src={simpleScreen} alt={"Simple view"} />}
/>
<CardSelect.Option
value={"advanced"}
title={"Advanced"}
description={"All details and power-user options visible."}
preview={<ScreenPreview src={advancedScreen} alt={"Advanced view"} />}
/>
</CardSelect>
</SectionGroup>
<SectionGroup title={"Interface"}>
<FancyToggleSwitch
value={showPeersNav}
onChange={(v) => setField("showPeersNav", v)}
label={"Peers"}
helpText={"Show the Peers item in the side navigation."}
/>
<FancyToggleSwitch
value={showResourcesNav}
onChange={(v) => setField("showResourcesNav", v)}
label={"Resources"}
helpText={"Show the Resources item in the side navigation."}
/>
<FancyToggleSwitch
value={showExitNodeNav}
onChange={(v) => setField("showExitNodeNav", v)}
label={"Exit Node"}
helpText={"Show the active exit node in the side navigation."}
/>
<FancyToggleSwitch
value={showProfileSelector}
onChange={(v) => setField("showProfileSelector", v)}
label={"Profile Selector"}
helpText={"Show the profile selector in the header."}
/>
<FancyToggleSwitch
value={showSettingsButton}
onChange={(v) => setField("showSettingsButton", v)}
label={"Settings Button"}
helpText={"Show the settings button in the header."}
/>
</SectionGroup>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
SwatchBookIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
@@ -29,6 +30,11 @@ export const SettingsNavigationTriggers = () => {
icon={SlidersHorizontalIcon}
title={"General"}
/>
<VerticalTabs.Trigger
value={"appearance"}
icon={SwatchBookIcon}
title={"Appearance"}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}