update stuff

This commit is contained in:
Eduard Gert
2026-05-13 16:28:51 +02:00
parent 83030dbbd6
commit 1932b76f5b
24 changed files with 702 additions and 375 deletions

View File

@@ -3,15 +3,16 @@ import ReactDOM from "react-dom/client";
import "./globals.css";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import QuickActions from "@/screens/QuickActions.tsx";
import LoginUrl from "@/pages/LoginUrl.tsx";
import SessionExpired from "@/pages/SessionExpired.tsx";
import Update from "@/screens/Update.tsx";
import { AppLayout } from "@/layouts/AppLayout.tsx";
import { SettingsLayout } from "@/layouts/SettingsLayout.tsx";
import { Main } from "@/layouts/Main.tsx";
import { Settings } from "@/modules/settings/Settings.tsx";
import { SkeletonTheme } from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { welcome } from "@/lib/welcome";
import Login from "@/pages/Login.tsx";
welcome();
@@ -21,12 +22,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/login" element={<Login />} />
<Route path="/update" element={<Update />} />
<Route path="/session-expired" element={<SessionExpired />} />
<Route element={<SettingsLayout />}>
<Route path="settings" element={<Settings />} />
</Route>
<Route element={<AppLayout />}>
<Route index element={<Main />} />
<Route path="settings" element={<Settings />} />
<Route
path="*"
element={<Navigate to={"/"} replace />}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -18,9 +18,10 @@ type NetBirdConnectToggleProps = {
state: ConnectionState;
size?: number;
onClick?: () => void;
disabled?: boolean;
};
export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConnectToggleProps) => {
export const NetBirdConnectToggle = ({ state, size = 140, onClick, disabled }: NetBirdConnectToggleProps) => {
const [visualState, setVisualState] = useState(state);
useEffect(() => {
@@ -28,9 +29,10 @@ export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConn
}, [state]);
const handleClick = () => {
if (disabled) return;
if (visualState === ConnectionState.Connected) {
setVisualState(ConnectionState.Disconnecting);
} else {
} else if (visualState === ConnectionState.Disconnected) {
setVisualState(ConnectionState.Connecting);
}
onClick?.();
@@ -46,10 +48,14 @@ export const NetBirdConnectToggle = ({ state, size = 140, onClick }: NetBirdConn
return (
<div>
<motion.button
className="rounded-full relative overflow-visible cursor-default outline-none border-none bg-transparent"
className={cn(
"rounded-full relative overflow-visible outline-none border-none bg-transparent",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
)}
style={{ padding }}
onClick={handleClick}
whileTap={{ scale: 0.98 }}
disabled={disabled}
whileTap={disabled ? undefined : { scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<OuterRing state={visualState} />

View File

@@ -5,89 +5,82 @@ import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Dialogs } from "@wailsio/runtime";
import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { generateColorFromString } from "@/lib/color";
import { NewProfileDialog } from "@/components/NewProfileDialog";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
export type Profile = {
id: string;
name: string;
};
const DEFAULT_PROFILE = "default";
const MOCK_PROFILES: Profile[] = [
{ id: "default", name: "Default Profile" },
{ id: "work", name: "Work" },
{ id: "personal", name: "Personal" },
{ id: "staging", name: "Staging" },
{ id: "production", name: "Production" },
{ id: "dev", name: "Development" },
{ id: "qa", name: "QA Environment" },
{ id: "demo", name: "Demo" },
{ id: "client-acme", name: "Client - ACME" },
{ id: "client-globex", name: "Client - Globex" },
{ id: "client-initech", name: "Client - Initech" },
{ id: "homelab", name: "Homelab" },
{ id: "office-berlin", name: "Office Berlin" },
{ id: "office-sf", name: "Office San Francisco" },
{ id: "office-tokyo", name: "Office Tokyo" },
{ id: "vpn-eu", name: "VPN EU" },
{ id: "vpn-us", name: "VPN US" },
{ id: "vpn-asia", name: "VPN Asia" },
{ id: "test", name: "Test" },
{ id: "sandbox", name: "Sandbox" },
];
export const ProfileSelector = () => {
const {
profiles,
activeProfile,
loaded,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
} = useProfile();
type Props = {
email?: string;
};
export const ProfileSelector = ({ email = "" }: Props) => {
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
const [selectedId, setSelectedId] = useState<string>(MOCK_PROFILES[0].id);
const [open, setOpen] = useState(false);
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const selected = profiles.find((p) => p.id === selectedId) ?? profiles[0];
const selected =
profiles.find((p) => p.name === activeProfile) ??
profiles.find((p) => p.isActive) ??
profiles[0];
const sorted = [...profiles].sort((a, b) => a.name.localeCompare(b.name));
const handleSelect = (id: string) => {
setSelectedId(id);
setOpen(false);
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await Dialogs.Error({
Title: title,
Message: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
const handleDeregister = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
const handleSelect = (name: string) => {
setOpen(false);
if (name === activeProfile) return;
void guarded("Switch Profile Failed", () => switchProfile(name));
};
const handleDeregister = async (name: string) => {
const result = await Dialogs.Warning({
Title: "Deregister Profile",
Message: `Are you sure you want to deregister "${profile.name}"? You will need to log in again to use it.`,
Message: `Are you sure you want to deregister "${name}"? You will need to log in again to use it.`,
Buttons: [
{ Label: "Cancel", IsCancel: true },
{ Label: "Deregister", IsDefault: true },
],
});
if (result !== "Deregister") return;
console.log("Deregister profile", id);
void guarded("Deregister Profile Failed", () => logoutProfile(name));
};
const handleDelete = async (id: string) => {
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
const handleDelete = async (name: string) => {
if (name === DEFAULT_PROFILE) return;
const result = await Dialogs.Warning({
Title: "Delete Profile",
Message: `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`,
Message: `Are you sure you want to delete "${name}"? This action cannot be undone.`,
Buttons: [
{ Label: "Cancel", IsCancel: true },
{ Label: "Delete", IsDefault: true },
],
});
if (result !== "Delete") return;
setProfiles((prev) => prev.filter((p) => p.id !== id));
if (selectedId === id) {
const remaining = profiles.filter((p) => p.id !== id);
if (remaining.length > 0) setSelectedId(remaining[0].id);
}
void guarded("Delete Profile Failed", () => removeProfile(name));
};
const handleNewProfile = () => {
@@ -96,12 +89,11 @@ export const ProfileSelector = ({ email = "" }: Props) => {
};
const handleCreateProfile = (name: string) => {
const id = `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`;
setProfiles((prev) => [...prev, { id, name }]);
setSelectedId(id);
void guarded("Create Profile Failed", () => addProfile(name));
};
const initial = selected?.name.charAt(0).toUpperCase() ?? "?";
const displayName = selected?.name ?? (loaded ? "No profile" : "Loading...");
const initial = (selected?.name ?? "?").charAt(0).toUpperCase();
const initialColor = generateColorFromString(selected?.name);
return (
@@ -116,27 +108,20 @@ export const ProfileSelector = ({ email = "" }: Props) => {
>
<div
className={cn(
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold",
email ? "h-7 w-7" : "h-6 w-6",
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold h-6 w-6",
)}
style={{ color: initialColor }}
>
{initial}
</div>
<div
className={cn(
"whitespace-nowrap flex flex-col ml-1 text-left",
email ? "mt-1" : "justify-center",
)}
className={
"whitespace-nowrap flex flex-col ml-1 text-left justify-center"
}
>
<span className={"leading-none text-nb-gray-200 font-semibold"}>
{selected?.name ?? "No profile"}
{displayName}
</span>
{email && (
<span className={"text-[0.73rem] font-normal text-nb-gray-300"}>
{email}
</span>
)}
</div>
<ChevronDown size={14} className={"ml-2 mr-2"} />
</button>
@@ -196,12 +181,13 @@ export const ProfileSelector = ({ email = "" }: Props) => {
{sorted.map((profile) => (
<ProfileRow
key={profile.id}
key={profile.name}
profile={profile}
selected={profile.id === selectedId}
onSelect={() => handleSelect(profile.id)}
onDeregister={() => handleDeregister(profile.id)}
onDelete={() => handleDelete(profile.id)}
selected={profile.name === activeProfile}
onSelect={() => handleSelect(profile.name)}
onDeregister={() => handleDeregister(profile.name)}
onDelete={() => handleDelete(profile.name)}
deletable={profile.name !== DEFAULT_PROFILE}
/>
))}
</Command.List>
@@ -255,9 +241,17 @@ type ProfileRowProps = {
onSelect: () => void;
onDeregister: () => void;
onDelete: () => void;
deletable: boolean;
};
const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: ProfileRowProps) => {
const ProfileRow = ({
profile,
selected,
onSelect,
onDeregister,
onDelete,
deletable,
}: ProfileRowProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const initial = profile.name.charAt(0).toUpperCase();
const initialColor = generateColorFromString(profile.name);
@@ -265,7 +259,6 @@ const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: Pro
return (
<Command.Item
value={profile.name}
keywords={[profile.id]}
onSelect={() => onSelect()}
className={cn(
"group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
@@ -338,14 +331,19 @@ const ProfileRow = ({ profile, selected, onSelect, onDeregister, onDelete }: Pro
<span>Deregister</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={!deletable}
onSelect={(e) => {
e.preventDefault();
if (!deletable) return;
onDelete();
setMenuOpen(false);
}}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
"text-xs text-red-500 data-[highlighted]:bg-nb-gray-850",
"text-xs data-[highlighted]:bg-nb-gray-850",
deletable
? "text-red-500"
: "text-nb-gray-500 cursor-not-allowed",
)}
>
<Trash2 size={14} />

View File

@@ -37,7 +37,7 @@ const switchVariants = cva("", {
"thumb-size": {
default: "h-5 w-5 data-[state=checked]:translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px]",
large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[30px]",
large: "h-[28px] w-[28px] data-[state=checked]:translate-x-[34px]",
},
},
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Events } from "@wailsio/runtime";
import { Peers } from "@bindings/services";
import type { Status } from "@bindings/services/models.js";
@@ -6,20 +6,30 @@ import type { Status } from "@bindings/services/models.js";
const EVENT_STATUS = "netbird:status";
// useStatus loads the current daemon status once and re-renders whenever the
// peers service emits a fresh snapshot over the Wails event bus.
export function useStatus(): { status: Status | null; error: string | null } {
// peers service emits a fresh snapshot over the Wails event bus. Callers can
// also force a manual refresh (e.g. right after Connection.Up/Down) so the
// view never lags behind a user action even if the daemon event stream is
// briefly silent.
export function useStatus(): {
status: Status | null;
error: string | null;
refresh: () => Promise<void>;
} {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const s = await Peers.Get();
setStatus(s);
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
useEffect(() => {
let cancelled = false;
Peers.Get()
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e: unknown) => {
if (!cancelled) setError(String(e));
});
void refresh();
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
@@ -27,10 +37,9 @@ export function useStatus(): { status: Status | null; error: string | null } {
});
return () => {
cancelled = true;
off();
};
}, []);
}, [refresh]);
return { status, error };
return { status, error, refresh };
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Dialogs } from "@wailsio/runtime";
import { Connection } from "@bindings/services";
import { ConnectionState } from "@/components/NetBirdConnectToggle.tsx";
import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
import { useStatus } from "@/hooks/useStatus";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
const CONNECT_DURATION_MS = 1500;
const DISCONNECT_DURATION_MS = 800;
const STATUS_LABEL: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "Disconnected",
[ConnectionState.Connecting]: "Connecting...",
@@ -14,46 +16,98 @@ const STATUS_LABEL: Record<ConnectionState, string> = {
[ConnectionState.Disconnecting]: "Disconnecting...",
};
const errorMessage = (e: unknown) =>
e instanceof Error ? e.message : String(e);
export const ConnectionStatusSwitch = () => {
const [state, setState] = useState<ConnectionState>(ConnectionState.Disconnected);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { status, refresh } = useStatus();
const { activeProfile, username } = useProfile();
const navigate = useNavigate();
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
const daemonState = status?.status ?? "Idle";
const needsLogin =
daemonState === "NeedsLogin" ||
daemonState === "SessionExpired" ||
daemonState === "LoginFailed";
const unreachable = daemonState === "DaemonUnavailable";
const transition = (next: ConnectionState, after: ConnectionState, delay: number) => {
if (timerRef.current) clearTimeout(timerRef.current);
setState(next);
timerRef.current = setTimeout(() => {
setState(after);
timerRef.current = null;
}, delay);
// Tracks an in-flight user action (Up/Down RPC + refresh) so we can show a
// transitional label and disable the switch without lying about the
// daemon's actual state.
const [action, setAction] = useState<"connect" | "disconnect" | null>(null);
const connState: ConnectionState = useMemo(() => {
if (action === "disconnect" && daemonState === "Connected") {
return ConnectionState.Disconnecting;
}
if (action === "connect" && daemonState !== "Connected") {
return ConnectionState.Connecting;
}
switch (daemonState) {
case "Connected":
return ConnectionState.Connected;
case "Connecting":
return ConnectionState.Connecting;
default:
return ConnectionState.Disconnected;
}
}, [daemonState, action]);
const connect = async () => {
setAction("connect");
try {
await Connection.Up({
profileName: activeProfile,
username,
});
} catch (e) {
await Dialogs.Error({
Title: "Connect Failed",
Message: errorMessage(e),
});
} finally {
await refresh();
setAction(null);
}
};
const connect = () =>
transition(ConnectionState.Connecting, ConnectionState.Connected, CONNECT_DURATION_MS);
const disconnect = () =>
transition(
ConnectionState.Disconnecting,
ConnectionState.Disconnected,
DISCONNECT_DURATION_MS,
);
const disconnect = async () => {
setAction("disconnect");
try {
await Connection.Down();
} catch (e) {
await Dialogs.Error({
Title: "Disconnect Failed",
Message: errorMessage(e),
});
} finally {
await refresh();
setAction(null);
}
};
const handleSwitch = (next: boolean) => {
if (next) {
if (state === ConnectionState.Disconnected) connect();
} else if (state === ConnectionState.Connected) {
disconnect();
if (unreachable || action !== null) return;
if (needsLogin) {
navigate("/login");
return;
}
if (next && connState === ConnectionState.Disconnected) {
void connect();
} else if (!next && connState === ConnectionState.Connected) {
void disconnect();
}
};
const isTransitioning =
state === ConnectionState.Connecting || state === ConnectionState.Disconnecting;
const isOn = state === ConnectionState.Connected || state === ConnectionState.Connecting;
connState === ConnectionState.Connecting ||
connState === ConnectionState.Disconnecting;
const isOn =
connState === ConnectionState.Connected ||
connState === ConnectionState.Connecting;
const showLocal = connState === ConnectionState.Connected;
const fqdn = status?.local.fqdn || "";
const ip = status?.local.ip || "";
return (
<div className={cn("flex flex-col h-full w-full items-center justify-center gap-4 -mt-4")}>
@@ -68,8 +122,11 @@ export const ConnectionStatusSwitch = () => {
size={"large"}
checked={isOn}
onCheckedChange={handleSwitch}
disabled={isTransitioning}
className={cn(isTransitioning && "opacity-80")}
disabled={isTransitioning || unreachable}
className={cn(
unreachable && "opacity-80",
isTransitioning && "animate-pulse",
)}
/>
<div className={"flex flex-col items-center"}>
@@ -78,23 +135,27 @@ export const ConnectionStatusSwitch = () => {
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300"
}
>
{STATUS_LABEL[state]}
{unreachable
? "Daemon unavailable"
: needsLogin
? "Login required"
: STATUS_LABEL[connState]}
</h1>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-2 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
className={cn(
"font-mono text-xs leading-tight min-h-[1em] text-nb-gray-300 mt-2 transition-opacity duration-300",
showLocal && fqdn ? "opacity-100" : "opacity-0",
)}
>
peer-hostname.netbird.cloud
{fqdn || " "}
</p>
<p
className={
"font-mono text-xs text-nb-gray-300 mt-0.5 transition-opacity duration-300 " +
(state === ConnectionState.Connected ? "opacity-100" : "opacity-0")
}
className={cn(
"font-mono text-xs leading-tight min-h-[1em] text-nb-gray-300 mt-0.5 transition-opacity duration-300",
showLocal && ip ? "opacity-100" : "opacity-0",
)}
>
192.168.0.1
{ip || " "}
</p>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react";
import { Window } from "@wailsio/runtime";
import { Windows as WindowsSvc } from "@bindings/services";
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
import { IconButton } from "@/components/IconButton.tsx";
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
@@ -13,11 +13,7 @@ const WINDOW_HEIGHT = 615;
const EXPANDED_THRESHOLD = 500;
export const Header = () => {
const navigate = useNavigate();
const location = useLocation();
const isSettingsPage = location.pathname.startsWith("/settings");
const { showProfileSelector, showSettingsButton, expanded, setField } = useAppearance();
const showSettings = showSettingsButton || isSettingsPage;
const didInitialResize = useRef(false);
useEffect(() => {
@@ -28,6 +24,12 @@ export const Header = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!didInitialResize.current) return;
const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH;
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
}, [expanded]);
useEffect(() => {
const onResize = () => {
const isWide = window.innerWidth >= EXPANDED_THRESHOLD;
@@ -44,6 +46,10 @@ export const Header = () => {
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
};
const openSettings = () => {
void WindowsSvc.OpenSettings().catch(() => {});
};
return (
<div
className={cn(
@@ -53,7 +59,7 @@ export const Header = () => {
>
{showProfileSelector && (
<div className={"ml-20"}>
<ProfileSelector email={"eduard@netbird.io"} />
<ProfileSelector />
</div>
)}
@@ -61,15 +67,8 @@ export const Header = () => {
icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon}
onClick={togglePanel}
/>
{showSettings && (
<IconButton
icon={SettingsIcon}
onClick={() => navigate(isSettingsPage ? "/" : "/settings")}
className={cn(
isSettingsPage &&
"bg-nb-gray-910 hover:bg-nb-gray-910 text-nb-gray-200 hover:text-nb-gray-200",
)}
/>
{showSettingsButton && (
<IconButton icon={SettingsIcon} onClick={openSettings} />
)}
</div>
);

View File

@@ -0,0 +1,36 @@
import { Outlet } from "react-router-dom";
import { AppearanceProvider } from "@/modules/appearance/AppearanceContext.tsx";
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
// SettingsLayout wraps the Settings screen for use inside its own dedicated
// window. Same provider stack as AppLayout but without the main Header — the
// settings window has its own native title bar and doesn't show the profile
// selector / panel toggle / settings icon.
//
// The 38px placeholder strip at the top accounts for the macOS
// `MacTitleBarHiddenInset` setting in services/windows.go: the native title
// bar is invisible but the traffic-light buttons still float in the top-left
// corner. Without this strip the buttons would overlap the settings content.
// The strip is `wails-draggable` so users can move the window by dragging it.
export const SettingsLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
<AppearanceProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<div
className={
"wails-draggable cursor-default select-none h-[38px] shrink-0"
}
/>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</AppearanceProvider>
</div>
);
};

View File

@@ -8,11 +8,9 @@ import {
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;
@@ -25,7 +23,6 @@ export type AppearanceState = {
const STORAGE_KEY = "netbird:appearance";
const DEFAULTS: AppearanceState = {
view: "default",
connectionLayout: "default",
expanded: true,
showPeersNav: true,
@@ -47,7 +44,6 @@ const readStored = (): AppearanceState => {
};
type AppearanceContextValue = AppearanceState & {
setView: (v: AppearanceView) => void;
setField: <K extends keyof AppearanceState>(k: K, v: AppearanceState[K]) => void;
};
@@ -79,13 +75,9 @@ export const AppearanceProvider = ({ children }: { children: ReactNode }) => {
[],
);
const setView = useCallback((v: AppearanceView) => {
setState((s) => ({ ...s, view: v }));
}, []);
const value = useMemo<AppearanceContextValue>(
() => ({ ...state, setView, setField }),
[state, setView, setField],
() => ({ ...state, setField }),
[state, setField],
);
return (

View File

@@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { Dialogs } from "@wailsio/runtime";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
@@ -19,8 +20,7 @@ export type DebugStage =
| { kind: "bundling" }
| { kind: "uploading" }
| { kind: "cancelling" }
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
| { kind: "error"; message: string };
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean };
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
@@ -53,10 +53,7 @@ export const useDebugBundle = () => {
const [lastBundlePath, setLastBundlePath] = useState<string>("");
const abortRef = useRef<AbortController | null>(null);
const isRunning =
stage.kind !== "idle" &&
stage.kind !== "done" &&
stage.kind !== "error";
const isRunning = stage.kind !== "idle" && stage.kind !== "done";
const reset = () => setStage({ kind: "idle" });
@@ -157,7 +154,11 @@ export const useDebugBundle = () => {
setStage({ kind: "idle" });
return;
}
setStage({ kind: "error", message: String(e) });
setStage({ kind: "idle" });
await Dialogs.Error({
Title: "Debug Bundle Failed",
Message: e instanceof Error ? e.message : String(e),
});
} finally {
if (abortRef.current === ctrl) abortRef.current = null;
}

View File

@@ -6,15 +6,20 @@ import {
useState,
type ReactNode,
} from "react";
import { Profiles as ProfilesSvc } from "@bindings/services";
import { Dialogs } from "@wailsio/runtime";
import { Connection, Peers, Profiles as ProfilesSvc } from "@bindings/services";
import type { Profile } from "@bindings/services/models.js";
type ProfileContextValue = {
username: string;
activeProfile: string;
profiles: Profile[];
loaded: boolean;
error: string | null;
refresh: () => Promise<void>;
switchProfile: (name: string) => Promise<void>;
addProfile: (name: string) => Promise<void>;
removeProfile: (name: string) => Promise<void>;
logoutProfile: (name: string) => Promise<void>;
};
const ProfileContext = createContext<ProfileContextValue | null>(null);
@@ -30,18 +35,24 @@ export const useProfile = () => {
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const [username, setUsername] = useState("");
const [activeProfile, setActiveProfile] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const u = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
const [active, list] = await Promise.all([
ProfilesSvc.GetActive(),
ProfilesSvc.List(u),
]);
setUsername(u);
setActiveProfile(active.profileName || "default");
setError(null);
setProfiles(list);
} catch (e) {
setError(String(e));
await Dialogs.Error({
Title: "Load Profiles Failed",
Message: e instanceof Error ? e.message : String(e),
});
} finally {
setLoaded(true);
}
@@ -53,10 +64,53 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const switchProfile = useCallback(
async (name: string) => {
// Mirror tray.go switchProfile: only reconnect when the daemon was
// actively online. Calling Up on an Idle/NeedsLogin daemon makes
// the daemon wait 50s on its internal waitForUp and return
// DeadlineExceeded.
let wasActive = false;
try {
const prev = await Peers.Get();
const s = (prev?.status ?? "").toLowerCase();
wasActive = s === "connected" || s === "connecting";
} catch {
wasActive = false;
}
await ProfilesSvc.Switch({ profileName: name, username });
setActiveProfile(name);
if (wasActive) {
await Connection.Down();
await Connection.Up({ profileName: name, username });
}
await refresh();
},
[username],
[username, refresh],
);
const addProfile = useCallback(
async (name: string) => {
await ProfilesSvc.Add({ profileName: name, username });
await refresh();
},
[username, refresh],
);
const removeProfile = useCallback(
async (name: string) => {
await ProfilesSvc.Remove({ profileName: name, username });
await refresh();
},
[username, refresh],
);
const logoutProfile = useCallback(
async (name: string) => {
await Connection.Logout({ profileName: name, username });
await refresh();
},
[username, refresh],
);
return (
@@ -64,10 +118,13 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
value={{
username,
activeProfile,
profiles,
loaded,
error,
refresh,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
}}
>
{children}

View File

@@ -1,26 +1,9 @@
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"}
/>
);
import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx";
export function SettingsAppearance() {
const {
view,
setView,
showPeersNav,
showResourcesNav,
showExitNodeNav,
@@ -30,59 +13,37 @@ export function SettingsAppearance() {
} = 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>
</>
<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

@@ -7,11 +7,15 @@ import {
useState,
type ReactNode,
} from "react";
import { Dialogs } from "@wailsio/runtime";
import { Settings as SettingsSvc } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
const errorMessage = (e: unknown) =>
e instanceof Error ? e.message : String(e);
const SAVE_DEBOUNCE_MS = 400;
type SettingsContextValue = {
@@ -35,7 +39,6 @@ export const useSettings = () => {
const useSettingsState = () => {
const { username, activeProfile, loaded: profileLoaded } = useProfile();
const [config, setConfig] = useState<Config | null>(null);
const [error, setError] = useState<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -47,9 +50,11 @@ const useSettingsState = () => {
username,
});
setConfig(c);
setError(null);
} catch (e) {
setError(String(e));
await Dialogs.Error({
Title: "Load Settings Failed",
Message: errorMessage(e),
});
}
})();
}, [profileLoaded, activeProfile, username]);
@@ -75,9 +80,11 @@ const useSettingsState = () => {
profileName: activeProfile,
username,
});
setError(null);
} catch (e) {
setError(String(e));
await Dialogs.Error({
Title: "Save Settings Failed",
Message: errorMessage(e),
});
}
},
[activeProfile, username],
@@ -135,33 +142,29 @@ const useSettingsState = () => {
[config, save],
);
return { config, error, setField, saveField, saveFields, saveNow };
return { config, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, error, setField, saveField, saveFields, saveNow } = useSettingsState();
const { config, setField, saveField, saveFields, saveNow } = useSettingsState();
// TODO: Better displaying of errors
return (
<>
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SkeletonSettings />
) : (
<SettingsContext.Provider
value={{
config,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
)}
</div>
</>
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SkeletonSettings />
) : (
<SettingsContext.Provider
value={{
config,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
)}
</div>
);
};

View File

@@ -31,8 +31,14 @@ export function SettingsTroubleshooting() {
reset,
} = useDebugBundleContext();
if (stage.kind === "done" || stage.kind === "error") {
return <ResultSection stage={stage} onClose={reset} />;
if (stage.kind === "done") {
return (
<DoneResult
result={stage.result}
uploaded={stage.uploadAttempted}
onClose={reset}
/>
);
}
if (stage.kind !== "idle") {
return <ProgressSection stage={stage} onCancel={cancel} />;
@@ -127,30 +133,6 @@ function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: ()
);
}
function ResultSection({
stage,
onClose,
}: {
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
onClose: () => void;
}) {
if (stage.kind === "error") {
return (
<StatusPanel
variant={"error"}
title={"Bundle failed"}
description={stage.message}
actions={
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
Close
</Button>
}
/>
);
}
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
}
function DoneResult({
result,
uploaded,

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { Dialogs } from "@wailsio/runtime";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export enum ManagementMode {
@@ -52,13 +53,29 @@ export function useManagementUrl() {
}, [config.managementUrl]);
const setMode = (next: ManagementMode) => {
setModeState(next);
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm before applying.
void Dialogs.Warning({
Title: "Switch to NetBird Cloud?",
Message:
"This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
Buttons: [
{ Label: "Cancel", IsCancel: true, IsDefault: true },
{ Label: "Switch to Cloud" },
],
}).then((result) => {
if (result !== "Switch to Cloud") return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
});
return;
}
setModeState(next);
};
const normalizedUrl = normalizeManagementUrl(url);

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
import { Button } from "../components/Button";
export default function LoginUrl() {
const [url, setUrl] = useState<string>("");
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
setUrl(params.get("url") ?? "");
}, []);
if (!url) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-nb-gray-500">
No login URL provided.
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Continue in your browser</h1>
<p className="max-w-sm text-sm text-nb-gray-500">
Open the following URL to finish signing in.
</p>
<Button onClick={() => Connection.OpenURL(url).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Open URL
</Button>
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">{url}</p>
</div>
);
}

View File

@@ -12,13 +12,16 @@ export default function Status() {
const { status, error } = useStatus();
const navigate = useNavigate();
const connState = status?.status ?? "Disconnected";
const connState = status?.status ?? "Idle";
const connected = connState === "Connected";
const connecting = connState === "Connecting";
// The daemon reports "NeedsLogin" on a fresh install or after a session
// expires; "SessionExpired" once a previously good session lapses. In both
// cases Connect would fail without a fresh SSO login.
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired" || connState === "LoginFailed";
// DaemonUnavailable is the synthetic status the UI emits when the gRPC
// socket is unreachable; Up/Down would just error, so the toggle is dead.
const unreachable = connState === "DaemonUnavailable";
// Always offer Login while we aren't Connected — including Connecting,
// because a stuck Login on the daemon leaves us in Connecting forever and
// the user has no other way out. Disconnect is the manual unstick path.
@@ -32,7 +35,17 @@ export default function Status() {
const login = () => navigate("/login");
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
const disconnect = () => Connection.Down().catch(console.error);
const toggleConnection = () => (connected ? disconnect() : connect());
const toggleConnection = () => {
if (needsLogin) {
navigate("/login");
return;
}
if (connected) {
disconnect();
return;
}
connect();
};
return (
<div className="space-y-4 p-6">
@@ -107,8 +120,12 @@ export default function Status() {
})()}
</Card>
<div className="flex justify-center bg-nb-gray p-10">
<NetBirdConnectToggle state={toggleState} onClick={toggleConnection} />
<div className="flex justify-center py-6">
<NetBirdConnectToggle
state={toggleState}
onClick={toggleConnection}
disabled={unreachable}
/>
</div>
</div>
);

View File

@@ -1,22 +1,31 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { Dialogs } from "@wailsio/runtime";
import { Update as UpdateSvc } from "@bindings/services";
const TIMEOUT_MS = 15 * 60 * 1000;
const showError = (message: string) =>
Dialogs.Error({ Title: "Update Failed", Message: message });
export default function Update() {
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e)));
UpdateSvc.Trigger().catch((e) => {
if (cancelled) return;
setFailed(true);
void showError(e instanceof Error ? e.message : String(e));
});
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > TIMEOUT_MS) {
setError("Update timed out.");
clearInterval(timer);
setFailed(true);
void showError("Update timed out.");
return;
}
try {
@@ -25,8 +34,9 @@ export default function Update() {
setDone(true);
clearInterval(timer);
} else if (r.errorMsg) {
setError(r.errorMsg);
clearInterval(timer);
setFailed(true);
void showError(r.errorMsg);
}
} catch {
// installer not finished yet
@@ -44,8 +54,8 @@ export default function Update() {
<div className="text-center">
{done ? (
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
) : error ? (
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
) : failed ? (
<h1 className="text-xl font-semibold text-red-500">Update failed</h1>
) : (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />