mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-05 13:09:07 +02:00
remove unused stuff, refactor frontend folder structure
This commit is contained in:
@@ -2,17 +2,16 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./globals.css";
|
||||
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import SessionExpiredDialog from "@/modules/authentication/SessionExpiredDialog.tsx";
|
||||
import SessionAboutToExpireDialog from "@/modules/authentication/SessionAboutToExpireDialog.tsx";
|
||||
import InstallProgressDialog from "@/modules/auto-update/InstallProgressDialog.tsx";
|
||||
import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
|
||||
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
|
||||
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.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 { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import WaitingForBrowserDialog from "@/modules/authentication/WaitingForBrowserDialog.tsx";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
|
||||
welcome();
|
||||
@@ -30,15 +29,15 @@ initI18n()
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/browser-login" element={<WaitingForBrowserDialog />} />
|
||||
<Route path="/install-progress" element={<InstallProgressDialog />} />
|
||||
<Route path="/session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="/session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
<Route element={<SettingsLayout />}>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="dialog">
|
||||
<Route path="browser-login" element={<LoginWaitingForBrowserDialog />} />
|
||||
<Route path="install-progress" element={<UpdateInProgressDialog />} />
|
||||
<Route path="session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Main />} />
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={"/"} replace />}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { generateColorFromString } from "@/lib/color";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
name?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Avatar = forwardRef<HTMLButtonElement, Props>(function Avatar(
|
||||
{ name = "", size = 28, className, type = "button", ...props },
|
||||
ref,
|
||||
) {
|
||||
const initial = (name.trim().charAt(0) || "?").toUpperCase();
|
||||
const color = generateColorFromString(name);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-grid place-items-center rounded-full bg-nb-gray-850 p-0 text-center",
|
||||
"text-[0.9rem] font-semibold cursor-default outline-none",
|
||||
"transition-colors duration-150 hover:bg-nb-gray-850",
|
||||
"data-[state=open]:bg-nb-gray-850",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
lineHeight: 0,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const BottomSheet = ({ open, onOpenChange, children, className }: Props) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onOpenChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className={"fixed inset-0 z-50"}>
|
||||
<motion.div
|
||||
className={"absolute inset-0 bg-black/40 backdrop-blur-sm"}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<motion.div
|
||||
role={"dialog"}
|
||||
aria-modal={"true"}
|
||||
className={cn(
|
||||
"absolute left-0 right-0 bottom-0",
|
||||
"bg-nb-gray-925 border-t border-nb-gray-850 rounded-t-2xl",
|
||||
"shadow-2xl outline-none",
|
||||
"max-h-[85vh] overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 34 }}
|
||||
>
|
||||
<div className={"flex justify-center pt-2"}>
|
||||
<div className={"h-1 w-10 rounded-full bg-nb-gray-700"} />
|
||||
</div>
|
||||
<div className={"px-5 pt-4 pb-6"}>{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
ButtonHTMLAttributes,
|
||||
ComponentType,
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
icon?: ComponentType<LucideProps>;
|
||||
iconNode?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
active?: boolean;
|
||||
iconSize?: number;
|
||||
};
|
||||
|
||||
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
|
||||
function CardNavItem(
|
||||
{
|
||||
icon: Icon,
|
||||
iconNode,
|
||||
title,
|
||||
description,
|
||||
active = false,
|
||||
iconSize = 15,
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
|
||||
"transition-colors duration-150",
|
||||
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
|
||||
"transition-colors duration-150",
|
||||
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
|
||||
)}
|
||||
>
|
||||
{iconNode ?? (Icon && (
|
||||
<Icon
|
||||
size={iconSize}
|
||||
className={cn(
|
||||
"transition-colors duration-150",
|
||||
active ? "text-nb-gray-200" : "text-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={"min-w-0"}>
|
||||
<h2
|
||||
className={cn(
|
||||
"font-medium text-[0.81rem] truncate",
|
||||
active ? "text-nb-gray-100" : "text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium truncate",
|
||||
active ? "text-nb-gray-300" : "text-nb-gray-400",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type RootProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Root = ({ value, onChange, children, className }: RootProps) => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
className={cn("grid grid-cols-2 gap-3", className)}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type OptionProps = {
|
||||
value: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
preview?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Option = ({ value, title, description, preview, className }: OptionProps) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-stretch text-left rounded-lg",
|
||||
"border border-nb-gray-850 bg-nb-gray-925 p-3 cursor-default outline-none",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-nb-gray-800",
|
||||
"data-[state=checked]:border-netbird data-[state=checked]:ring-1 data-[state=checked]:ring-netbird",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-2.5 right-2.5 flex h-4 w-4 items-center justify-center rounded-[4px]",
|
||||
"border border-nb-gray-700 bg-nb-gray-900",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator className={"flex items-center justify-center"}>
|
||||
<CheckIcon size={11} className={"text-white"} strokeWidth={3} />
|
||||
</RadioGroup.Indicator>
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"h-48 -mx-3 -mt-3 mb-3 overflow-hidden",
|
||||
"bg-gradient-to-b from-nb-gray-800/15 to-nb-gray",
|
||||
"rounded-t-lg flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
<h3 className={"text-sm font-semibold text-nb-gray-100"}>{title}</h3>
|
||||
{description && (
|
||||
<p className={"text-[0.72rem] leading-snug text-nb-gray-400 mt-0.5"}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardSelect = Object.assign(Root, { Option });
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
const HoverCardPortal = HoverCardPrimitive.Portal;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "start", sideOffset = 6, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-80 rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"p-3 text-nb-gray-200 shadow-lg outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger };
|
||||
+2
-2
@@ -7,8 +7,8 @@ import { Dialogs } from "@wailsio/runtime";
|
||||
import { CheckIcon, ChevronDown, Search } from "lucide-react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import netbirdLogo from "@/assets/logos/netbird.svg";
|
||||
import { SwitchItem } from "@/components/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
|
||||
import { SwitchItem } from "@/components/switches/SwitchItem";
|
||||
import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
|
||||
import { ManagementMode } from "@/hooks/useManagementUrl.ts";
|
||||
|
||||
type Props = {
|
||||
value: ManagementMode;
|
||||
@@ -1,48 +0,0 @@
|
||||
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<Variant, { icon: ReactNode; className: string }> = {
|
||||
loading: {
|
||||
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
|
||||
className: "bg-nb-gray-100",
|
||||
},
|
||||
success: {
|
||||
icon: <Check className={"text-white"} size={18} />,
|
||||
className: "bg-green-500",
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className={"text-white"} size={18} />,
|
||||
className: "bg-red-500",
|
||||
},
|
||||
};
|
||||
|
||||
export function StatusPanel({ variant, title, description, children, actions }: Props) {
|
||||
const { icon, className } = VARIANTS[variant];
|
||||
return (
|
||||
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
|
||||
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
|
||||
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
|
||||
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
|
||||
</div>
|
||||
|
||||
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
|
||||
|
||||
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, disabled, label, description }: Props) {
|
||||
return (
|
||||
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{(label || description) && (
|
||||
<span className="flex flex-col">
|
||||
{label && <span className="text-sm font-medium">{label}</span>}
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-500">{description}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
interface Tab {
|
||||
value: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
initial?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, initial }: Props) {
|
||||
const [active, setActive] = useState(initial ?? tabs[0]?.value);
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
onClick={() => setActive(t.value)}
|
||||
className={cn(
|
||||
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
active === t.value
|
||||
? "border-netbird text-netbird"
|
||||
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tabs.find((t) => t.value === active)?.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -3,15 +3,15 @@ import classNames from "classnames";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
copy?: string;
|
||||
}
|
||||
|
||||
export const buttonVariants = cva(
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"relative",
|
||||
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm select-none",
|
||||
+1
-4
@@ -5,11 +5,8 @@ import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const Root = DialogPrimitive.Root;
|
||||
export const Trigger = DialogPrimitive.Trigger;
|
||||
export const Close = DialogPrimitive.Close;
|
||||
export const Portal = DialogPrimitive.Portal;
|
||||
|
||||
export const Overlay = forwardRef<
|
||||
const Overlay = forwardRef<
|
||||
ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(function DialogOverlay({ className, ...props }, ref) {
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircleIcon, BookText } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { ExternalLinkIcon, LucideProps } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SquareIcon } from "./SquareIcon";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
|
||||
type Props = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { cva, VariantProps } from "class-variance-authority";
|
||||
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";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
|
||||
type InputVariants = VariantProps<typeof inputVariants>;
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { ToggleSwitch } from "@/components/ToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface Props {
|
||||
+1
-1
@@ -2,7 +2,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
+13
-47
@@ -9,6 +9,8 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dialogs, Events } from "@wailsio/runtime";
|
||||
|
||||
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
@@ -34,18 +36,6 @@ type ClientVersionContextValue = {
|
||||
|
||||
const EVENT_UPDATE_STATE = "netbird:update:state";
|
||||
|
||||
// Dev tab in Settings emits this with { updateAvailable, enforced, version }.
|
||||
// Lives only in-memory in the main window for the session — losing it when
|
||||
// Settings closes is acceptable per the dev-toggle scope (no daemon write,
|
||||
// no persistence). See SettingsDevelopment.tsx.
|
||||
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
|
||||
|
||||
type DevOverrides = {
|
||||
updateAvailable: boolean;
|
||||
enforced: boolean;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const emptyState: UpdateState = {
|
||||
available: false,
|
||||
version: "",
|
||||
@@ -66,7 +56,6 @@ export const useClientVersion = () => {
|
||||
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, setState] = useState<UpdateState>(emptyState);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [devOverride, setDevOverride] = useState<DevOverrides | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -91,46 +80,23 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_DEV_OVERRIDES, (ev: { data: DevOverrides }) => {
|
||||
if (ev?.data) setDevOverride(ev.data);
|
||||
});
|
||||
return () => {
|
||||
off?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Dev override only kicks in when it explicitly forces updateAvailable on.
|
||||
// Otherwise daemon truth wins.
|
||||
const effective = useMemo<UpdateState>(() => {
|
||||
if (devOverride && devOverride.updateAvailable) {
|
||||
return {
|
||||
available: true,
|
||||
version: devOverride.version || "0.65.0",
|
||||
enforced: devOverride.enforced,
|
||||
installing: state.installing,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}, [state, devOverride]);
|
||||
|
||||
// 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 (effective.installing && !prevInstallingRef.current) {
|
||||
WindowManager.OpenInstallProgress(effective.version || "").catch(console.error);
|
||||
if (state.installing && !prevInstallingRef.current) {
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
}
|
||||
prevInstallingRef.current = effective.installing;
|
||||
}, [effective.installing, effective.version]);
|
||||
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(effective.version || "").catch(console.error);
|
||||
WindowManager.OpenInstallProgress(state.version || "").catch(console.error);
|
||||
UpdateSvc.Trigger()
|
||||
.catch(async (e) => {
|
||||
// The daemon may already be down (force-install branch raced
|
||||
@@ -146,18 +112,18 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
});
|
||||
})
|
||||
.finally(() => setUpdating(false));
|
||||
}, [effective.version]);
|
||||
}, [state.version]);
|
||||
|
||||
const value = useMemo<ClientVersionContextValue>(
|
||||
() => ({
|
||||
updateAvailable: effective.available,
|
||||
updateVersion: effective.version || null,
|
||||
enforced: effective.enforced,
|
||||
installing: effective.installing,
|
||||
updateAvailable: state.available,
|
||||
updateVersion: state.version || null,
|
||||
enforced: state.enforced,
|
||||
installing: state.installing,
|
||||
triggerUpdate,
|
||||
updating,
|
||||
}),
|
||||
[effective, triggerUpdate, updating],
|
||||
[state, triggerUpdate, updating],
|
||||
);
|
||||
|
||||
return (
|
||||
+32
-3
@@ -1,4 +1,10 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import {
|
||||
Connection as ConnectionSvc,
|
||||
@@ -7,7 +13,7 @@ import {
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
@@ -44,7 +50,7 @@ const sleep = (ms: number, signal: AbortSignal) =>
|
||||
const isAbort = (e: unknown) =>
|
||||
e instanceof DOMException && e.name === "AbortError";
|
||||
|
||||
export const useDebugBundle = () => {
|
||||
const useDebugBundle = () => {
|
||||
const { activeProfile, username } = useProfile();
|
||||
const [anonymize, setAnonymize] = useState(false);
|
||||
const [systemInfo, setSystemInfo] = useState(true);
|
||||
@@ -191,3 +197,26 @@ export const useDebugBundle = () => {
|
||||
openBundleDir,
|
||||
};
|
||||
};
|
||||
|
||||
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
|
||||
|
||||
const DebugBundleContext = createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
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",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
+1
-1
@@ -10,7 +10,7 @@ import {
|
||||
} from "react";
|
||||
import { Networks as NetworksSvc } from "@bindings/services";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
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
|
||||
+3
-3
@@ -11,8 +11,8 @@ import { Dialogs } from "@wailsio/runtime";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
@@ -150,7 +150,7 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className={"flex-1 min-h-0 overflow-y-auto"}>
|
||||
{!config ? (
|
||||
<SkeletonSettings />
|
||||
<SettingsSkeleton />
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Peers } from "@bindings/services";
|
||||
import type { Status } from "@bindings/services/models.js";
|
||||
import { DaemonUnavailableOverlay } from "@/modules/daemon-status/DaemonUnavailableOverlay.tsx";
|
||||
import { DaemonUnavailableOverlay } from "@/components/empty-state/DaemonUnavailableOverlay.tsx";
|
||||
|
||||
const EVENT_STATUS = "netbird:status";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
export enum ManagementMode {
|
||||
Cloud = "cloud",
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "@/layouts/Header.tsx";
|
||||
import { NavSectionProvider } from "@/lib/navSection";
|
||||
import { ViewModeProvider } from "@/lib/viewMode";
|
||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/contexts/StatusContext.tsx";
|
||||
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
|
||||
|
||||
// Shared shell for every in-window route (main + settings). Owns the daemon-
|
||||
// availability gate (via StatusProvider) and the providers every page needs.
|
||||
// Order matters: SettingsContext depends on ProfileContext; ClientVersionContext
|
||||
// reads StatusContext events.
|
||||
//
|
||||
// Page-specific surface (the main Header, the settings draggable strip,
|
||||
// view-mode + nav-section providers) lives inside the page components, not here.
|
||||
export const AppLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
@@ -14,12 +18,7 @@ export const AppLayout = () => {
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<ViewModeProvider>
|
||||
<NavSectionProvider>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</NavSectionProvider>
|
||||
</ViewModeProvider>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const PANEL_TRANSITION = {
|
||||
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||
};
|
||||
|
||||
export const MainRightSide = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
export const AppRightPanel = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection } from "@/lib/navSection";
|
||||
import { useViewMode } from "@/lib/viewMode";
|
||||
import { NotConnectedState } from "@/components/NotConnectedState";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { Peers } from "@/modules/peers/Peers";
|
||||
import { Networks } from "@/modules/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/modules/networks/NetworksContext";
|
||||
import {
|
||||
PeerDetailProvider,
|
||||
usePeerDetail,
|
||||
} from "@/modules/peers/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/peers/PeerDetailPanel";
|
||||
|
||||
export const Main = () => {
|
||||
return (
|
||||
<NetworksProvider>
|
||||
<PeerDetailProvider>
|
||||
<MainBody />
|
||||
</PeerDetailProvider>
|
||||
</NetworksProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const MainBody = () => {
|
||||
const { viewMode } = useViewMode();
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
const { section } = useNavSection();
|
||||
const { selected } = usePeerDetail();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center shrink-0 w-[348px]"
|
||||
}
|
||||
>
|
||||
<ConnectionStatusSwitch />
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<MainRightSide
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
!isConnected && "pointer-events-none select-none",
|
||||
)}
|
||||
aria-hidden={!isConnected}
|
||||
>
|
||||
<Navigation />
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<div
|
||||
className={
|
||||
"absolute inset-0 z-20 flex pointer-events-auto bg-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<NotConnectedState />
|
||||
</div>
|
||||
)}
|
||||
</MainRightSide>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/modules/daemon-status/StatusContext.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 h-10 placeholder strip at the top accounts for the macOS
|
||||
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
|
||||
// title bar is invisible but the traffic-light buttons still float in the
|
||||
// top-left corner. The height also mirrors the main window's Header so the
|
||||
// MainRightSide panel ends up the same height in both windows.
|
||||
export const SettingsLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col select-none"}>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<div
|
||||
className={
|
||||
"wails-draggable cursor-default select-none h-12 shrink-0"
|
||||
}
|
||||
/>
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
</StatusProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import chroma from "chroma-js";
|
||||
|
||||
export const generateColorFromString = (str?: string) => {
|
||||
if (!str) return "#f68330";
|
||||
if (str.includes("System")) return "#808080";
|
||||
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
|
||||
let hash = 0;
|
||||
str.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let colour = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
colour += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
return chroma(colour).saturate(2).luminance(0.4).hex();
|
||||
};
|
||||
+7
-7
@@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Loader2, XCircle } from "lucide-react";
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
@@ -25,7 +25,7 @@ type Phase =
|
||||
| { kind: "canceled" }
|
||||
| { kind: "failed"; message: string };
|
||||
|
||||
export default function InstallProgressDialog() {
|
||||
export default function UpdateInProgressDialog() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const version = params.get("version") ?? "";
|
||||
@@ -2,8 +2,8 @@ import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { DownloadIcon, NotepadText } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createContext, type ReactNode } from "react";
|
||||
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||
|
||||
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
|
||||
|
||||
export const DebugBundleContext =
|
||||
createContext<DebugBundleContextValue | null>(null);
|
||||
|
||||
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const value = useDebugBundle();
|
||||
return (
|
||||
<DebugBundleContext.Provider value={value}>
|
||||
{children}
|
||||
</DebugBundleContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
type Props = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
export const ExitNodesList = ({ data, onToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
className={"flex flex-col"}
|
||||
>
|
||||
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
|
||||
{data.map((n) => (
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
const Row = ({ value, label, first }: RowProps) => (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
|
||||
first && "mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
"border-nb-gray-700 bg-nb-gray-900",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={"h-2 w-2 rounded-full bg-white"}
|
||||
/>
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
+7
-7
@@ -4,19 +4,19 @@ import { useSearchParams } from "react-router-dom";
|
||||
import { Dialogs, Events } from "@wailsio/runtime";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Connection } from "@bindings/services";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const EVENT_CANCEL = "browser-login:cancel";
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
export default function WaitingForBrowserDialog() {
|
||||
export default function LoginWaitingForBrowserDialog() {
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const uri = params.get("uri") ?? "";
|
||||
+4
-4
@@ -3,9 +3,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { Dialogs, Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
@@ -114,7 +114,7 @@ async function startLogin(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const ConnectionStatusSwitch = () => {
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
const { activeProfile, username } = useProfile();
|
||||
+6
-6
@@ -18,16 +18,16 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { IconButton } from "@/components/IconButton";
|
||||
import { ProfileDropdown } from "@/components/ProfileDropdown";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext";
|
||||
import { IconButton } from "@/components/buttons/IconButton";
|
||||
import { ProfileDropdown } from "@/modules/profiles/ProfileDropdown";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/lib/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/lib/viewMode";
|
||||
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
|
||||
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
|
||||
|
||||
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
|
||||
|
||||
export const Header = () => {
|
||||
export const MainHeader = () => {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MainConnectionStatusSwitch } from "@/modules/main/MainConnectionStatusSwitch.tsx";
|
||||
import { MainHeader } from "@/modules/main/MainHeader.tsx";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { Navigation } from "@/modules/main/advanced/Navigation.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { NavSectionProvider, useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { ViewModeProvider, useViewMode } from "@/contexts/ViewModeContext";
|
||||
import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/main/advanced/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import {
|
||||
PeerDetailProvider,
|
||||
usePeerDetail,
|
||||
} from "@/contexts/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/main/advanced/peers/PeerDetailPanel";
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
<ViewModeProvider>
|
||||
<MainHeader />
|
||||
<NetworksProvider>
|
||||
<PeerDetailProvider>
|
||||
<MainBody />
|
||||
</PeerDetailProvider>
|
||||
</NetworksProvider>
|
||||
</ViewModeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const MainBody = () => {
|
||||
const { viewMode } = useViewMode();
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
<div
|
||||
className={"flex flex-col items-center shrink-0 w-[348px]"}
|
||||
>
|
||||
<MainConnectionStatusSwitch />
|
||||
</div>
|
||||
{isAdvanced && (
|
||||
<NavSectionProvider>
|
||||
<AdvancedAppRightPanel />
|
||||
</NavSectionProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdvancedAppRightPanel = () => {
|
||||
const { section } = useNavSection();
|
||||
const { selected } = usePeerDetail();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
return (
|
||||
<AppRightPanel
|
||||
overlay={<PeerDetailPanel />}
|
||||
overlayOpen={selected !== null}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col",
|
||||
!isConnected && "pointer-events-none select-none",
|
||||
)}
|
||||
aria-hidden={!isConnected}
|
||||
>
|
||||
<Navigation />
|
||||
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "exitNode" && <ExitNodes />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<div
|
||||
className={
|
||||
"absolute inset-0 z-20 flex pointer-events-auto bg-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<NotConnectedState />
|
||||
</div>
|
||||
)}
|
||||
</AppRightPanel>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -2,8 +2,8 @@ import { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/lib/navSection";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
type TabEntry = {
|
||||
value: NavSection;
|
||||
@@ -80,4 +80,4 @@ const ExitNodeIcon = ({ size, ...props }: LucideProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export type { NavSection } from "@/lib/navSection";
|
||||
export type { NavSection } from "@/contexts/NavSectionContext";
|
||||
+80
-6
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { NoResults } from "@/components/NoResults";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
||||
import { ExitNodesList } from "./ExitNodesList";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -104,3 +107,74 @@ export const ExitNodes = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExitNodesListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
const ExitNodesList = ({ data, onToggle }: ExitNodesListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={handleChange}
|
||||
className={"flex flex-col"}
|
||||
>
|
||||
<Row value={NONE_VALUE} label={t("exitNodes.none")} first />
|
||||
{data.map((n) => (
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
const Row = ({ value, label, first }: RowProps) => (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"group flex items-center gap-2.5 pl-6 pr-8 py-3 min-w-0 w-full",
|
||||
first && "mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer outline-none text-left",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"min-w-0 flex-1 text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-full border",
|
||||
"border-nb-gray-700 bg-nb-gray-900",
|
||||
"flex items-center justify-center",
|
||||
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
|
||||
)}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={"h-2 w-2 rounded-full bg-white"}
|
||||
/>
|
||||
</span>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
+212
-20
@@ -1,10 +1,17 @@
|
||||
import type { ComponentType } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { GlobeIcon, type LucideProps, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { useNetworks } from "@/contexts/NetworksContext";
|
||||
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||
|
||||
// The daemon stringifies route.Network via netip.Prefix.String(). For
|
||||
// DNS-based routes the prefix is the zero value, which Go renders as
|
||||
@@ -44,26 +51,202 @@ const resourceIconFor = (type: ResourceType): ComponentType<LucideProps> => {
|
||||
return NetworkIcon;
|
||||
};
|
||||
|
||||
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
const Icon = resourceIconFor(type);
|
||||
// Map every range string -> ids of CIDR routes that share it. Domain routes
|
||||
// are skipped (they overlap on domain, not prefix). Single-entry buckets
|
||||
// aren't overlaps.
|
||||
const buildOverlapMap = (
|
||||
routes: { id: string; range: string; domains: string[] }[],
|
||||
): Map<string, string[]> => {
|
||||
const byRange = new Map<string, string[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r.id);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out = new Map<string, string[]>();
|
||||
for (const [range, ids] of byRange) {
|
||||
if (ids.length > 1) out.set(range, ids);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
|
||||
|
||||
const overlapById = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const ids of overlapGroups.values()) {
|
||||
for (const id of ids) map.set(id, ids);
|
||||
}
|
||||
return map;
|
||||
}, [overlapGroups]);
|
||||
|
||||
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||
() => ({
|
||||
all: networkRoutes.length,
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlapById.size,
|
||||
}),
|
||||
[networkRoutes, overlapById],
|
||||
);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it, and newly discovered routes append at
|
||||
// the end (sorted active-first / by-id among themselves). The ref carries
|
||||
// the previous order across renders so the reconciliation is synchronous
|
||||
// with networkRoutes updates (no useEffect lag → no visual hop).
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = networkRoutes
|
||||
.filter((r) => !known.has(r.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((r) => r.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [networkRoutes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return ordered.filter((r) => {
|
||||
if (filter === "active" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
|
||||
if (q) {
|
||||
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [ordered, search, filter, overlapById]);
|
||||
|
||||
if (isConnected && networkRoutes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title={t("networks.empty.title")}
|
||||
description={t("networks.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
|
||||
learnMoreTopic={t("nav.resources.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedInView = filtered.filter((r) => r.selected).length;
|
||||
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
|
||||
const bulkLabel = allSelected
|
||||
? t("networks.bulk.disableAll")
|
||||
: t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
if (allSelected) {
|
||||
void setNetworksSelected(
|
||||
filtered.map((r) => r.id),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
|
||||
void setNetworksSelected(ids, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("networks.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<NetworksList data={filtered} onToggle={toggleNetwork} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
{filtered.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-6 py-3.5",
|
||||
"border-t border-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<span className={"flex-1 text-xs font-medium text-nb-gray-300 tabular-nums"}>
|
||||
{t("networks.bulk.selectionCount", {
|
||||
selected: selectedInView,
|
||||
total: filtered.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onBulkClick}
|
||||
className={cn(
|
||||
"inline-flex items-center h-8 px-3 rounded-md",
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
|
||||
"transition-colors outline-none wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type NetworksListProps = {
|
||||
data: Network[];
|
||||
onToggle: (id: string, selected: boolean) => void;
|
||||
};
|
||||
|
||||
export const NetworksList = ({ data, onToggle }: Props) => {
|
||||
const NetworksList = ({ data, onToggle }: NetworksListProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -113,6 +296,20 @@ export const NetworksList = ({ data, onToggle }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
const Icon = resourceIconFor(type);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0 rounded-md flex items-center justify-center mt-[0.3125rem]",
|
||||
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Subtitle = ({ network }: { network: Network }) => {
|
||||
if (isDnsRoute(network)) {
|
||||
const domain = network.domains[0];
|
||||
@@ -135,12 +332,7 @@ const Subtitle = ({ network }: { network: Network }) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
type DomainSubtitleProps = {
|
||||
domain: string;
|
||||
ips: string[];
|
||||
};
|
||||
|
||||
const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
|
||||
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
|
||||
const first = ips[0];
|
||||
const extra = ips.length - 1;
|
||||
|
||||
@@ -235,7 +427,7 @@ type ToggleProps = {
|
||||
mixed?: boolean;
|
||||
};
|
||||
|
||||
export const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => (
|
||||
<button
|
||||
type={"button"}
|
||||
role={"switch"}
|
||||
+119
-5
@@ -1,7 +1,10 @@
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { ComponentType, ReactNode, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpIcon,
|
||||
CableIcon,
|
||||
@@ -20,15 +23,126 @@ import {
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { formatBytes, formatRelative, latencyColor } from "./format";
|
||||
import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
|
||||
type Props = {
|
||||
peer: PeerStatus;
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.32,
|
||||
ease: [0.32, 0.72, 0, 1],
|
||||
};
|
||||
|
||||
const DASH = "-";
|
||||
|
||||
export const PeerDetails = ({ peer }: Props) => {
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, setSelected } = usePeerDetail();
|
||||
const { status } = useStatus();
|
||||
|
||||
// Keep `selected` in sync with the live peer list so the panel reflects
|
||||
// status / latency / byte updates without re-opening. If the peer
|
||||
// disappears, close the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const peers = status?.peers ?? [];
|
||||
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
|
||||
if (!fresh) {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (fresh !== selected) setSelected(fresh);
|
||||
}, [status, selected, setSelected]);
|
||||
|
||||
// Esc closes the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelected(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={transition}
|
||||
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-3",
|
||||
"px-3 h-12 border-b border-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label={t("common.close")}
|
||||
className={cn(
|
||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"transition-colors outline-none cursor-default",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
dotClass(selected.connStatus),
|
||||
)}
|
||||
title={selected.connStatus}
|
||||
/>
|
||||
<span className={"min-w-0 text-sm font-medium text-nb-gray-100 truncate"}>
|
||||
{selected.fqdn || selected.ip}
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerDetails peer={selected} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerDetails = ({ peer }: { peer: PeerStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never");
|
||||
const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH;
|
||||
+89
-7
@@ -1,17 +1,31 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { LaptopIcon } from "lucide-react";
|
||||
import { ChevronRightIcon, LaptopIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { NoResults } from "@/components/NoResults";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { latencyColor } from "@/lib/formatters";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
import { PeersList } from "./PeersList";
|
||||
|
||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
@@ -19,7 +33,7 @@ export const Peers = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Peers is only mounted in advanced view (see layouts/Main.tsx), so a
|
||||
// Peers is only mounted in advanced view (see pages/Main.tsx), so a
|
||||
// mount-time focus is equivalent to "focus when the user toggles into
|
||||
// advanced view".
|
||||
useEffect(() => {
|
||||
@@ -110,3 +124,71 @@ export const Peers = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((peer) => {
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
return (
|
||||
<li
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
dotClass(peer.connStatus),
|
||||
)}
|
||||
title={peer.connStatus}
|
||||
/>
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<span
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{peer.fqdn}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.ip}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{peer.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{isConnected && peer.latencyMs > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 self-center text-xs tabular-nums",
|
||||
latencyColor(peer.latencyMs),
|
||||
)}
|
||||
>
|
||||
{peer.latencyMs} ms
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"shrink-0 self-center text-nb-gray-300",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { NetworkIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { NoResults } from "@/components/NoResults";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||
import { NetworksList } from "./NetworksList";
|
||||
import { useNetworks } from "./NetworksContext";
|
||||
|
||||
// Map every range string -> ids of CIDR routes that share it. Domain routes
|
||||
// are skipped (they overlap on domain, not prefix). Single-entry buckets
|
||||
// aren't overlaps.
|
||||
const buildOverlapMap = (
|
||||
routes: { id: string; range: string; domains: string[] }[],
|
||||
): Map<string, string[]> => {
|
||||
const byRange = new Map<string, string[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
const arr = byRange.get(r.range) ?? [];
|
||||
arr.push(r.id);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out = new Map<string, string[]>();
|
||||
for (const [range, ids] of byRange) {
|
||||
if (ids.length > 1) out.set(range, ids);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlapGroups = useMemo(() => buildOverlapMap(networkRoutes), [networkRoutes]);
|
||||
|
||||
const overlapById = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const ids of overlapGroups.values()) {
|
||||
for (const id of ids) map.set(id, ids);
|
||||
}
|
||||
return map;
|
||||
}, [overlapGroups]);
|
||||
|
||||
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||
() => ({
|
||||
all: networkRoutes.length,
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlapById.size,
|
||||
}),
|
||||
[networkRoutes, overlapById],
|
||||
);
|
||||
|
||||
// Initial order: active-first, then by id. After that, positions are sticky
|
||||
// — toggling a row doesn't move it, and newly discovered routes append at
|
||||
// the end (sorted active-first / by-id among themselves). The ref carries
|
||||
// the previous order across renders so the reconciliation is synchronous
|
||||
// with networkRoutes updates (no useEffect lag → no visual hop).
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const byId = new Map(networkRoutes.map((r) => [r.id, r]));
|
||||
const kept = orderRef.current.filter((id) => byId.has(id));
|
||||
const known = new Set(kept);
|
||||
const fresh = networkRoutes
|
||||
.filter((r) => !known.has(r.id))
|
||||
.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.map((r) => r.id);
|
||||
const next = [...kept, ...fresh];
|
||||
orderRef.current = next;
|
||||
return next.map((id) => byId.get(id)!);
|
||||
}, [networkRoutes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return ordered.filter((r) => {
|
||||
if (filter === "active" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
|
||||
if (q) {
|
||||
const haystack = [r.id, r.range, ...r.domains].join(" ").toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [ordered, search, filter, overlapById]);
|
||||
|
||||
if (isConnected && networkRoutes.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex-1 flex items-center justify-center px-6 pb-20 w-full h-full min-h-0"
|
||||
}
|
||||
>
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title={t("networks.empty.title")}
|
||||
description={t("networks.empty.description")}
|
||||
learnMoreUrl={"https://docs.netbird.io/how-to/networks"}
|
||||
learnMoreTopic={t("nav.resources.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedInView = filtered.filter((r) => r.selected).length;
|
||||
const allSelected = filtered.length > 0 && selectedInView === filtered.length;
|
||||
const bulkLabel = allSelected
|
||||
? t("networks.bulk.disableAll")
|
||||
: t("networks.bulk.enableAll");
|
||||
|
||||
const onBulkClick = () => {
|
||||
if (filtered.length === 0) return;
|
||||
if (allSelected) {
|
||||
void setNetworksSelected(
|
||||
filtered.map((r) => r.id),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const ids = filtered.filter((r) => !r.selected).map((r) => r.id);
|
||||
void setNetworksSelected(ids, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||
<div className={"flex items-center gap-2 px-6 py-2.5 border-b border-nb-gray-910"}>
|
||||
<div className={"flex-1 min-w-0"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("networks.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<NetworkFilters value={filter} onChange={setFilter} counts={counts} />
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<NetworksList data={filtered} onToggle={toggleNetwork} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
{filtered.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-6 py-3.5",
|
||||
"border-t border-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<span className={"flex-1 text-xs font-medium text-nb-gray-300 tabular-nums"}>
|
||||
{t("networks.bulk.selectionCount", {
|
||||
selected: selectedInView,
|
||||
total: filtered.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onBulkClick}
|
||||
className={cn(
|
||||
"inline-flex items-center h-8 px-3 rounded-md",
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"bg-nb-gray-920 hover:bg-nb-gray-910 border border-nb-gray-900 hover:border-nb-gray-850",
|
||||
"transition-colors outline-none wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion, type Transition } from "framer-motion";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { PeerDetails } from "./PeerDetails";
|
||||
import { usePeerDetail } from "./PeerDetailContext";
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.32,
|
||||
ease: [0.32, 0.72, 0, 1],
|
||||
};
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { selected, setSelected } = usePeerDetail();
|
||||
const { status } = useStatus();
|
||||
|
||||
// Keep `selected` in sync with the live peer list so the panel reflects
|
||||
// status / latency / byte updates without re-opening. If the peer
|
||||
// disappears, close the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const peers = status?.peers ?? [];
|
||||
const fresh = peers.find((p) => p.pubKey === selected.pubKey);
|
||||
if (!fresh) {
|
||||
setSelected(null);
|
||||
return;
|
||||
}
|
||||
if (fresh !== selected) setSelected(fresh);
|
||||
}, [status, selected, setSelected]);
|
||||
|
||||
// Esc closes the panel.
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setSelected(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [selected, setSelected]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={transition}
|
||||
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-3",
|
||||
"px-3 h-12 border-b border-nb-gray-910",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label={t("common.close")}
|
||||
className={cn(
|
||||
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"transition-colors outline-none cursor-default",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
dotClass(selected.connStatus),
|
||||
)}
|
||||
title={selected.connStatus}
|
||||
/>
|
||||
<span className={"min-w-0 text-sm font-medium text-nb-gray-100 truncate"}>
|
||||
{selected.fqdn || selected.ip}
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerDetails peer={selected} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { latencyColor } from "./format";
|
||||
import { usePeerDetail } from "./PeerDetailContext";
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((peer) => {
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
return (
|
||||
<li
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
dotClass(peer.connStatus),
|
||||
)}
|
||||
title={peer.connStatus}
|
||||
/>
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<span
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{peer.fqdn}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.ip}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{peer.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{isConnected && peer.latencyMs > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 self-center text-xs tabular-nums",
|
||||
latencyColor(peer.latencyMs),
|
||||
)}
|
||||
>
|
||||
{peer.latencyMs} ms
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"shrink-0 self-center text-nb-gray-300",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
# Peers — info missing in PeersList.tsx
|
||||
|
||||
`PeersList.tsx` currently shows only: `connStatus` (dot), `fqdn`, `ip`.
|
||||
|
||||
`screens/Peers.tsx` additionally surfaces the following fields from `PeerStatus`:
|
||||
|
||||
## Row chrome (collapsed)
|
||||
- `peer.relayed` — Network (relayed, yellow) vs Zap (P2P, green) icon, gated on `connStatus === "Connected"`.
|
||||
- `peer.rosenpassEnabled` — ShieldCheck icon when true.
|
||||
- `peer.latencyMs` — `"{n} ms"` on the right when Connected and > 0.
|
||||
|
||||
## Top-level controls
|
||||
- Filter input — matches against `fqdn`, `ip`, and each entry in `networks`.
|
||||
- Peer count — `status.peers.length` next to the title.
|
||||
- Expand/collapse per row (chevron).
|
||||
|
||||
## Expanded details panel
|
||||
- `peer.pubKey` — Public key (mono).
|
||||
- `peer.lastHandshakeUnix` — Last handshake (relative time).
|
||||
- `peer.connStatusUpdateUnix` — Status since (relative time).
|
||||
- `peer.bytesRx` / `peer.bytesTx` — formatted B/KB/MB/GB.
|
||||
- `peer.localIceCandidateType` + `peer.localIceCandidateEndpoint` — Local candidate.
|
||||
- `peer.remoteIceCandidateType` + `peer.remoteIceCandidateEndpoint` — Remote candidate.
|
||||
- `peer.relayAddress` — shown only when `peer.relayed`.
|
||||
- `peer.networks` — joined list, shown when non-empty.
|
||||
|
||||
## `PeerStatus` interface (from `@bindings/services/models.js`)
|
||||
```ts
|
||||
interface PeerStatus {
|
||||
ip: string;
|
||||
pubKey: string;
|
||||
connStatus: string; // "Connected" | "Connecting" | "Idle" | ...
|
||||
connStatusUpdateUnix: number;
|
||||
relayed: boolean;
|
||||
localIceCandidateType: string;
|
||||
remoteIceCandidateType: string;
|
||||
localIceCandidateEndpoint: string;
|
||||
remoteIceCandidateEndpoint: string;
|
||||
fqdn: string;
|
||||
bytesRx: number;
|
||||
bytesTx: number;
|
||||
latencyMs: number;
|
||||
relayAddress: string;
|
||||
lastHandshakeUnix: number;
|
||||
rosenpassEnabled: boolean;
|
||||
networks: string[];
|
||||
}
|
||||
```
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import * as Dialog from "@/components/Dialog";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Button } from "@/components/Button";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
onCreate: (name: string) => void;
|
||||
};
|
||||
|
||||
export const NewProfileModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
+4
-4
@@ -5,11 +5,11 @@ import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react";
|
||||
import { pickProfileIcon } from "@/components/ProfileAvatar";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { NewProfileModal } from "@/components/NewProfileModal";
|
||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<NewProfileModal
|
||||
<ProfileCreationModal
|
||||
open={newProfileOpen}
|
||||
onOpenChange={setNewProfileOpen}
|
||||
onCreate={handleCreateProfile}
|
||||
+7
-7
@@ -4,20 +4,20 @@ import { Dialogs } from "@wailsio/runtime";
|
||||
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/Button";
|
||||
import HelpText from "@/components/HelpText";
|
||||
import { NewProfileModal } from "@/components/NewProfileModal";
|
||||
import { pickProfileIcon } from "@/components/ProfileAvatar";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import HelpText from "@/components/typography/HelpText";
|
||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export function SettingsProfiles() {
|
||||
export function ProfilesTab() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
profiles,
|
||||
@@ -145,7 +145,7 @@ export function SettingsProfiles() {
|
||||
</SettingsBottomBar>
|
||||
</SectionGroup>
|
||||
|
||||
<NewProfileModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import {
|
||||
Connection,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Session,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
+6
-6
@@ -2,14 +2,14 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
const WINDOW_WIDTH = 360;
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
|
||||
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { SettingsProfiles } from "@/modules/settings/SettingsProfiles.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx";
|
||||
|
||||
// The settings window opens at General by default. Navigation state (e.g. the
|
||||
// update-available header trigger jumps to About) or a `?tab=` query param
|
||||
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
|
||||
// the profile dropdown) override the default. No persistence across opens —
|
||||
// a user who wants to revisit a deep tab gets there in two clicks.
|
||||
export const Settings = () => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryTab = searchParams.get("tab");
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const [active, setActive] = useState(
|
||||
() => navState?.tab ?? queryTab ?? "general",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.tab) setActive(navState.tab);
|
||||
}, [navState?.tab, location.key]);
|
||||
|
||||
return (
|
||||
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
|
||||
<SettingsNavigationTriggers />
|
||||
<MainRightSide>
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"profiles"}>
|
||||
<SettingsProfiles />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"troubleshooting"}>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
{import.meta.env.DEV && (
|
||||
<VerticalTabs.Content value={"development"}>
|
||||
<SettingsDevelopment />
|
||||
</VerticalTabs.Content>
|
||||
)}
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</MainRightSide>
|
||||
</VerticalTabs>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "luci
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import pkg from "../../../package.json";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { System } from "@wailsio/runtime";
|
||||
import Button from "@/components/Button";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import Button from "@/components/buttons/Button";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
// macOS: the Darwin utun control socket parses the digits after "utun" as the
|
||||
// unit number, so the daemon (and the CLI's parseInterfaceName in
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
// Cross-window dev override: ClientVersionContext in the main window
|
||||
// listens for this and replaces daemon-reported update state with the
|
||||
// toggle values. Resets when the Settings window closes (no persistence
|
||||
// by design).
|
||||
const EVENT_DEV_OVERRIDES = "netbird:dev:overrides";
|
||||
const PREVIEW_VERSION = "0.65.0";
|
||||
|
||||
export function SettingsDevelopment() {
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [enforced, setEnforced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void Events.Emit(EVENT_DEV_OVERRIDES, {
|
||||
updateAvailable,
|
||||
enforced,
|
||||
version: PREVIEW_VERSION,
|
||||
});
|
||||
}, [updateAvailable, enforced]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Auto-update"}>
|
||||
<FancyToggleSwitch
|
||||
value={updateAvailable}
|
||||
onChange={setUpdateAvailable}
|
||||
label={"Is update available"}
|
||||
helpText={
|
||||
"Force the UI to think a new version is available. Reflects in the About card and the header badge."
|
||||
}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={enforced}
|
||||
onChange={setEnforced}
|
||||
label={"Auto update enabled"}
|
||||
helpText={
|
||||
"Force the UI to think management has auto-update enabled. Switches the About card to “Install now”."
|
||||
}
|
||||
/>
|
||||
<div className={"flex flex-col gap-2 items-start pt-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenInstallProgress(PREVIEW_VERSION).catch(
|
||||
console.error,
|
||||
)
|
||||
}
|
||||
>
|
||||
Show updating dialog
|
||||
</Button>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Session windows"}>
|
||||
<div className={"flex flex-col gap-2 items-start"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionExpired().catch(console.error)
|
||||
}
|
||||
>
|
||||
Open “Session expired”
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() =>
|
||||
WindowManager.OpenSessionAboutToExpire(336).catch(
|
||||
console.error,
|
||||
)
|
||||
}
|
||||
>
|
||||
Open “About to expire” (5:36)
|
||||
</Button>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/Button";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/modules/settings/LanguagePicker.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
+2
-10
@@ -2,10 +2,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@/components/Tooltip.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { UpdateBadge } from "@/modules/auto-update/UpdateBadge.tsx";
|
||||
import { useClientVersion } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||
import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
HammerIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const SettingsNavigationTriggers = () => {
|
||||
export const SettingsNavigation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { updateAvailable } = useClientVersion();
|
||||
|
||||
@@ -69,13 +68,6 @@ export const SettingsNavigationTriggers = () => {
|
||||
title={t("settings.tabs.about")}
|
||||
adornment={aboutAdornment}
|
||||
/>
|
||||
{import.meta.env.DEV && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"development"}
|
||||
icon={HammerIcon}
|
||||
title={"Development"}
|
||||
/>
|
||||
)}
|
||||
</VerticalTabs.List>
|
||||
</div>
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
export function SettingsNetwork() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AppRightPanel } from "@/layouts/AppRightPanel.tsx";
|
||||
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
|
||||
import { SettingsNavigation } from "@/modules/settings/SettingsNavigation.tsx";
|
||||
import { SettingsProvider } from "@/contexts/SettingsContext.tsx";
|
||||
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
|
||||
|
||||
// The settings window opens at General by default. Navigation state (e.g. the
|
||||
// update-available header trigger jumps to About) or a `?tab=` query param
|
||||
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
|
||||
// the profile dropdown) override the default. No persistence across opens —
|
||||
// a user who wants to revisit a deep tab gets there in two clicks.
|
||||
//
|
||||
// The `h-12` draggable strip at the top accounts for the macOS
|
||||
// `MacTitleBarHiddenInset` setting in services/windowmanager.go (traffic-light
|
||||
// buttons float over invisible title bar) and mirrors the main window's
|
||||
// Header height so AppRightPanel ends up the same height in both windows.
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryTab = searchParams.get("tab");
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const [active, setActive] = useState(
|
||||
() => navState?.tab ?? queryTab ?? "general",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.tab) setActive(navState.tab);
|
||||
}, [navState?.tab, location.key]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"wails-draggable cursor-default select-none h-12 shrink-0"
|
||||
}
|
||||
/>
|
||||
<VerticalTabs
|
||||
value={active}
|
||||
onValueChange={setActive}
|
||||
className={"p-4"}
|
||||
>
|
||||
<SettingsNavigation />
|
||||
<AppRightPanel>
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
className={"flex-1 min-h-0 overflow-hidden"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"py-8 px-7"}>
|
||||
<SettingsProvider>
|
||||
<VerticalTabs.Content value={"general"}>
|
||||
<SettingsGeneral />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"network"}>
|
||||
<SettingsNetwork />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"security"}>
|
||||
<SettingsSecurity />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"profiles"}>
|
||||
<ProfilesTab />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"ssh"}>
|
||||
<SettingsSSH />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"advanced"}>
|
||||
<SettingsAdvanced />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content
|
||||
value={"troubleshooting"}
|
||||
>
|
||||
<SettingsTroubleshooting />
|
||||
</VerticalTabs.Content>
|
||||
<VerticalTabs.Content value={"about"}>
|
||||
<SettingsAbout />
|
||||
</VerticalTabs.Content>
|
||||
</SettingsProvider>
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</AppRightPanel>
|
||||
</VerticalTabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
|
||||
export function SettingsSecurity() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
export const SettingsSkeleton = () => {
|
||||
return (
|
||||
<div className={"gap-6 flex flex-col"}>
|
||||
<div>
|
||||
@@ -3,18 +3,18 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
|
||||
import { Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import { Button } from "@/components/Button";
|
||||
import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
|
||||
import HelpText from "@/components/HelpText.tsx";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
import HelpText from "@/components/typography/HelpText.tsx";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
|
||||
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
|
||||
import type { DebugStage } from "@/contexts/DebugBundleContext";
|
||||
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
|
||||
export function SettingsTroubleshooting() {
|
||||
|
||||
Reference in New Issue
Block a user