diff --git a/client/ui-wails/frontend/src/components/Button.tsx b/client/ui-wails/frontend/src/components/Button.tsx index 5d52db8ba..24792d192 100644 --- a/client/ui-wails/frontend/src/components/Button.tsx +++ b/client/ui-wails/frontend/src/components/Button.tsx @@ -1,12 +1,14 @@ import { cva, VariantProps } from "class-variance-authority"; import classNames from "classnames"; -import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Check, Copy } from "lucide-react"; +import { ButtonHTMLAttributes, forwardRef, useState } from "react"; export type ButtonVariants = VariantProps; export interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { disabled?: boolean; stopPropagation?: boolean; + copy?: string; } export const buttonVariants = cva( @@ -84,7 +86,7 @@ export const buttonVariants = cva( ], }, size: { - xs: "text-xs py-2 px-4", + xs: "text-xs py-2 px-3.5", xs2: "text-[0.78rem] py-2 px-4", sm: "text-sm py-[9px] px-4", md: "text-md py-[9px] px-4", @@ -115,10 +117,13 @@ export const Button = forwardRef(function Button className, onClick, disabled, + copy, ...props }, ref, ) { + const [copied, setCopied] = useState(false); + const iconSize = size === "xs" ? 12 : 14; return ( ); diff --git a/client/ui-wails/frontend/src/components/Input.tsx b/client/ui-wails/frontend/src/components/Input.tsx index 0f6738c0e..8dbab86a2 100644 --- a/client/ui-wails/frontend/src/components/Input.tsx +++ b/client/ui-wails/frontend/src/components/Input.tsx @@ -1,21 +1,12 @@ import { cva, VariantProps } from "class-variance-authority"; -import { ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react"; -import { - forwardRef, - InputHTMLAttributes, - ReactNode, - useId, - useRef, - useState, -} from "react"; +import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react"; +import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react"; import { cn } from "@/lib/cn"; import { Label } from "@/components/Label"; type InputVariants = VariantProps; -export interface InputProps - extends InputHTMLAttributes, - InputVariants { +export interface InputProps extends InputHTMLAttributes, InputVariants { label?: string; customPrefix?: ReactNode; customSuffix?: ReactNode; @@ -24,6 +15,7 @@ export interface InputProps error?: string; prefixClassName?: string; showPasswordToggle?: boolean; + copy?: boolean; } const inputVariants = cva("", { @@ -46,9 +38,7 @@ const inputVariants = cva("", { default: [ "dark:bg-nb-gray-900 border-neutral-200 dark:border-nb-gray-700 text-nb-gray-300", ], - error: [ - "dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500", - ], + error: ["dark:bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"], }, }, }); @@ -66,19 +56,20 @@ export const Input = forwardRef(function Input( variant = "default", prefixClassName, showPasswordToggle = false, + copy = false, id, ...props }, ref, ) { const [showPassword, setShowPassword] = useState(false); + const [copied, setCopied] = useState(false); const isPasswordType = type === "password"; const inputType = isPasswordType && showPassword ? "text" : type; const isNumber = type === "number"; const reactId = useId(); - const inputId = - id ?? (label ? `input-${reactId}` : undefined); + const inputId = id ?? (label ? `input-${reactId}` : undefined); const internalRef = useRef(null); const setRefs = (el: HTMLInputElement | null) => { @@ -118,7 +109,30 @@ export const Input = forwardRef(function Input( ) : null; - const suffix = passwordToggle || customSuffix; + const onCopy = async () => { + const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? ""); + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // ignore + } + }; + + const copyToggle = copy ? ( + + ) : null; + + const suffix = passwordToggle || copyToggle || customSuffix; const showStepper = isNumber; return ( @@ -129,9 +143,7 @@ export const Input = forwardRef(function Input(
(function Input( icon && "!pl-10", "border", props.readOnly && - "!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800", + "!bg-nb-gray-910 text-nb-gray-400 !border-nb-gray-800", showStepper && "!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]", className, diff --git a/client/ui-wails/frontend/src/components/StatusPanel.tsx b/client/ui-wails/frontend/src/components/StatusPanel.tsx new file mode 100644 index 000000000..937157201 --- /dev/null +++ b/client/ui-wails/frontend/src/components/StatusPanel.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; +import { Check, Loader2, XCircle } from "lucide-react"; +import { cn } from "@/lib/cn"; + +type Variant = "loading" | "success" | "error"; + +type Props = { + variant: Variant; + title: ReactNode; + description?: ReactNode; + children?: ReactNode; + actions?: ReactNode; +}; + +const VARIANTS: Record = { + loading: { + icon: , + className: "bg-nb-gray-100", + }, + success: { + icon: , + className: "bg-green-500", + }, + error: { + icon: , + className: "bg-red-500", + }, +}; + +export function StatusPanel({ variant, title, description, children, actions }: Props) { + const { icon, className } = VARIANTS[variant]; + return ( +
+
+ {icon} +
+ +
+

{title}

+ {description &&

{description}

} +
+ + {children &&
{children}
} + + {actions &&
{actions}
} +
+ ); +} diff --git a/client/ui-wails/frontend/src/layouts/AppLayout.tsx b/client/ui-wails/frontend/src/layouts/AppLayout.tsx index 79ab3979f..1fbe0ea4c 100644 --- a/client/ui-wails/frontend/src/layouts/AppLayout.tsx +++ b/client/ui-wails/frontend/src/layouts/AppLayout.tsx @@ -1,16 +1,19 @@ import { Outlet } from "react-router-dom"; import { Header } from "@/layouts/Header.tsx"; import { AutoUpdate } from "@/modules/auto-update/AutoUpdate.tsx"; +import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; export const AppLayout = () => { return ( -
-
- - -
+ +
+
+ + +
+
); }; diff --git a/client/ui-wails/frontend/src/modules/debug-bundle/DebugBundleContext.tsx b/client/ui-wails/frontend/src/modules/debug-bundle/DebugBundleContext.tsx new file mode 100644 index 000000000..d4e443fc3 --- /dev/null +++ b/client/ui-wails/frontend/src/modules/debug-bundle/DebugBundleContext.tsx @@ -0,0 +1,16 @@ +import { createContext, type ReactNode } from "react"; +import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts"; + +export type DebugBundleContextValue = ReturnType; + +export const DebugBundleContext = + createContext(null); + +export const DebugBundleProvider = ({ children }: { children: ReactNode }) => { + const value = useDebugBundle(); + return ( + + {children} + + ); +}; diff --git a/client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts b/client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundle.ts similarity index 56% rename from client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts rename to client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundle.ts index a5ffc93ec..dc0fdfbab 100644 --- a/client/ui-wails/frontend/src/modules/settings/useDebugBundle.ts +++ b/client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundle.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Connection as ConnectionSvc, Debug as DebugSvc, @@ -6,7 +6,7 @@ import { import type { DebugBundleResult } from "@bindings/services/models.js"; import { useProfile } from "@/modules/profile/ProfileContext.tsx"; -const NETBIRD_UPLOAD_URL = "https://debug.netbird.io/upload"; +const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; const TRACE_LOG_FILE_COUNT = 5; const PLAIN_LOG_FILE_COUNT = 1; @@ -18,19 +18,40 @@ export type DebugStage = | { kind: "restoring-level" } | { kind: "bundling" } | { kind: "uploading" } + | { kind: "cancelling" } | { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean } | { kind: "error"; message: string }; -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(id); + reject(new DOMException("aborted", "AbortError")); + }; + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort); + }); + +const isAbort = (e: unknown) => + e instanceof DOMException && e.name === "AbortError"; export const useDebugBundle = () => { const { activeProfile, username } = useProfile(); - const [anonymize, setAnonymize] = useState(true); + const [anonymize, setAnonymize] = useState(false); const [systemInfo, setSystemInfo] = useState(true); - const [upload, setUpload] = useState(false); - const [trace, setTrace] = useState(false); - const [traceMinutes, setTraceMinutes] = useState(3); + const [upload, setUpload] = useState(true); + const [trace, setTrace] = useState(true); + const [traceMinutes, setTraceMinutes] = useState(1); const [stage, setStage] = useState({ kind: "idle" }); + const [lastBundlePath, setLastBundlePath] = useState(""); + const abortRef = useRef(null); const isRunning = stage.kind !== "idle" && @@ -39,10 +60,26 @@ export const useDebugBundle = () => { const reset = () => setStage({ kind: "idle" }); + const cancel = () => { + if (!abortRef.current || abortRef.current.signal.aborted) return; + abortRef.current.abort(); + setStage({ kind: "cancelling" }); + }; + const run = async () => { + 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; + try { - let originalLevel = "info"; if (trace) { setStage({ kind: "preparing-trace" }); try { @@ -51,14 +88,18 @@ export const useDebugBundle = () => { } 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, @@ -72,17 +113,19 @@ export const useDebugBundle = () => { remainingSec: remaining, totalSec, }); - await sleep(1000); + await sleep(1000, signal); } setStage({ kind: "restoring-level" }); try { await DebugSvc.SetLogLevel({ level: originalLevel }); + raisedLevel = false; } catch { // restore is best-effort } } + checkAbort(); setStage({ kind: "bundling" }); const logFileCount = trace ? TRACE_LOG_FILE_COUNT @@ -95,16 +138,36 @@ export const useDebugBundle = () => { uploadUrl, logFileCount, }); + checkAbort(); + if (result.path) setLastBundlePath(result.path); setStage({ kind: "done", result, uploadAttempted: Boolean(uploadUrl), }); } catch (e) { + if (isAbort(e)) { + if (raisedLevel) { + try { + await DebugSvc.SetLogLevel({ level: originalLevel }); + } catch { + // best effort + } + } + setStage({ kind: "idle" }); + return; + } setStage({ kind: "error", message: String(e) }); + } finally { + if (abortRef.current === ctrl) abortRef.current = null; } }; + const openBundleDir = () => { + if (!lastBundlePath) return; + void DebugSvc.RevealFile(lastBundlePath).catch(() => {}); + }; + return { anonymize, setAnonymize, @@ -118,7 +181,10 @@ export const useDebugBundle = () => { setTraceMinutes, stage, isRunning, + lastBundlePath, run, + cancel, reset, + openBundleDir, }; }; diff --git a/client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundleContext.ts b/client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundleContext.ts new file mode 100644 index 000000000..43fd0ddac --- /dev/null +++ b/client/ui-wails/frontend/src/modules/debug-bundle/useDebugBundleContext.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx"; + +export const useDebugBundleContext = () => { + const ctx = useContext(DebugBundleContext); + if (!ctx) { + throw new Error( + "useDebugBundleContext must be used inside DebugBundleProvider", + ); + } + return ctx; +}; diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx index 5c1f58a8b..e6a6479c8 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import Button from "@/components/Button"; import { HelpText } from "@/components/HelpText"; import { Input } from "@/components/Input"; @@ -9,7 +9,7 @@ import { useSettings } from "@/modules/settings/SettingsContext.tsx"; export function SettingsAdvanced() { const { config, saveFields } = useSettings(); - const [draft, setDraft] = useState({ + const [values, setValues] = useState({ interfaceName: config.interfaceName, wireguardPort: config.wireguardPort, mtu: config.mtu, @@ -17,27 +17,17 @@ export function SettingsAdvanced() { }); const [saving, setSaving] = useState(false); - // Re-sync the draft when the underlying config changes from elsewhere (e.g. reload). - useEffect(() => { - setDraft({ - interfaceName: config.interfaceName, - wireguardPort: config.wireguardPort, - mtu: config.mtu, - preSharedKey: config.preSharedKey, - }); - }, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]); - - const isDirty = - draft.interfaceName !== config.interfaceName || - draft.wireguardPort !== config.wireguardPort || - draft.mtu !== config.mtu || - draft.preSharedKey !== config.preSharedKey; + const hasChanges = + values.interfaceName !== config.interfaceName || + values.wireguardPort !== config.wireguardPort || + values.mtu !== config.mtu || + values.preSharedKey !== config.preSharedKey; const handleSave = async () => { - if (!isDirty || saving) return; + if (!hasChanges || saving) return; setSaving(true); try { - await saveFields(draft); + await saveFields(values); } finally { setSaving(false); } @@ -48,17 +38,19 @@ export function SettingsAdvanced() { setDraft((d) => ({ ...d, interfaceName: e.target.value }))} + value={values.interfaceName} + onChange={(e) => + setValues((v) => ({ ...v, interfaceName: e.target.value })) + } />
- setDraft((d) => ({ - ...d, + setValues((v) => ({ + ...v, wireguardPort: Number(e.target.value), })) } @@ -66,12 +58,9 @@ export function SettingsAdvanced() { - setDraft((d) => ({ - ...d, - mtu: Number(e.target.value), - })) + setValues((v) => ({ ...v, mtu: Number(e.target.value) })) } />
@@ -82,19 +71,16 @@ export function SettingsAdvanced() { Optional WireGuard PSK for extra symmetric encryption. Not the same as a - NetBird Setup Key. Set the same value on every peer, otherwise they can't - connect to each other. + NetBird Setup Key. You will only communicate with peers that use the same + pre-shared key. - setDraft((d) => ({ - ...d, - preSharedKey: e.target.value, - })) + setValues((v) => ({ ...v, preSharedKey: e.target.value })) } />
@@ -105,7 +91,7 @@ export function SettingsAdvanced() { - {stage.kind === "error" && ( - + /> +
+
+ + + How long to capture trace logs before generating the bundle. + +
+
+ + setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1))) + } + customSuffix={"Minutes"} + disabled={!trace} + /> +
- + + + ); } -function BundleStatus({ stage }: { stage: DebugStage }) { - if (stage.kind === "idle") return null; - - if ( - stage.kind === "preparing-trace" || - stage.kind === "reconnecting" || - stage.kind === "capturing" || - stage.kind === "restoring-level" || - stage.kind === "bundling" || - stage.kind === "uploading" - ) { - return ( -
- -

- {stageLabel(stage)} -

-
- ); - } - - if (stage.kind === "error") { - return ( -
- {stage.message} -
- ); - } - - return ; +function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) { + const cancelling = stage.kind === "cancelling"; + return ( + + {cancelling ? "Cancelling…" : "Cancel"} + + } + /> + ); } -function stageLabel(stage: DebugStage): string { +function ResultSection({ + stage, + onClose, +}: { + stage: Extract; + onClose: () => void; +}) { + if (stage.kind === "error") { + return ( + + Close + + } + /> + ); + } + return ; +} + +function DoneResult({ + result, + uploaded, + onClose, +}: { + result: DebugBundleResult; + uploaded: boolean; + onClose: () => void; +}) { + const showKey = uploaded && Boolean(result.uploadedKey); + const uploadFailed = uploaded && !result.uploadedKey; + const onRevealPath = () => { + if (!result.path) return; + void DebugSvc.RevealFile(result.path).catch(() => {}); + }; + return ( + + + {showKey ? ( + + ) : ( + result.path && ( + + ) + )} + + } + > +
+ {showKey && } + + {result.path && ( + + + + } + /> + )} + + {uploadFailed && ( +
+ Upload failed + {result.uploadFailureReason + ? `: ${result.uploadFailureReason}` + : "."}{" "} + The bundle is still saved locally. +
+ )} +
+
+ ); +} + +function BottomBar({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +const stageLabel = (stage: DebugStage): string => { switch (stage.kind) { case "preparing-trace": return "Switching to trace logging…"; case "reconnecting": return "Reconnecting NetBird…"; case "capturing": { - const fmt = (s: number) => - `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; + const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; return `Capturing logs — ${fmt( stage.totalSec - stage.remainingSec, )} / ${fmt(stage.totalSec)}`; @@ -190,131 +275,12 @@ function stageLabel(stage: DebugStage): string { case "restoring-level": return "Restoring previous log level…"; case "bundling": - return "Building bundle…"; + return "Generating debug bundle…"; case "uploading": return "Uploading to NetBird…"; + case "cancelling": + return "Cancelling…"; default: return ""; } -} - -function BundleResult({ - result, - uploaded, -}: { - result: DebugBundleResult; - uploaded: boolean; -}) { - const uploadFailed = uploaded && !result.uploadedKey; - return ( -
- {uploaded && result.uploadedKey && ( -
-

- Bundle uploaded -

-

- Share this key with NetBird support so they can find - your bundle. -

- -
- )} - - {uploadFailed && ( -
- Upload failed - {result.uploadFailureReason - ? `: ${result.uploadFailureReason}` - : "."}{" "} - The bundle is still saved locally. -
- )} - - {result.path && ( -
-

- {uploaded && result.uploadedKey - ? "A local copy was also saved at:" - : "Bundle saved to:"} -

- -

- You may need admin privileges to open this file. -

-
- )} -
- ); -} - -function CopyableValue({ - value, - mono = false, - large = false, -}: { - value: string; - mono?: boolean; - large?: boolean; -}) { - const [copied, setCopied] = useState(false); - const onCopy = async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // ignore - } - }; - const onReveal = () => { - void Browser.OpenURL(`file://${value}`).catch(() => {}); - }; - return ( -
- - {value} - - - {value.startsWith("/") || value.match(/^[A-Za-z]:\\/) ? ( - - ) : null} -
- ); -} +}; diff --git a/client/ui-wails/services/debug.go b/client/ui-wails/services/debug.go index 71ea6138a..42059624b 100644 --- a/client/ui-wails/services/debug.go +++ b/client/ui-wails/services/debug.go @@ -4,6 +4,10 @@ package services import ( "context" + "fmt" + "os/exec" + "path/filepath" + "runtime" "github.com/netbirdio/netbird/client/proto" ) @@ -74,6 +78,25 @@ func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) { return LogLevel{Level: resp.GetLevel().String()}, nil } +// RevealFile opens the OS file manager focused on the given path. Wails' +// Browser.OpenURL refuses non-http(s) schemes, so the UI calls this binding +// instead of constructing a file:// URL. +func (s *Debug) RevealFile(_ context.Context, path string) error { + if path == "" { + return fmt.Errorf("empty path") + } + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", "-R", path) + case "windows": + cmd = exec.Command("explorer", "/select,"+path) + default: + cmd = exec.Command("xdg-open", filepath.Dir(path)) + } + return cmd.Start() +} + func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error { cli, err := s.conn.Client() if err != nil {