mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-21 08:09:55 +00:00
wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
110
client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx
Normal file
110
client/ui/frontend/src/modules/auto-update/UpdatingOverlay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user