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