mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 21:59:07 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -9,18 +9,12 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
|
||||
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Daemon-down is already surfaced globally by DaemonUnavailableOverlay and
|
||||
// (for Trigger) handled by the install window's polling-grace branch; a
|
||||
// second popup on top of those is pure noise. Every Update RPC routes
|
||||
// through the shared gRPC conn, so the Unavailable code is the marker.
|
||||
const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
@@ -81,9 +75,6 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Force-install branch: daemon's progress_window:show flipped installing
|
||||
// to true while the UI was idle. Open the install window so the user
|
||||
// sees the progress UI without having to click anything.
|
||||
const prevInstallingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (state.installing && !prevInstallingRef.current) {
|
||||
@@ -92,19 +83,11 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
prevInstallingRef.current = state.installing;
|
||||
}, [state.installing, state.version]);
|
||||
|
||||
// Enforced user-driven branch: kick Trigger() in the background, then
|
||||
// hand off to the install window. The window owns the polling loop and
|
||||
// the final Quit() — this provider just fires the trigger.
|
||||
const triggerUpdate = useCallback(() => {
|
||||
setUpdating(true);
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
UpdateSvc.Trigger()
|
||||
.catch(async (e) => {
|
||||
// The daemon may already be down (force-install branch raced
|
||||
// us). The install window's polling loop handles that case.
|
||||
// Anything else is a real failure — close the install window
|
||||
// (otherwise it spins forever on a daemon that won't ever
|
||||
// produce a result) and surface the error.
|
||||
if (isDaemonUnavailable(e)) return;
|
||||
WindowManager.CloseInstallProgress().catch(console.error);
|
||||
await errorDialog({
|
||||
@@ -127,9 +110,5 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
[state, triggerUpdate, updating],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientVersionContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientVersionContext.Provider>
|
||||
);
|
||||
return <ClientVersionContext.Provider value={value}>{children}</ClientVersionContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import {
|
||||
Connection as ConnectionSvc,
|
||||
Debug as DebugSvc,
|
||||
} from "@bindings/services";
|
||||
import { createContext, useContext, useRef, useState, type ReactNode } from "react";
|
||||
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -47,8 +37,64 @@ const sleep = (ms: number, signal: AbortSignal) =>
|
||||
signal.addEventListener("abort", onAbort);
|
||||
});
|
||||
|
||||
const isAbort = (e: unknown) =>
|
||||
e instanceof DOMException && e.name === "AbortError";
|
||||
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
||||
|
||||
const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const setLogLevelBestEffort = async (level: string) => {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level });
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
};
|
||||
|
||||
type LevelState = { original: string; raised: boolean };
|
||||
|
||||
const runTracePhase = async (
|
||||
signal: AbortSignal,
|
||||
level: LevelState,
|
||||
setStage: (s: DebugStage) => void,
|
||||
target: { profileName: string; username: string },
|
||||
traceMinutes: number,
|
||||
) => {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) level.original = cur.level;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
level.raised = true;
|
||||
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await ConnectionSvc.Up(target);
|
||||
|
||||
const totalSec = Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({ kind: "capturing", remainingSec: remaining, totalSec });
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: level.original });
|
||||
level.raised = false;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
};
|
||||
|
||||
const useDebugBundle = () => {
|
||||
const { activeProfile, username } = useProfile();
|
||||
@@ -75,66 +121,24 @@ const useDebugBundle = () => {
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
const signal = ctrl.signal;
|
||||
const checkAbort = () => {
|
||||
if (signal.aborted)
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
};
|
||||
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
let originalLevel = "info";
|
||||
let raisedLevel = false;
|
||||
const level: LevelState = { original: "info", raised: false };
|
||||
|
||||
try {
|
||||
if (trace) {
|
||||
setStage({ kind: "preparing-trace" });
|
||||
try {
|
||||
const cur = await DebugSvc.GetLogLevel();
|
||||
if (cur?.level) originalLevel = cur.level;
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
checkAbort();
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
raisedLevel = true;
|
||||
|
||||
checkAbort();
|
||||
setStage({ kind: "reconnecting" });
|
||||
try {
|
||||
await ConnectionSvc.Down();
|
||||
} catch {
|
||||
// already down
|
||||
}
|
||||
checkAbort();
|
||||
await ConnectionSvc.Up({
|
||||
profileName: activeProfile,
|
||||
username,
|
||||
});
|
||||
|
||||
const totalSec =
|
||||
Math.max(1, Math.min(30, traceMinutes)) * 60;
|
||||
for (let remaining = totalSec; remaining > 0; remaining--) {
|
||||
setStage({
|
||||
kind: "capturing",
|
||||
remainingSec: remaining,
|
||||
totalSec,
|
||||
});
|
||||
await sleep(1000, signal);
|
||||
}
|
||||
|
||||
setStage({ kind: "restoring-level" });
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
raisedLevel = false;
|
||||
} catch {
|
||||
// restore is best-effort
|
||||
}
|
||||
await runTracePhase(
|
||||
signal,
|
||||
level,
|
||||
setStage,
|
||||
{ profileName: activeProfile, username },
|
||||
traceMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
checkAbort();
|
||||
throwIfAborted(signal);
|
||||
setStage({ kind: "bundling" });
|
||||
const logFileCount = trace
|
||||
? TRACE_LOG_FILE_COUNT
|
||||
: PLAIN_LOG_FILE_COUNT;
|
||||
const logFileCount = trace ? TRACE_LOG_FILE_COUNT : PLAIN_LOG_FILE_COUNT;
|
||||
|
||||
if (uploadUrl) setStage({ kind: "uploading" });
|
||||
const result = await DebugSvc.Bundle({
|
||||
@@ -143,7 +147,7 @@ const useDebugBundle = () => {
|
||||
uploadUrl,
|
||||
logFileCount,
|
||||
});
|
||||
checkAbort();
|
||||
throwIfAborted(signal);
|
||||
if (result.path) setLastBundlePath(result.path);
|
||||
setStage({
|
||||
kind: "done",
|
||||
@@ -152,13 +156,7 @@ const useDebugBundle = () => {
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbort(e)) {
|
||||
if (raisedLevel) {
|
||||
try {
|
||||
await DebugSvc.SetLogLevel({ level: originalLevel });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
if (level.raised) await setLogLevelBestEffort(level.original);
|
||||
setStage({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
@@ -174,7 +172,9 @@ const useDebugBundle = () => {
|
||||
|
||||
const openBundleDir = () => {
|
||||
if (!lastBundlePath) return;
|
||||
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
|
||||
DebugSvc.RevealFile(lastBundlePath).catch((err: unknown) =>
|
||||
console.error("[DebugBundleContext] reveal failed", err),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -204,19 +204,13 @@ const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
return (
|
||||
<DebugBundleContext.Provider value={value}>
|
||||
{children}
|
||||
</DebugBundleContext.Provider>
|
||||
);
|
||||
return <DebugBundleContext.Provider value={value}>{children}</DebugBundleContext.Provider>;
|
||||
};
|
||||
|
||||
export const useDebugBundleContext = () => {
|
||||
const ctx = useContext(DebugBundleContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useDebugBundleContext must be used inside DebugBundleProvider",
|
||||
);
|
||||
throw new Error("useDebugBundleContext must be used inside DebugBundleProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
|
||||
|
||||
// DialogContext exposes an imperative `confirm(...)` that resolves to a
|
||||
// boolean — the in-app equivalent of a native confirmation dialog. The
|
||||
// single <ConfirmModal/> lives here at the provider level, so call sites
|
||||
// just `await confirm({...})` instead of each wiring up their own modal
|
||||
// component + open/busy state.
|
||||
//
|
||||
// const confirm = useConfirm();
|
||||
// if (await confirm({ title, description, confirmLabel })) { …do it… }
|
||||
//
|
||||
// Mounted once (outermost in AppLayout) so it's available in every in-window
|
||||
// route across both the main and settings windows.
|
||||
export type ConfirmOptions = {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
confirmLabel: string;
|
||||
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
|
||||
cancelLabel?: string;
|
||||
/** Use the destructive (red) confirm button variant. */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
@@ -28,7 +23,7 @@ type DialogContextValue = {
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<((result: boolean) => void) | null>(null);
|
||||
@@ -41,17 +36,16 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Resolve the pending promise and start the close animation. The options
|
||||
// stay in state so ConfirmModal still has content to render while it
|
||||
// animates out.
|
||||
const settle = (result: boolean) => {
|
||||
resolverRef.current?.(result);
|
||||
resolverRef.current = null;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ confirm }}>
|
||||
<DialogContext.Provider value={value}>
|
||||
{children}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks";
|
||||
|
||||
@@ -12,18 +12,13 @@ const NavSectionContext = createContext<NavSectionContextValue | null>(null);
|
||||
export const useNavSection = (): NavSectionContextValue => {
|
||||
const ctx = useContext(NavSectionContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useNavSection must be used inside NavSectionProvider",
|
||||
);
|
||||
throw new Error("useNavSection must be used inside NavSectionProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [section, setSection] = useState<NavSection>("peers");
|
||||
return (
|
||||
<NavSectionContext.Provider value={{ section, setSection }}>
|
||||
{children}
|
||||
</NavSectionContext.Provider>
|
||||
);
|
||||
const value = useMemo<NavSectionContextValue>(() => ({ section, setSection }), [section]);
|
||||
return <NavSectionContext.Provider value={value}>{children}</NavSectionContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -12,10 +12,9 @@ import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
// A range is treated as an exit-node candidate when any of its CIDRs is a
|
||||
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
|
||||
// comma-joined range string for one peer.
|
||||
export const isDefaultRoute = (range: string): boolean =>
|
||||
// A route that covers all traffic (0.0.0.0/0 or ::/0) is an exit node.
|
||||
// The daemon may merge a v4+v6 pair into a single comma-joined range string.
|
||||
export const isExitNode = (range: string): boolean =>
|
||||
range.split(",").some((part) => {
|
||||
const trimmed = part.trim();
|
||||
return trimmed === "0.0.0.0/0" || trimmed === "::/0";
|
||||
@@ -45,33 +44,61 @@ export const useNetworks = () => {
|
||||
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { status } = useStatus();
|
||||
const [routes, setRoutes] = useState<Network[]>([]);
|
||||
// Optimistic overrides: id → expected `selected` value. Applied on top of
|
||||
// the server-side `routes` so toggles paint instantly. Entries are cleared
|
||||
// either when the next server snapshot agrees (success path) or when the
|
||||
// RPC throws (rollback). Linear-style optimistic mutation tracking.
|
||||
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
|
||||
// Mirror of `pending` for use inside async callbacks without re-binding
|
||||
// them on every change.
|
||||
const pendingRef = useRef(pending);
|
||||
useEffect(() => {
|
||||
pendingRef.current = pending;
|
||||
}, [pending]);
|
||||
|
||||
const setPendingFor = useCallback((updates: Array<[string, boolean]>) => {
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, sel] of updates) next.set(id, sel);
|
||||
return next;
|
||||
});
|
||||
// Safety timer: if a prediction diverges from the daemon, the override would mask the true value forever.
|
||||
const STUCK_OVERRIDE_MS = 4000;
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const clearTimer = useCallback((id: string) => {
|
||||
const tid = timersRef.current.get(id);
|
||||
if (tid !== undefined) {
|
||||
clearTimeout(tid);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPendingFor = useCallback((ids: string[]) => {
|
||||
setPending((prev) => {
|
||||
if (ids.every((id) => !prev.has(id))) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const clearPendingFor = useCallback(
|
||||
(ids: string[]) => {
|
||||
for (const id of ids) clearTimer(id);
|
||||
setPending((prev) => {
|
||||
if (ids.every((id) => !prev.has(id))) return prev;
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const setPendingFor = useCallback(
|
||||
(updates: Array<[string, boolean]>) => {
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, sel] of updates) next.set(id, sel);
|
||||
return next;
|
||||
});
|
||||
for (const [id] of updates) {
|
||||
clearTimer(id);
|
||||
timersRef.current.set(
|
||||
id,
|
||||
setTimeout(() => clearPendingFor([id]), STUCK_OVERRIDE_MS),
|
||||
);
|
||||
}
|
||||
},
|
||||
[clearTimer, clearPendingFor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const tid of timers.values()) clearTimeout(tid);
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -83,19 +110,11 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The daemon bumps networksRevision whenever the routed-network set or a
|
||||
// selection changes (from any surface) and pushes it on the status stream.
|
||||
// Refetch on every bump so the list stays live without polling — and on
|
||||
// mount, since the revision is already defined by the time this provider
|
||||
// renders (StatusProvider only mounts children once the daemon is reachable).
|
||||
const networksRevision = status?.networksRevision;
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
refresh().catch((err: unknown) => console.error("[NetworksContext] refresh failed", err));
|
||||
}, [refresh, networksRevision]);
|
||||
|
||||
// When the server snapshot agrees with a pending optimistic value, the
|
||||
// mutation is confirmed — drop the override so the row tracks the server
|
||||
// again. Runs whenever routes change.
|
||||
useEffect(() => {
|
||||
if (pendingRef.current.size === 0) return;
|
||||
const confirmed: string[] = [];
|
||||
@@ -116,13 +135,10 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
} else {
|
||||
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: false });
|
||||
}
|
||||
// Don't clear pending here — let the revision-driven refresh
|
||||
// confirm via the snapshot-match effect. That avoids a flash
|
||||
// back to old state if the refresh races the RPC return.
|
||||
// Don't clear pending here — let the snapshot-match effect confirm, else a refresh racing the RPC return flashes back.
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// Roll back to the last server-observed value for each id.
|
||||
setPending((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id] of rollback) next.delete(id);
|
||||
@@ -143,9 +159,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
[mutate, setPendingFor],
|
||||
);
|
||||
|
||||
// Batch toggle for the bottom-bar select-all switch. The daemon's
|
||||
// Select/Deselect RPCs accept an ID list natively, so we don't fan out
|
||||
// per-ID calls — one round-trip + one refresh.
|
||||
const setNetworksSelected = useCallback(
|
||||
async (ids: string[], selected: boolean) => {
|
||||
if (ids.length === 0) return;
|
||||
@@ -160,11 +173,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
[mutate, setPendingFor, routes],
|
||||
);
|
||||
|
||||
// Exit nodes are mutually exclusive, but the daemon enforces that now —
|
||||
// selecting one deselects the other exit nodes. Append so activating an
|
||||
// exit node doesn't wipe the user's network-route selections. We also
|
||||
// mirror that mutual-exclusion locally so the optimistic paint matches
|
||||
// the daemon's eventual state.
|
||||
// Daemon enforces exit-node mutual exclusion; mirror it locally so the optimistic paint matches.
|
||||
const toggleExitNode = useCallback(
|
||||
async (id: string, selected: boolean) => {
|
||||
const target = !selected;
|
||||
@@ -172,7 +181,7 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
const rollback: Array<[string, boolean]> = [[id, selected]];
|
||||
if (target) {
|
||||
for (const r of routes) {
|
||||
if (r.id !== id && isDefaultRoute(r.range) && r.selected) {
|
||||
if (r.id !== id && isExitNode(r.range) && r.selected) {
|
||||
updates.push([r.id, false]);
|
||||
rollback.push([r.id, true]);
|
||||
}
|
||||
@@ -185,9 +194,6 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
);
|
||||
|
||||
const value = useMemo<NetworksContextValue>(() => {
|
||||
// Apply pending overrides on top of the server snapshot. The override
|
||||
// map is usually empty or tiny (one entry per in-flight toggle), so
|
||||
// the per-route lookup is effectively free.
|
||||
const effective =
|
||||
pending.size === 0
|
||||
? routes
|
||||
@@ -197,8 +203,8 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||
? r
|
||||
: { ...r, selected: override };
|
||||
});
|
||||
const networkRoutes = effective.filter((r) => !isDefaultRoute(r.range));
|
||||
const exitNodes = effective.filter((r) => isDefaultRoute(r.range));
|
||||
const networkRoutes = effective.filter((r) => !isExitNode(r.range));
|
||||
const exitNodes = effective.filter((r) => isExitNode(r.range));
|
||||
const activeExitNode = exitNodes.find((r) => r.selected) ?? null;
|
||||
return {
|
||||
routes: effective,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
|
||||
type PeerDetailContextValue = {
|
||||
@@ -11,18 +11,13 @@ const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
|
||||
export const usePeerDetail = (): PeerDetailContextValue => {
|
||||
const ctx = useContext(PeerDetailContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"usePeerDetail must be used inside PeerDetailProvider",
|
||||
);
|
||||
throw new Error("usePeerDetail must be used inside PeerDetailProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [selected, setSelected] = useState<PeerStatus | null>(null);
|
||||
return (
|
||||
<PeerDetailContext.Provider value={{ selected, setSelected }}>
|
||||
{children}
|
||||
</PeerDetailContext.Provider>
|
||||
);
|
||||
const value = useMemo<PeerDetailContextValue>(() => ({ selected, setSelected }), [selected]);
|
||||
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -3,19 +3,15 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import {
|
||||
Connection,
|
||||
ProfileSwitcher,
|
||||
Profiles as ProfilesSvc,
|
||||
} from "@bindings/services";
|
||||
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
@@ -58,10 +54,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
setActiveProfile(active.profileName || "default");
|
||||
setProfiles(list);
|
||||
} catch (e) {
|
||||
// Daemon-down is already surfaced globally by
|
||||
// DaemonUnavailableOverlay; a second popup on top of it is
|
||||
// pure noise. Every profile RPC routes through the same gRPC
|
||||
// conn, so the Unavailable code is the reliable marker.
|
||||
// Daemon-down is already surfaced by DaemonUnavailableOverlay; swallow it here.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("code = Unavailable")) {
|
||||
return;
|
||||
@@ -76,16 +69,14 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
refresh().catch((err: unknown) => console.error("[ProfileContext] refresh failed", err));
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
// The tray and other windows drive switches through the same
|
||||
// ProfileSwitcher.SwitchActive RPC, which emits this event on success.
|
||||
// Without the subscription, a tray-initiated switch leaves this
|
||||
// window painting the old activeProfile until the next mount.
|
||||
const off = Events.On(EVENT_PROFILE_CHANGED, () => {
|
||||
void refresh();
|
||||
refresh().catch((err: unknown) =>
|
||||
console.error("[ProfileContext] refresh failed", err),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
off();
|
||||
@@ -124,21 +115,30 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider
|
||||
value={{
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
const value = useMemo<ProfileContextValue>(
|
||||
() => ({
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
}),
|
||||
[
|
||||
username,
|
||||
activeProfile,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
],
|
||||
);
|
||||
|
||||
return <ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -3,20 +3,22 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
const logSaveError = (err: unknown) => console.error("[SettingsContext] save failed", err);
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
type SettingsContextValue = {
|
||||
@@ -47,9 +49,7 @@ export const useSettings = () => {
|
||||
export const useAutostartSetting = () => {
|
||||
const ctx = useContext(AutostartContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useAutostartSetting must be used inside AutostartSettingsProvider",
|
||||
);
|
||||
throw new Error("useAutostartSetting must be used inside AutostartSettingsProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
@@ -97,10 +97,7 @@ const useSettingsState = () => {
|
||||
|
||||
const save = useCallback(
|
||||
async (next: Config) => {
|
||||
// The daemon masks an existing PSK as "**********" in GetConfig.
|
||||
// Sending the mask back round-trips it into the saved config and
|
||||
// wgtypes.ParseKey fails on the next connect. Drop the mask so
|
||||
// unrelated toggles don't corrupt the stored PSK.
|
||||
// Sending the "**********" PSK mask back corrupts the stored PSK (wgtypes.ParseKey fails next connect).
|
||||
const { preSharedKey, ...rest } = next;
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
@@ -126,7 +123,7 @@ const useSettingsState = () => {
|
||||
const next = { ...c, [k]: v };
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
void save(next);
|
||||
save(next).catch(logSaveError);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return next;
|
||||
});
|
||||
@@ -175,26 +172,19 @@ const useSettingsState = () => {
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } =
|
||||
useSettingsState();
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<SettingsSkeleton />
|
||||
{value ? (
|
||||
<SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
config,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
<SettingsSkeleton />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -232,9 +222,10 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AutostartContext.Provider value={{ autostart, setAutostartEnabled }}>
|
||||
{children}
|
||||
</AutostartContext.Provider>
|
||||
const value = useMemo<AutostartContextValue>(
|
||||
() => ({ autostart, setAutostartEnabled }),
|
||||
[autostart, setAutostartEnabled],
|
||||
);
|
||||
|
||||
return <AutostartContext.Provider value={value}>{children}</AutostartContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { DaemonFeed } from "@bindings/services";
|
||||
import type { Status } from "@bindings/services/models.js";
|
||||
import { Status } from "@bindings/services/models.js";
|
||||
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
|
||||
|
||||
const EVENT_STATUS = "netbird:status";
|
||||
|
||||
// StatusContext is the single subscription point for the daemon status
|
||||
// stream. It owns the initial DaemonFeed.Get, the netbird:status event listener,
|
||||
// and the synthetic DaemonUnavailable handling. The provider also renders
|
||||
// the DaemonUnavailableOverlay so every layout that mounts it inherits the
|
||||
// same blocker without re-importing the component.
|
||||
//
|
||||
// Boolean flags consumers should prefer over hand-rolled checks:
|
||||
// - isReady first DaemonFeed.Get has resolved
|
||||
// - isDaemonUnavailable ready and status === "DaemonUnavailable"
|
||||
// - isDaemonAvailable ready and status !== "DaemonUnavailable"
|
||||
type StatusContextValue = {
|
||||
status: Status | null;
|
||||
error: string | null;
|
||||
@@ -45,20 +43,14 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
||||
setStatus(s);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
// DaemonFeed.Get returns a gRPC error when the socket itself is
|
||||
// unreachable (daemon not running, missing socket, etc.); only
|
||||
// the streaming path synthesizes a DaemonUnavailable status.
|
||||
// Synthesize one here too so the overlay paints on cold start
|
||||
// without a daemon — otherwise the whole UI stays blank since
|
||||
// `isReady` would never flip and StatusProvider's short-circuit
|
||||
// wouldn't render either children or the overlay.
|
||||
setStatus({ status: "DaemonUnavailable" } as Status);
|
||||
// Synthesize DaemonUnavailable so cold-start-without-daemon isn't a blank UI (isReady stays false otherwise).
|
||||
setStatus(Status.createFrom({ status: "DaemonUnavailable" }));
|
||||
setError(String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
refresh().catch((err: unknown) => console.error("[StatusContext] refresh failed", err));
|
||||
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
|
||||
setStatus(ev.data);
|
||||
setError(null);
|
||||
@@ -72,23 +64,20 @@ export const StatusProvider = ({ children }: { children: ReactNode }) => {
|
||||
const isDaemonUnavailable = isReady && status.status === "DaemonUnavailable";
|
||||
const isDaemonAvailable = isReady && !isDaemonUnavailable;
|
||||
|
||||
// Don't mount children until the first DaemonFeed.Get has resolved and the
|
||||
// daemon is reachable. Consumers (ProfileContext, SettingsContext, …)
|
||||
// can then assume any daemon RPC they make at mount will reach the
|
||||
// socket — no per-context availability gating. When the daemon flips
|
||||
// back to unavailable the children unmount and remount fresh once it
|
||||
// returns.
|
||||
const value = useMemo<StatusContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
error,
|
||||
refresh,
|
||||
isReady,
|
||||
isDaemonUnavailable,
|
||||
isDaemonAvailable,
|
||||
}),
|
||||
[status, error, refresh, isReady, isDaemonUnavailable, isDaemonAvailable],
|
||||
);
|
||||
|
||||
return (
|
||||
<StatusContext.Provider
|
||||
value={{
|
||||
status,
|
||||
error,
|
||||
refresh,
|
||||
isReady,
|
||||
isDaemonUnavailable,
|
||||
isDaemonAvailable,
|
||||
}}
|
||||
>
|
||||
<StatusContext.Provider value={value}>
|
||||
{isDaemonAvailable && children}
|
||||
<DaemonUnavailableOverlay />
|
||||
</StatusContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -13,13 +14,8 @@ import { ViewMode as ViewModePref } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ViewMode = "default" | "advanced";
|
||||
|
||||
// Window widths per view. Height stays at whatever the window was first
|
||||
// created with — we deliberately don't pass a fixed height to
|
||||
// Window.SetSize because Wails' macOS implementation interprets it as the
|
||||
// outer frame (windowSetSize → setFrame:), while the initial creation
|
||||
// uses initWithContentRect:. The two differ by one title-bar height
|
||||
// (~28px), so re-asserting 640 here would chop ~28px off the content
|
||||
// area on the first switch and visually shift everything inside.
|
||||
// Don't pass a fixed height to Window.SetSize: macOS SetSize is frame (incl. ~28px
|
||||
// title bar) while creation is content, so re-asserting a constant chops the content on first switch.
|
||||
export const VIEW_WIDTH: Record<ViewMode, number> = {
|
||||
default: 380,
|
||||
advanced: 900,
|
||||
@@ -33,18 +29,12 @@ type ViewModeContextValue = {
|
||||
const ViewModeContext = createContext<ViewModeContextValue | null>(null);
|
||||
|
||||
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [viewMode, setMode] = useState<ViewMode>("default");
|
||||
// Mirror of viewMode for dedup inside the async setViewMode without
|
||||
// adding the state to the callback's dep array (which would re-create
|
||||
// the callback on every change).
|
||||
const [mode, setMode] = useState<ViewMode>("default");
|
||||
const modeRef = useRef<ViewMode>("default");
|
||||
|
||||
// Hydrate from the persisted preference. The Go side has already sized
|
||||
// the main window to match (see main.go), so this only catches the
|
||||
// React state and dropdown checkmark up — no resize is triggered here.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Preferences.Get()
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled) return;
|
||||
const saved = prefs?.viewMode as ViewMode | undefined;
|
||||
@@ -59,31 +49,30 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Resize the window BEFORE flipping React state — otherwise the new
|
||||
// layout (e.g., advanced-mode right panel mounting) paints into a
|
||||
// window that hasn't grown yet, causing a brief flex-overflow that
|
||||
// wobbles the connect toggle's position. Cost: one IPC roundtrip
|
||||
// (~30ms) before the dropdown checkmark updates.
|
||||
// Resize before flipping React state, else the layout paints into a window that hasn't grown yet.
|
||||
const setViewMode = useCallback((mode: ViewMode) => {
|
||||
if (modeRef.current === mode) return;
|
||||
modeRef.current = mode;
|
||||
void (async () => {
|
||||
// Reuse the live frame height instead of asserting a
|
||||
// constant — keeps content area stable across switches
|
||||
// (see VIEW_WIDTH comment above).
|
||||
(async () => {
|
||||
const size = await Window.Size().catch(() => null);
|
||||
const width = VIEW_WIDTH[mode];
|
||||
const height = size?.height ?? 640;
|
||||
await Window.SetSize(width, height).catch(() => {});
|
||||
setMode(mode);
|
||||
void Preferences.SetViewMode(mode as unknown as ViewModePref).catch(() => {});
|
||||
})();
|
||||
const pref =
|
||||
mode === "advanced" ? ViewModePref.ViewModeAdvanced : ViewModePref.ViewModeDefault;
|
||||
Preferences.SetViewMode(pref).catch((err: unknown) =>
|
||||
console.error("[ViewModeContext] SetViewMode failed", err),
|
||||
);
|
||||
})().catch((err: unknown) => console.error("[ViewModeContext] setViewMode failed", err));
|
||||
}, []);
|
||||
return (
|
||||
<ViewModeContext.Provider value={{ viewMode, setViewMode }}>
|
||||
{children}
|
||||
</ViewModeContext.Provider>
|
||||
|
||||
const value = useMemo<ViewModeContextValue>(
|
||||
() => ({ viewMode: mode, setViewMode }),
|
||||
[mode, setViewMode],
|
||||
);
|
||||
|
||||
return <ViewModeContext.Provider value={value}>{children}</ViewModeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useViewMode = () => {
|
||||
|
||||
Reference in New Issue
Block a user