mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 04:19:08 +02:00
add peer details
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
|||||||
Generated
+31
@@ -11,6 +11,9 @@ dependencies:
|
|||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.16
|
specifier: ^2.1.16
|
||||||
version: 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
version: 2.1.16(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-hover-card':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.8
|
specifier: ^2.1.8
|
||||||
version: 2.1.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
version: 2.1.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
@@ -828,6 +831,34 @@ packages:
|
|||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1):
|
||||||
|
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
'@types/react': 18.3.28
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1):
|
/@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@18.3.1):
|
||||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { ComponentType, forwardRef, ReactNode } from "react";
|
import {
|
||||||
import { motion, HTMLMotionProps } from "framer-motion";
|
ButtonHTMLAttributes,
|
||||||
|
ComponentType,
|
||||||
|
forwardRef,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
import { LucideProps } from "lucide-react";
|
import { LucideProps } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
type Props = HTMLMotionProps<"button"> & {
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
icon?: ComponentType<LucideProps>;
|
icon?: ComponentType<LucideProps>;
|
||||||
iconNode?: ReactNode;
|
iconNode?: ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -28,10 +32,9 @@ export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type={type}
|
type={type}
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
|
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
|
||||||
"transition-colors duration-150",
|
"transition-colors duration-150",
|
||||||
@@ -77,7 +80,7 @@ export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.button>
|
</button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type CopyToClipboardProps = {
|
|||||||
size?: number;
|
size?: number;
|
||||||
iconAlignment?: "left" | "right";
|
iconAlignment?: "left" | "right";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
alwaysShowIcon?: boolean;
|
alwaysShowIcon?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export const CopyToClipboard = ({
|
|||||||
size = 10,
|
size = 10,
|
||||||
iconAlignment = "right",
|
iconAlignment = "right",
|
||||||
className,
|
className,
|
||||||
|
iconClassName,
|
||||||
alwaysShowIcon = false,
|
alwaysShowIcon = false,
|
||||||
}: CopyToClipboardProps) => {
|
}: CopyToClipboardProps) => {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -57,6 +59,7 @@ export const CopyToClipboard = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 inline-flex relative top-[2px] right-[1px]",
|
"shrink-0 inline-flex relative top-[2px] right-[1px]",
|
||||||
iconAlignment === "left" ? "order-first" : "order-last",
|
iconAlignment === "left" ? "order-first" : "order-last",
|
||||||
|
iconClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { ComponentType } from "react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
learnMoreUrl?: string;
|
||||||
|
learnMoreTopic?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUrl = (url: string) => {
|
||||||
|
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyState = ({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
learnMoreUrl,
|
||||||
|
learnMoreTopic,
|
||||||
|
className,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={cn("py-12 text-center", className)}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex flex-col items-center justify-center max-w-sm mx-auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SquareIcon icon={icon} className={"mb-3"} />
|
||||||
|
<p className={"text-base font-semibold text-nb-gray-200 mb-1"}>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{description && (
|
||||||
|
<p className={"text-sm text-nb-gray-350"}>{description}</p>
|
||||||
|
)}
|
||||||
|
{learnMoreUrl && learnMoreTopic && (
|
||||||
|
<p className={"text-sm text-nb-gray-350"}>
|
||||||
|
{t("common.learnMoreAbout")}{" "}
|
||||||
|
<a
|
||||||
|
href={learnMoreUrl}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openUrl(learnMoreUrl);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"text-netbird hover:underline underline-offset-4",
|
||||||
|
"cursor-pointer wails-no-draggable",
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{learnMoreTopic}
|
||||||
|
<ExternalLinkIcon
|
||||||
|
size={12}
|
||||||
|
className={"shrink-0"}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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 };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentType } from "react";
|
||||||
|
import { FunnelXIcon, LucideProps } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon?: ComponentType<LucideProps>;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoResults = ({
|
||||||
|
icon = FunnelXIcon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={icon}
|
||||||
|
title={title ?? t("common.noResults.title")}
|
||||||
|
description={description ?? t("common.noResults.description")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { UnplugIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
|
export const NotConnectedState = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"h-full min-h-[260px] flex items-center justify-center px-6"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={UnplugIcon}
|
||||||
|
title={t("notConnected.title")}
|
||||||
|
description={t("notConnected.description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
import { forwardRef, InputHTMLAttributes } from "react";
|
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||||
import { SearchIcon } from "lucide-react";
|
import { SearchIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
|
shortcut?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
||||||
function SearchInput({ iconSize = 16, className, ...props }, ref) {
|
function SearchInput(
|
||||||
|
{ iconSize = 16, className, disabled, shortcut, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className={"flex items-center gap-2 px-1 h-10"}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-1 h-10",
|
||||||
|
disabled && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SearchIcon
|
<SearchIcon
|
||||||
size={iconSize}
|
size={iconSize}
|
||||||
className={"text-nb-gray-300 shrink-0"}
|
className={"text-nb-gray-300 shrink-0"}
|
||||||
@@ -17,13 +26,29 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(
|
|||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type={"text"}
|
type={"text"}
|
||||||
|
disabled={disabled}
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||||
"outline-none border-none",
|
"outline-none border-none",
|
||||||
|
disabled && "cursor-not-allowed",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{shortcut && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 select-none",
|
||||||
|
"inline-flex items-center justify-center",
|
||||||
|
"h-5 min-w-[20px] px-1.5 rounded",
|
||||||
|
"border border-nb-gray-850 bg-nb-gray-920",
|
||||||
|
"text-[10px] font-medium text-nb-gray-400",
|
||||||
|
"wails-no-draggable",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{shortcut}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type SquareIconProps = {
|
|||||||
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
|
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 rounded-xl flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
|
"h-11 w-11 rounded-lg flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
|
|||||||
type SwitchItemGroupContextValue = {
|
type SwitchItemGroupContextValue = {
|
||||||
value: string;
|
value: string;
|
||||||
layoutId: string;
|
layoutId: string;
|
||||||
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
|
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
|
||||||
@@ -22,18 +23,27 @@ type Props = {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => {
|
export const SwitchItemGroup = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
}: Props) => {
|
||||||
const layoutId = useId();
|
const layoutId = useId();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwitchItemGroupContext.Provider value={{ value, layoutId }}>
|
<SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}>
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
|
||||||
|
disabled && "opacity-50 pointer-events-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Header } from "@/layouts/Header.tsx";
|
import { Header } from "@/layouts/Header.tsx";
|
||||||
|
import { NavSectionProvider } from "@/lib/navSection";
|
||||||
import { ViewModeProvider } from "@/lib/viewMode";
|
import { ViewModeProvider } from "@/lib/viewMode";
|
||||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||||
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
|
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
|
||||||
@@ -14,8 +15,10 @@ export const AppLayout = () => {
|
|||||||
<DebugBundleProvider>
|
<DebugBundleProvider>
|
||||||
<ClientVersionProvider>
|
<ClientVersionProvider>
|
||||||
<ViewModeProvider>
|
<ViewModeProvider>
|
||||||
<Header />
|
<NavSectionProvider>
|
||||||
<Outlet />
|
<Header />
|
||||||
|
<Outlet />
|
||||||
|
</NavSectionProvider>
|
||||||
</ViewModeProvider>
|
</ViewModeProvider>
|
||||||
</ClientVersionProvider>
|
</ClientVersionProvider>
|
||||||
</DebugBundleProvider>
|
</DebugBundleProvider>
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ export const ConnectionStatusSwitch = () => {
|
|||||||
<div className={"flex flex-col items-center"}>
|
<div className={"flex flex-col items-center"}>
|
||||||
<h1
|
<h1
|
||||||
className={
|
className={
|
||||||
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable"
|
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable mb-1"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t(STATUS_KEY[connState])}
|
{t(STATUS_KEY[connState])}
|
||||||
@@ -339,7 +339,7 @@ export const ConnectionStatusSwitch = () => {
|
|||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
message={ip}
|
message={ip}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-[1em] transition-opacity duration-300 ",
|
"min-h-[1em] transition-opacity duration-300",
|
||||||
"relative left-[0.55rem]",
|
"relative left-[0.55rem]",
|
||||||
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,91 +48,97 @@ export const Header = () => {
|
|||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const profileSlot = <ProfileDropdown onManageProfiles={openManageProfiles} />;
|
||||||
<div
|
|
||||||
className={cn(
|
const settingsSlot = (
|
||||||
"shrink-0 cursor-default wails-draggable grid grid-cols-3 items-center",
|
<div className={"relative"}>
|
||||||
//"bg-gradient-to-b from-nb-gray-850/30",
|
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
//"bg-nb-gray-935 border border-b border-nb-gray-850",
|
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
|
||||||
"py-3 px-3",
|
<IconButton
|
||||||
)}
|
icon={MoreVertical}
|
||||||
>
|
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||||
<div />
|
className={"select-none"}
|
||||||
<div className={"flex justify-center ml-4"}>
|
/>
|
||||||
<ProfileDropdown onManageProfiles={openManageProfiles} />
|
</DropdownMenuTrigger>
|
||||||
</div>
|
<DropdownMenuContent
|
||||||
<div className={"flex justify-end"}>
|
align="end"
|
||||||
<div className={"relative"}>
|
sideOffset={8}
|
||||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
className="min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
|
||||||
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
|
>
|
||||||
<IconButton
|
{updateAvailable && (
|
||||||
icon={MoreVertical}
|
<>
|
||||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
<DropdownMenuItem onClick={openAbout}>
|
||||||
className={"select-none"}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
|
|
||||||
>
|
|
||||||
{updateAvailable && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem onClick={openAbout}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ArrowUpCircleIcon
|
|
||||||
size={14}
|
|
||||||
className={"text-netbird"}
|
|
||||||
/>
|
|
||||||
<span className={"text-netbird"}>
|
|
||||||
{t("header.menu.updateAvailable")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={openSettings}>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings size={14} />
|
<ArrowUpCircleIcon size={14} className={"text-netbird"} />
|
||||||
{t("header.menu.settings")}
|
<span className={"text-netbird"}>
|
||||||
|
{t("header.menu.updateAvailable")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<ViewModeItem
|
</>
|
||||||
icon={RectangleVertical}
|
|
||||||
label={t("header.menu.defaultView")}
|
|
||||||
selected={viewMode === "default"}
|
|
||||||
onSelect={() => selectMode("default")}
|
|
||||||
/>
|
|
||||||
<ViewModeItem
|
|
||||||
icon={PanelsRightBottom}
|
|
||||||
label={t("header.menu.advancedView")}
|
|
||||||
selected={viewMode === "advanced"}
|
|
||||||
onSelect={() => selectMode("advanced")}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{updateAvailable && (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"absolute inset-0 rounded-full bg-netbird opacity-60 animate-ping"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
"relative h-1.5 w-1.5 rounded-full bg-netbird"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<DropdownMenuItem onClick={openSettings}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings size={14} />
|
||||||
|
{t("header.menu.settings")}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<ViewModeItem
|
||||||
|
icon={RectangleVertical}
|
||||||
|
label={t("header.menu.defaultView")}
|
||||||
|
selected={viewMode === "default"}
|
||||||
|
onSelect={() => selectMode("default")}
|
||||||
|
/>
|
||||||
|
<ViewModeItem
|
||||||
|
icon={PanelsRightBottom}
|
||||||
|
label={t("header.menu.advancedView")}
|
||||||
|
selected={viewMode === "advanced"}
|
||||||
|
onSelect={() => selectMode("advanced")}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{updateAvailable && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"absolute inset-0 rounded-full bg-netbird opacity-60 animate-ping"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={"relative h-1.5 w-1.5 rounded-full bg-netbird"} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The inner grid is locked to 356px (the default-mode content width:
|
||||||
|
// 380px window − 12px px-3 each side). It stays left-anchored regardless
|
||||||
|
// of window size, so the profile keeps the exact same absolute X
|
||||||
|
// position when the user flips to advanced view. The settings button is
|
||||||
|
// pulled out as an absolute, right-anchored element so it tracks the
|
||||||
|
// window's right edge in both modes.
|
||||||
|
// Header height matches the Settings window's top traffic-light strip
|
||||||
|
// so the right panel ends up the same height in both windows. The h-10
|
||||||
|
// of the inner buttons (profile trigger, more-vertical) defines the
|
||||||
|
// natural height; the strip in SettingsLayout is sized to mirror it.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 cursor-default wails-draggable relative",
|
||||||
|
"flex items-center h-12 px-3 top-2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={"grid grid-cols-3 items-center w-[356px] shrink-0"}>
|
||||||
|
<div />
|
||||||
|
<div className={"flex justify-center ml-4"}>{profileSlot}</div>
|
||||||
|
<div />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={"absolute right-3 top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +1,56 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
||||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||||
import { Navigation, NavSection } from "@/layouts/Navigation.tsx";
|
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||||
|
import { useNavSection } from "@/lib/navSection";
|
||||||
import { useViewMode } from "@/lib/viewMode";
|
import { useViewMode } from "@/lib/viewMode";
|
||||||
import { Peers } from "@/modules/peers/Peers";
|
import { Peers } from "@/modules/peers/Peers";
|
||||||
import { Networks } from "@/modules/networks/Networks";
|
import { Networks } from "@/modules/networks/Networks";
|
||||||
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
|
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
|
||||||
import { NetworksProvider } from "@/modules/networks/NetworksContext";
|
import { NetworksProvider } from "@/modules/networks/NetworksContext";
|
||||||
|
import {
|
||||||
|
PeerDetailProvider,
|
||||||
|
usePeerDetail,
|
||||||
|
} from "@/modules/peers/PeerDetailContext";
|
||||||
|
import { PeerDetailPanel } from "@/modules/peers/PeerDetailPanel";
|
||||||
|
|
||||||
export const Main = () => {
|
export const Main = () => {
|
||||||
const { viewMode } = useViewMode();
|
|
||||||
const isAdvanced = viewMode === "advanced";
|
|
||||||
const [section, setSection] = useState<NavSection>("peers");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NetworksProvider>
|
<NetworksProvider>
|
||||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
<PeerDetailProvider>
|
||||||
{/* Fixed-width column for the connection switch. Navigation
|
<MainBody />
|
||||||
is rendered absolutely at the bottom in advanced view so
|
</PeerDetailProvider>
|
||||||
it doesn't reshape the column and shift the switch up. */}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"relative flex flex-col items-center shrink-0 w-[348px]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ConnectionStatusSwitch />
|
|
||||||
{isAdvanced && (
|
|
||||||
<div className={"absolute inset-x-0 bottom-0 px-1"}>
|
|
||||||
<Navigation
|
|
||||||
active={section}
|
|
||||||
onSelect={setSection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isAdvanced && (
|
|
||||||
<MainRightSide>
|
|
||||||
{section === "peers" && <Peers />}
|
|
||||||
{section === "networks" && <Networks />}
|
|
||||||
{section === "exitNode" && <ExitNodes />}
|
|
||||||
</MainRightSide>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</NetworksProvider>
|
</NetworksProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MainBody = () => {
|
||||||
|
const { viewMode } = useViewMode();
|
||||||
|
const isAdvanced = viewMode === "advanced";
|
||||||
|
const { section } = useNavSection();
|
||||||
|
const { selected } = usePeerDetail();
|
||||||
|
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<Navigation />
|
||||||
|
<div className={"flex-1 min-h-0 flex flex-col"}>
|
||||||
|
{section === "peers" && <Peers />}
|
||||||
|
{section === "networks" && <Networks />}
|
||||||
|
{section === "exitNode" && <ExitNodes />}
|
||||||
|
</div>
|
||||||
|
</MainRightSide>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { cn } from "@/lib/cn.ts";
|
import { cn } from "@/lib/cn.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
overlayOpen?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MainRightSide = ({ children }: Props) => {
|
// iOS-style push transition: incoming pane slides in from the right while
|
||||||
|
// the outgoing pane shifts slightly left. Same easing on both sides so
|
||||||
|
// they feel like one motion.
|
||||||
|
const PANEL_TRANSITION = {
|
||||||
|
duration: 0.32,
|
||||||
|
ease: [0.32, 0.72, 0, 1] as [number, number, number, number],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainRightSide = ({ children, overlay, overlayOpen = false }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"wails-no-draggable",
|
"wails-no-draggable relative",
|
||||||
"bg-nb-gray-935 border border-nb-gray-910",
|
"bg-nb-gray-940 border border-nb-gray-920",
|
||||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
<motion.div
|
||||||
|
animate={{ x: overlayOpen ? -48 : 0 }}
|
||||||
|
transition={PANEL_TRANSITION}
|
||||||
|
className={"flex-1 min-h-0 min-w-0 flex flex-col"}
|
||||||
|
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
{overlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,75 +1,79 @@
|
|||||||
import { useMemo } from "react";
|
import { ComponentType } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layers3Icon, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { useNavSection, type NavSection } from "@/lib/navSection";
|
||||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
|
||||||
|
|
||||||
export type NavSection = "peers" | "networks" | "exitNode";
|
type TabEntry = {
|
||||||
|
value: NavSection;
|
||||||
type Props = {
|
label: string;
|
||||||
active: NavSection;
|
icon: ComponentType<LucideProps>;
|
||||||
onSelect: (section: NavSection) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Navigation = ({ active, onSelect }: Props) => {
|
export const Navigation = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { section, setSection } = useNavSection();
|
||||||
const { status } = useStatus();
|
const { status } = useStatus();
|
||||||
const { networkRoutes, exitNodes, activeExitNode } = useNetworks();
|
const isConnected = status?.status === "Connected";
|
||||||
|
|
||||||
const peerCounts = useMemo(() => {
|
const tabs: TabEntry[] = [
|
||||||
const peers = status?.peers ?? [];
|
{
|
||||||
const online = peers.filter((p) => p.connStatus === "Connected").length;
|
value: "peers",
|
||||||
return { online, total: peers.length };
|
label: t("nav.peers.title"),
|
||||||
}, [status?.peers]);
|
icon: MonitorSmartphoneIcon,
|
||||||
|
},
|
||||||
const networkCounts = useMemo(
|
{
|
||||||
() => ({
|
value: "networks",
|
||||||
active: networkRoutes.filter((r) => r.selected).length,
|
label: t("nav.resources.title"),
|
||||||
total: networkRoutes.length,
|
icon: Layers3Icon,
|
||||||
}),
|
},
|
||||||
[networkRoutes],
|
{
|
||||||
);
|
value: "exitNode",
|
||||||
|
label: t("nav.exitNode.title"),
|
||||||
const exitNodeDescription = activeExitNode
|
icon: ExitNodeIcon,
|
||||||
? activeExitNode.id
|
},
|
||||||
: t("nav.exitNode.none", { total: exitNodes.length });
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
<div className={"wails-no-draggable shrink-0 flex items-stretch"}>
|
||||||
<CardNavItem
|
{tabs.map((tab) => {
|
||||||
icon={MonitorSmartphoneIcon}
|
const isActive = tab.value === section;
|
||||||
title={t("nav.peers.title")}
|
const isDisabled = !isConnected && !isActive;
|
||||||
description={t("nav.peers.description", peerCounts)}
|
const Icon = tab.icon;
|
||||||
active={active === "peers"}
|
return (
|
||||||
onClick={() => onSelect("peers")}
|
<button
|
||||||
/>
|
key={tab.value}
|
||||||
<CardNavItem
|
type={"button"}
|
||||||
icon={Layers3Icon}
|
onClick={() => setSection(tab.value)}
|
||||||
title={t("nav.resources.title")}
|
disabled={isDisabled}
|
||||||
description={t("nav.resources.description", networkCounts)}
|
|
||||||
iconSize={14}
|
|
||||||
active={active === "networks"}
|
|
||||||
onClick={() => onSelect("networks")}
|
|
||||||
/>
|
|
||||||
<CardNavItem
|
|
||||||
iconNode={
|
|
||||||
<SquareArrowUpRight
|
|
||||||
size={14}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors duration-150 rotate-45",
|
"group relative flex flex-1 items-center justify-center",
|
||||||
active === "exitNode"
|
"gap-2.5 px-5 py-3",
|
||||||
? "text-nb-gray-200"
|
"outline-none transition-all",
|
||||||
: "text-nb-gray-400",
|
isActive ? "text-netbird" : "text-nb-gray-500 hover:text-nb-gray-400",
|
||||||
|
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
}
|
<Icon size={14} />
|
||||||
title={t("nav.exitNode.title")}
|
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||||
description={exitNodeDescription}
|
<span
|
||||||
active={active === "exitNode"}
|
className={cn(
|
||||||
onClick={() => onSelect("exitNode")}
|
"absolute inset-x-0 bottom-0 h-px transition-all",
|
||||||
/>
|
isActive
|
||||||
</nav>
|
? "bg-netbird"
|
||||||
|
: "bg-nb-gray-900 group-hover:bg-nb-gray-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ExitNodeIcon = (props: LucideProps) => (
|
||||||
|
<SquareArrowUpRight {...props} className={cn("rotate-45", props.className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type { NavSection } from "@/lib/navSection";
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
|||||||
// settings window has its own native title bar and doesn't show the profile
|
// settings window has its own native title bar and doesn't show the profile
|
||||||
// selector / panel toggle / settings icon.
|
// selector / panel toggle / settings icon.
|
||||||
//
|
//
|
||||||
// The 38px placeholder strip at the top accounts for the macOS
|
// The h-10 placeholder strip at the top accounts for the macOS
|
||||||
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
|
// `MacTitleBarHiddenInset` setting in services/windowmanager.go: the native
|
||||||
// title bar is invisible but the traffic-light buttons still float in the
|
// title bar is invisible but the traffic-light buttons still float in the
|
||||||
// top-left corner. Without this strip the buttons would overlap the settings
|
// top-left corner. The height also mirrors the main window's Header so the
|
||||||
// content. The strip is `wails-draggable` so users can move the window by
|
// MainRightSide panel ends up the same height in both windows.
|
||||||
// dragging it.
|
|
||||||
export const SettingsLayout = () => {
|
export const SettingsLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div className={"relative flex h-full flex-col select-none"}>
|
<div className={"relative flex h-full flex-col select-none"}>
|
||||||
@@ -24,7 +23,7 @@ export const SettingsLayout = () => {
|
|||||||
<ClientVersionProvider>
|
<ClientVersionProvider>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
"wails-draggable cursor-default select-none h-[38px] shrink-0"
|
"wails-draggable cursor-default select-none h-12 shrink-0"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export type NavSection = "peers" | "networks" | "exitNode";
|
||||||
|
|
||||||
|
type NavSectionContextValue = {
|
||||||
|
section: NavSection;
|
||||||
|
setSection: (s: NavSection) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NavSectionContext = createContext<NavSectionContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useNavSection = (): NavSectionContextValue => {
|
||||||
|
const ctx = useContext(NavSectionContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useNavSection must be used inside NavSectionProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NavSectionProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [section, setSection] = useState<NavSection>("peers");
|
||||||
|
return (
|
||||||
|
<NavSectionContext.Provider value={{ section, setSection }}>
|
||||||
|
{children}
|
||||||
|
</NavSectionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export type Shortcut = {
|
||||||
|
key: string; // e.g. "k", "Escape", "/"
|
||||||
|
cmd?: boolean; // requires Cmd (mac) / Ctrl (win/linux)
|
||||||
|
shift?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
// When true (default), preventDefault is called on a match.
|
||||||
|
preventDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listens for a keyboard shortcut on the window and invokes `callback` on
|
||||||
|
// match. Disable conditionally via `enabled` to avoid stealing keys while a
|
||||||
|
// dialog/panel is in the foreground.
|
||||||
|
export const useKeyboardShortcut = (
|
||||||
|
shortcut: Shortcut,
|
||||||
|
callback: () => void,
|
||||||
|
enabled = true,
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) return;
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
if (!!shortcut.cmd !== mod) return;
|
||||||
|
if (!!shortcut.shift !== e.shiftKey) return;
|
||||||
|
if (!!shortcut.alt !== e.altKey) return;
|
||||||
|
if (shortcut.preventDefault !== false) e.preventDefault();
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [
|
||||||
|
shortcut.key,
|
||||||
|
shortcut.cmd,
|
||||||
|
shortcut.shift,
|
||||||
|
shortcut.alt,
|
||||||
|
shortcut.preventDefault,
|
||||||
|
callback,
|
||||||
|
enabled,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// True on macOS — use the ⌘ glyph; otherwise show "Ctrl".
|
||||||
|
export const isMac =
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||||
|
|
||||||
|
export const formatShortcut = (shortcut: Shortcut): string => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (shortcut.cmd) parts.push(isMac ? "⌘" : "Ctrl");
|
||||||
|
if (shortcut.shift) parts.push(isMac ? "⇧" : "Shift");
|
||||||
|
if (shortcut.alt) parts.push(isMac ? "⌥" : "Alt");
|
||||||
|
parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key);
|
||||||
|
return parts.join(isMac ? "" : "+");
|
||||||
|
};
|
||||||
@@ -1,13 +1,26 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { WaypointsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SearchInput } from "@/components/SearchInput";
|
import { SearchInput } from "@/components/SearchInput";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { NoResults } from "@/components/NoResults";
|
||||||
|
import { NotConnectedState } from "@/components/NotConnectedState";
|
||||||
|
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||||
|
import {
|
||||||
|
formatShortcut,
|
||||||
|
useKeyboardShortcut,
|
||||||
|
} from "@/lib/useKeyboardShortcut";
|
||||||
|
|
||||||
|
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
|
||||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
import { useNetworks } from "@/modules/networks/NetworksContext";
|
||||||
import { ExitNodesList } from "./ExitNodesList";
|
import { ExitNodesList } from "./ExitNodesList";
|
||||||
|
|
||||||
export const ExitNodes = () => {
|
export const ExitNodes = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { status } = useStatus();
|
||||||
|
const isConnected = status?.status === "Connected";
|
||||||
const { exitNodes, toggleExitNode } = useNetworks();
|
const { exitNodes, toggleExitNode } = useNetworks();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -16,14 +29,16 @@ export const ExitNodes = () => {
|
|||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||||
|
searchRef.current?.focus();
|
||||||
|
searchRef.current?.select();
|
||||||
|
});
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const matches = exitNodes.filter((r) => {
|
const matches = exitNodes.filter((r) => {
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return (
|
return r.id.toLowerCase().includes(q);
|
||||||
r.id.toLowerCase().includes(q) ||
|
|
||||||
r.range.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
return matches.sort((a, b) => {
|
return matches.sort((a, b) => {
|
||||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||||
@@ -31,6 +46,32 @@ export const ExitNodes = () => {
|
|||||||
});
|
});
|
||||||
}, [exitNodes, search]);
|
}, [exitNodes, search]);
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||||
|
<NotConnectedState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitNodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex-1 flex items-center justify-center px-6 w-full h-full min-h-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={WaypointsIcon}
|
||||||
|
title={t("exitNodes.empty.title")}
|
||||||
|
description={t("exitNodes.empty.description")}
|
||||||
|
learnMoreUrl={"https://docs.netbird.io/how-to/exit-node"}
|
||||||
|
learnMoreTopic={t("nav.exitNode.title")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||||
<div className={"flex flex-col gap-3 px-6"}>
|
<div className={"flex flex-col gap-3 px-6"}>
|
||||||
@@ -39,6 +80,7 @@ export const ExitNodes = () => {
|
|||||||
placeholder={t("exitNodes.search.placeholder")}
|
placeholder={t("exitNodes.search.placeholder")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea.Root
|
<ScrollArea.Root
|
||||||
@@ -46,7 +88,11 @@ export const ExitNodes = () => {
|
|||||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||||
>
|
>
|
||||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
{filtered.length === 0 ? (
|
||||||
|
<NoResults />
|
||||||
|
) : (
|
||||||
|
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||||
|
)}
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
<ScrollArea.Scrollbar
|
<ScrollArea.Scrollbar
|
||||||
orientation={"vertical"}
|
orientation={"vertical"}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Network } from "@bindings/services/models.js";
|
import type { Network } from "@bindings/services/models.js";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
|
||||||
|
|
||||||
const dotClass = (selected: boolean): string =>
|
const NONE_VALUE = "__none__";
|
||||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Network[];
|
data: Network[];
|
||||||
@@ -13,49 +12,64 @@ type Props = {
|
|||||||
|
|
||||||
export const ExitNodesList = ({ data, onToggle }: Props) => {
|
export const ExitNodesList = ({ data, onToggle }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (data.length === 0) {
|
const active = data.find((n) => n.selected) ?? null;
|
||||||
return (
|
const value = active?.id ?? NONE_VALUE;
|
||||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
|
||||||
{t("exitNodes.empty")}
|
const handleChange = (next: string) => {
|
||||||
</div>
|
if (next === value) return;
|
||||||
);
|
if (next === NONE_VALUE) {
|
||||||
}
|
if (active) onToggle(active.id, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggle(next, false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={"flex flex-col"}>
|
<RadioGroup.Root
|
||||||
|
value={value}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
className={"flex flex-col"}
|
||||||
|
>
|
||||||
|
<Row value={NONE_VALUE} label={t("exitNodes.none")} />
|
||||||
{data.map((n) => (
|
{data.map((n) => (
|
||||||
<li
|
<Row key={n.id} value={n.id} label={n.id} />
|
||||||
key={n.id}
|
|
||||||
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type={"button"}
|
|
||||||
onClick={() => onToggle(n.id, n.selected)}
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 rounded-full shrink-0 cursor-pointer",
|
|
||||||
dotClass(n.selected),
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
n.selected
|
|
||||||
? t("exitNodes.active")
|
|
||||||
: t("exitNodes.inactive")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
|
|
||||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
|
||||||
{n.id}
|
|
||||||
</span>
|
|
||||||
</CopyToClipboard>
|
|
||||||
<CopyToClipboard
|
|
||||||
message={n.range}
|
|
||||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
|
||||||
>
|
|
||||||
<span className={"text-xs font-mono text-nb-gray-400"}>
|
|
||||||
{n.range}
|
|
||||||
</span>
|
|
||||||
</CopyToClipboard>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</RadioGroup.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RowProps = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ value, label }: RowProps) => (
|
||||||
|
<RadioGroup.Item
|
||||||
|
value={value}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 px-7 py-3 min-w-0 text-left outline-none",
|
||||||
|
"cursor-pointer wails-no-draggable",
|
||||||
|
"hover:bg-nb-gray-900/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"text-[0.81rem] font-medium text-nb-gray-100 truncate min-w-0 flex-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</RadioGroup.Item>
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { SwitchItem } from "@/components/SwitchItem";
|
import { SwitchItem } from "@/components/SwitchItem";
|
||||||
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||||
|
|
||||||
export type NetworkFilter = "all" | "selected" | "overlapping";
|
export type NetworkFilter = "all" | "active" | "overlapping";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: NetworkFilter;
|
value: NetworkFilter;
|
||||||
onChange: (value: NetworkFilter) => void;
|
onChange: (value: NetworkFilter) => void;
|
||||||
counts: Record<NetworkFilter, number>;
|
counts: Record<NetworkFilter, number>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NetworkFilters = ({ value, onChange, counts }: Props) => {
|
export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const filters: { value: NetworkFilter; label: string }[] = [
|
const filters: { value: NetworkFilter; label: string }[] = [
|
||||||
{ value: "all", label: t("networks.filter.all") },
|
{ value: "all", label: t("networks.filter.all") },
|
||||||
{ value: "selected", label: t("networks.filter.selected") },
|
{ value: "active", label: t("networks.filter.active") },
|
||||||
{ value: "overlapping", label: t("networks.filter.overlapping") },
|
{ value: "overlapping", label: t("networks.filter.overlapping") },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export const NetworkFilters = ({ value, onChange, counts }: Props) => {
|
|||||||
key={i18n.language}
|
key={i18n.language}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v as NetworkFilter)}
|
onChange={(v) => onChange(v as NetworkFilter)}
|
||||||
|
disabled={disabled}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
>
|
>
|
||||||
{filters.map((f) => (
|
{filters.map((f) => (
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { NetworkIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SearchInput } from "@/components/SearchInput";
|
import { SearchInput } from "@/components/SearchInput";
|
||||||
import { NetworkFilters, NetworkFilter } from "./NetworkFilters";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { NoResults } from "@/components/NoResults";
|
||||||
|
import { NotConnectedState } from "@/components/NotConnectedState";
|
||||||
|
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||||
|
import {
|
||||||
|
formatShortcut,
|
||||||
|
useKeyboardShortcut,
|
||||||
|
} from "@/lib/useKeyboardShortcut";
|
||||||
|
|
||||||
|
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
|
||||||
|
import { NetworkFilter, NetworkFilters } from "./NetworkFilters";
|
||||||
import { NetworksList } from "./NetworksList";
|
import { NetworksList } from "./NetworksList";
|
||||||
import { useNetworks } from "./NetworksContext";
|
import { useNetworks } from "./NetworksContext";
|
||||||
|
|
||||||
const collectOverlapping = (routes: { id: string; range: string; domains: string[] }[]): Set<string> => {
|
// 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[]>();
|
const byRange = new Map<string, string[]>();
|
||||||
for (const r of routes) {
|
for (const r of routes) {
|
||||||
if (r.domains.length > 0) continue;
|
if (r.domains.length > 0) continue;
|
||||||
@@ -15,15 +31,17 @@ const collectOverlapping = (routes: { id: string; range: string; domains: string
|
|||||||
arr.push(r.id);
|
arr.push(r.id);
|
||||||
byRange.set(r.range, arr);
|
byRange.set(r.range, arr);
|
||||||
}
|
}
|
||||||
const out = new Set<string>();
|
const out = new Map<string, string[]>();
|
||||||
for (const ids of byRange.values()) {
|
for (const [range, ids] of byRange) {
|
||||||
if (ids.length > 1) ids.forEach((id) => out.add(id));
|
if (ids.length > 1) out.set(range, ids);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Networks = () => {
|
export const Networks = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { status } = useStatus();
|
||||||
|
const isConnected = status?.status === "Connected";
|
||||||
const { networkRoutes, toggleNetwork } = useNetworks();
|
const { networkRoutes, toggleNetwork } = useNetworks();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||||
@@ -33,25 +51,38 @@ export const Networks = () => {
|
|||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const overlappingIds = useMemo(
|
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||||
() => collectOverlapping(networkRoutes),
|
searchRef.current?.focus();
|
||||||
|
searchRef.current?.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlapGroups = useMemo(
|
||||||
|
() => buildOverlapMap(networkRoutes),
|
||||||
[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>>(
|
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||||
() => ({
|
() => ({
|
||||||
all: networkRoutes.length,
|
all: networkRoutes.length,
|
||||||
selected: networkRoutes.filter((r) => r.selected).length,
|
active: networkRoutes.filter((r) => r.selected).length,
|
||||||
overlapping: overlappingIds.size,
|
overlapping: overlapById.size,
|
||||||
}),
|
}),
|
||||||
[networkRoutes, overlappingIds],
|
[networkRoutes, overlapById],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const matches = networkRoutes.filter((r) => {
|
const matches = networkRoutes.filter((r) => {
|
||||||
if (filter === "selected" && !r.selected) return false;
|
if (filter === "active" && !r.selected) return false;
|
||||||
if (filter === "overlapping" && !overlappingIds.has(r.id)) return false;
|
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
|
||||||
if (q) {
|
if (q) {
|
||||||
const haystack = [r.id, r.range, ...r.domains]
|
const haystack = [r.id, r.range, ...r.domains]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -64,7 +95,33 @@ export const Networks = () => {
|
|||||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||||
return a.id.localeCompare(b.id);
|
return a.id.localeCompare(b.id);
|
||||||
});
|
});
|
||||||
}, [networkRoutes, search, filter, overlappingIds]);
|
}, [networkRoutes, search, filter, overlapById]);
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||||
|
<NotConnectedState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkRoutes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex-1 flex items-center justify-center px-6 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||||
@@ -74,6 +131,7 @@ export const Networks = () => {
|
|||||||
placeholder={t("networks.search.placeholder")}
|
placeholder={t("networks.search.placeholder")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||||
/>
|
/>
|
||||||
<NetworkFilters
|
<NetworkFilters
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -86,7 +144,14 @@ export const Networks = () => {
|
|||||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||||
>
|
>
|
||||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||||
<NetworksList data={filtered} onToggle={toggleNetwork} />
|
{filtered.length === 0 ? (
|
||||||
|
<NoResults />
|
||||||
|
) : (
|
||||||
|
<NetworksList
|
||||||
|
data={filtered}
|
||||||
|
onToggle={toggleNetwork}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
<ScrollArea.Scrollbar
|
<ScrollArea.Scrollbar
|
||||||
orientation={"vertical"}
|
orientation={"vertical"}
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Network } from "@bindings/services/models.js";
|
import type { Network } from "@bindings/services/models.js";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||||
|
|
||||||
const dotClass = (selected: boolean): string =>
|
// The daemon stringifies route.Network via netip.Prefix.String(). For
|
||||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
// DNS-based routes the prefix is the zero value, which Go renders as
|
||||||
|
// "invalid Prefix". Those rows render their domain + resolved IPs instead.
|
||||||
|
const INVALID_PREFIX = "invalid Prefix";
|
||||||
|
|
||||||
|
const isDnsRoute = (n: Network): boolean =>
|
||||||
|
n.domains.length > 0 && (!n.range || n.range === INVALID_PREFIX);
|
||||||
|
|
||||||
|
// Mirror management's NetworkResourceType (resource.go GetResourceType):
|
||||||
|
// a CIDR is a host when its prefix length equals the address width
|
||||||
|
// (32 for IPv4, 128 for IPv6); anything broader is a subnet. Routes with
|
||||||
|
// domains attached are domain resources.
|
||||||
|
type ResourceType = "host" | "subnet" | "domain";
|
||||||
|
|
||||||
|
const isHostCidr = (cidr: string): boolean => {
|
||||||
|
const [addr, bitsStr] = cidr.split("/");
|
||||||
|
if (!addr || !bitsStr) return false;
|
||||||
|
const bits = Number(bitsStr);
|
||||||
|
// IPv6 prefixes always contain ':'; IPv4 prefixes always contain '.'.
|
||||||
|
const isV6 = addr.includes(":");
|
||||||
|
return isV6 ? bits === 128 : bits === 32;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceTypeOf = (n: Network): ResourceType => {
|
||||||
|
if (isDnsRoute(n)) return "domain";
|
||||||
|
// n.range is a single CIDR for resource routes. Exit-node v4+v6 pairs
|
||||||
|
// come comma-joined, but those are filtered out upstream — guard
|
||||||
|
// defensively by inspecting only the first segment.
|
||||||
|
const primary = n.range.split(",")[0].trim();
|
||||||
|
return isHostCidr(primary) ? "host" : "subnet";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResourceIcon = ({ type }: { type: ResourceType }) => {
|
||||||
|
if (type === "host") return <WorkflowIcon size={15} />;
|
||||||
|
if (type === "domain") return <GlobeIcon size={15} />;
|
||||||
|
return <NetworkIcon size={15} />;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: Network[];
|
data: Network[];
|
||||||
@@ -13,13 +50,6 @@ type Props = {
|
|||||||
|
|
||||||
export const NetworksList = ({ data, onToggle }: Props) => {
|
export const NetworksList = ({ data, onToggle }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
|
||||||
{t("networks.empty")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={"flex flex-col"}>
|
<ul className={"flex flex-col"}>
|
||||||
@@ -28,30 +58,177 @@ export const NetworksList = ({ data, onToggle }: Props) => {
|
|||||||
key={n.id}
|
key={n.id}
|
||||||
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
|
className={"flex items-center gap-3 px-7 py-3 min-w-0"}
|
||||||
>
|
>
|
||||||
<button
|
<NetworkToggle
|
||||||
type={"button"}
|
checked={n.selected}
|
||||||
onClick={() => onToggle(n.id, n.selected)}
|
onChange={() => onToggle(n.id, n.selected)}
|
||||||
className={cn(
|
label={
|
||||||
"h-2 w-2 rounded-full shrink-0 cursor-pointer",
|
n.selected
|
||||||
dotClass(n.selected),
|
? t("networks.selected")
|
||||||
)}
|
: t("networks.unselected")
|
||||||
title={n.selected ? t("networks.selected") : t("networks.unselected")}
|
}
|
||||||
/>
|
/>
|
||||||
<CopyToClipboard message={n.id} className={"min-w-0 flex-1"}>
|
<span className={"shrink-0 text-nb-gray-400"}>
|
||||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
<ResourceIcon type={resourceTypeOf(n)} />
|
||||||
{n.id}
|
</span>
|
||||||
</span>
|
<div className={"min-w-0 flex-1 flex flex-col gap-0.5"}>
|
||||||
</CopyToClipboard>
|
<CopyToClipboard message={n.id}>
|
||||||
<CopyToClipboard
|
<span
|
||||||
message={n.range}
|
className={
|
||||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
"text-[0.81rem] font-medium text-nb-gray-100"
|
||||||
>
|
}
|
||||||
<span className={"text-xs font-mono text-nb-gray-400"}>
|
>
|
||||||
{n.range}
|
{n.id}
|
||||||
</span>
|
</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
<Subtitle network={n} />
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Subtitle = ({ network }: { network: Network }) => {
|
||||||
|
if (isDnsRoute(network)) {
|
||||||
|
const domain = network.domains[0];
|
||||||
|
const ips = network.resolvedIps[domain] ?? [];
|
||||||
|
return <DomainSubtitle domain={domain} ips={ips} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (network.range && network.range !== INVALID_PREFIX) {
|
||||||
|
return (
|
||||||
|
<CopyToClipboard message={network.range}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"text-xs font-mono text-nb-gray-400 truncate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{network.range}
|
||||||
|
</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DomainSubtitleProps = {
|
||||||
|
domain: string;
|
||||||
|
ips: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DomainSubtitle = ({ domain, ips }: DomainSubtitleProps) => {
|
||||||
|
const first = ips[0];
|
||||||
|
const extra = ips.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"flex items-center gap-1.5 text-xs min-w-0"}>
|
||||||
|
<CopyToClipboard message={domain}>
|
||||||
|
<span className={"font-mono text-nb-gray-400 truncate"}>
|
||||||
|
{domain}
|
||||||
|
</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
{first && (
|
||||||
|
<>
|
||||||
|
<span className={"text-nb-gray-600"}>·</span>
|
||||||
|
<CopyToClipboard message={first}>
|
||||||
|
<span
|
||||||
|
className={"font-mono text-nb-gray-500 truncate"}
|
||||||
|
>
|
||||||
|
{first}
|
||||||
|
</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
{extra > 0 && <ResolvedIpsPopover ips={ips} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResolvedIpsPopover = ({ ips }: { ips: string[] }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const extra = ips.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type={"button"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded bg-nb-gray-900 hover:bg-nb-gray-850",
|
||||||
|
"px-1.5 py-0.5 text-[10px] font-medium text-nb-gray-300",
|
||||||
|
"wails-no-draggable cursor-pointer outline-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("networks.ips.more", { count: extra })}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
side={"bottom"}
|
||||||
|
align={"start"}
|
||||||
|
sideOffset={6}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 max-h-72 overflow-auto",
|
||||||
|
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||||
|
"p-2 shadow-lg outline-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"px-1 pb-1 text-[10px] uppercase tracking-wide text-nb-gray-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("networks.ips.heading")}
|
||||||
|
</div>
|
||||||
|
<ul className={"flex flex-col"}>
|
||||||
|
{ips.map((ip) => (
|
||||||
|
<li key={ip}>
|
||||||
|
<CopyToClipboard
|
||||||
|
message={ip}
|
||||||
|
className={"px-1 py-0.5"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToggleProps = {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NetworkToggle = ({ checked, onChange, label }: ToggleProps) => (
|
||||||
|
<button
|
||||||
|
type={"button"}
|
||||||
|
role={"switch"}
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onChange}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 inline-flex h-4 w-7 items-center rounded-full",
|
||||||
|
"transition-colors cursor-pointer wails-no-draggable",
|
||||||
|
checked ? "bg-netbird" : "bg-nb-gray-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3 w-3 rounded-full bg-white transition-transform",
|
||||||
|
checked ? "translate-x-3.5" : "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
import type { PeerStatus } from "@bindings/services/models.js";
|
||||||
|
|
||||||
|
type PeerDetailContextValue = {
|
||||||
|
selected: PeerStatus | null;
|
||||||
|
setSelected: (p: PeerStatus | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PeerDetailContext = createContext<PeerDetailContextValue | null>(null);
|
||||||
|
|
||||||
|
export const usePeerDetail = (): PeerDetailContextValue => {
|
||||||
|
const ctx = useContext(PeerDetailContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"usePeerDetail must be used inside PeerDetailProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [selected, setSelected] = useState<PeerStatus | null>(null);
|
||||||
|
return (
|
||||||
|
<PeerDetailContext.Provider value={{ selected, setSelected }}>
|
||||||
|
{children}
|
||||||
|
</PeerDetailContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { ComponentType, ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CableIcon,
|
||||||
|
ClockIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
HandshakeIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
Layers3Icon,
|
||||||
|
LucideProps,
|
||||||
|
MapPinIcon,
|
||||||
|
MonitorIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
Radio,
|
||||||
|
ZapIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { PeerStatus } from "@bindings/services/models.js";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||||
|
import { formatBytes, formatRelative, latencyColor } from "./format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
peer: PeerStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DASH = "-";
|
||||||
|
|
||||||
|
export const PeerDetails = ({ peer }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never");
|
||||||
|
const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH;
|
||||||
|
const isConnected = peer.connStatus === "Connected";
|
||||||
|
const ConnectionIcon = peer.relayed ? NetworkIcon : ZapIcon;
|
||||||
|
const connectionLabel = peer.relayed ? t("peers.details.relayed") : t("peers.details.p2p");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={"flex flex-col divide-y divide-nb-gray-920"}>
|
||||||
|
<Row icon={MapPinIcon} label={t("peers.details.netbirdIp")}>
|
||||||
|
{peer.ip ? (
|
||||||
|
<CopyToClipboard
|
||||||
|
message={peer.ip}
|
||||||
|
alwaysShowIcon
|
||||||
|
className={"max-w-full"}
|
||||||
|
iconClassName={"top-0"}
|
||||||
|
>
|
||||||
|
<span className={"font-mono"}>{peer.ip}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{isConnected && (
|
||||||
|
<Row icon={CableIcon} label={t("peers.details.connection")}>
|
||||||
|
<span className={"inline-flex items-center gap-1.5 whitespace-nowrap"}>
|
||||||
|
<ConnectionIcon size={13} />
|
||||||
|
{connectionLabel}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{peer.latencyMs > 0 && (
|
||||||
|
<Row icon={GaugeIcon} label={t("peers.details.latency")}>
|
||||||
|
<span className={cn("tabular-nums", latencyColor(peer.latencyMs))}>
|
||||||
|
{peer.latencyMs} ms
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{(peer.bytesRx > 0 || peer.bytesTx > 0) && (
|
||||||
|
<Row icon={ArrowUpDownIcon} label={t("peers.details.bytes")}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex items-center gap-3 justify-end text-nb-gray-300 font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
||||||
|
<ArrowDownIcon size={13} className={"text-sky-400"} />
|
||||||
|
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
|
||||||
|
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
|
||||||
|
<ArrowUpIcon size={13} className={"text-netbird"} />
|
||||||
|
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
|
||||||
|
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<Row icon={HandshakeIcon} label={t("peers.details.lastHandshake")}>
|
||||||
|
{lastHandshake}
|
||||||
|
</Row>
|
||||||
|
<Row icon={ClockIcon} label={t("peers.details.statusSince")}>
|
||||||
|
{statusSince}
|
||||||
|
</Row>
|
||||||
|
<IceRow
|
||||||
|
icon={MonitorIcon}
|
||||||
|
baseLabel={t("peers.details.localIce")}
|
||||||
|
type={peer.localIceCandidateType}
|
||||||
|
endpoint={peer.localIceCandidateEndpoint}
|
||||||
|
/>
|
||||||
|
<IceRow
|
||||||
|
icon={Radio}
|
||||||
|
baseLabel={t("peers.details.remoteIce")}
|
||||||
|
type={peer.remoteIceCandidateType}
|
||||||
|
endpoint={peer.remoteIceCandidateEndpoint}
|
||||||
|
/>
|
||||||
|
{peer.relayed && (
|
||||||
|
<Row icon={NetworkIcon} label={t("peers.details.relayAddress")}>
|
||||||
|
{peer.relayAddress ? (
|
||||||
|
<CopyToClipboard
|
||||||
|
message={peer.relayAddress}
|
||||||
|
alwaysShowIcon
|
||||||
|
className={"max-w-full"}
|
||||||
|
iconClassName={"top-0"}
|
||||||
|
>
|
||||||
|
<span className={"font-mono truncate"}>{peer.relayAddress}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{peer.networks.length > 0 && (
|
||||||
|
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
|
||||||
|
<span className={"break-words"}>{peer.networks.join(", ")}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
|
||||||
|
{peer.pubKey ? (
|
||||||
|
<CopyToClipboard
|
||||||
|
message={peer.pubKey}
|
||||||
|
alwaysShowIcon
|
||||||
|
className={"max-w-full"}
|
||||||
|
iconClassName={"top-0"}
|
||||||
|
>
|
||||||
|
<span className={"font-mono truncate"}>{peer.pubKey}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
) : (
|
||||||
|
DASH
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowProps = {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
iconClassName?: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IceRowProps = {
|
||||||
|
icon: ComponentType<LucideProps>;
|
||||||
|
baseLabel: string;
|
||||||
|
type: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const capitalize = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
|
|
||||||
|
const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
|
||||||
|
if (!type && !endpoint) return null;
|
||||||
|
const label = type ? `${baseLabel} (${capitalize(type)})` : baseLabel;
|
||||||
|
return (
|
||||||
|
<Row icon={icon} label={label}>
|
||||||
|
{endpoint ? (
|
||||||
|
<CopyToClipboard
|
||||||
|
message={endpoint}
|
||||||
|
alwaysShowIcon
|
||||||
|
className={"max-w-full"}
|
||||||
|
iconClassName={"top-0"}
|
||||||
|
>
|
||||||
|
<span className={"font-mono truncate"}>{endpoint}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
|
) : (
|
||||||
|
<span className={"truncate"}>{capitalize(type)}</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
|
||||||
|
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
|
||||||
|
<Icon size={14} className={cn("text-nb-gray-100 shrink-0", iconClassName)} />
|
||||||
|
<span className={"text-nb-gray-200 shrink-0 font-semibold"}>{label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 text-right pl-8",
|
||||||
|
"text-nb-gray-350 font-medium",
|
||||||
|
"flex justify-end items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
@@ -8,9 +8,10 @@ type Props = {
|
|||||||
value: StatusFilter;
|
value: StatusFilter;
|
||||||
onChange: (value: StatusFilter) => void;
|
onChange: (value: StatusFilter) => void;
|
||||||
counts: Record<StatusFilter, number>;
|
counts: Record<StatusFilter, number>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const filters: { value: StatusFilter; label: string }[] = [
|
const filters: { value: StatusFilter; label: string }[] = [
|
||||||
{ value: "all", label: t("peers.filter.all") },
|
{ value: "all", label: t("peers.filter.all") },
|
||||||
@@ -23,6 +24,7 @@ export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
|||||||
key={i18n.language}
|
key={i18n.language}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v as StatusFilter)}
|
onChange={(v) => onChange(v as StatusFilter)}
|
||||||
|
disabled={disabled}
|
||||||
className={"w-full"}
|
className={"w-full"}
|
||||||
>
|
>
|
||||||
{filters.map((f) => (
|
{filters.map((f) => (
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||||
|
import { LaptopIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { SearchInput } from "@/components/SearchInput";
|
import { SearchInput } from "@/components/SearchInput";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { NoResults } from "@/components/NoResults";
|
||||||
|
import { NotConnectedState } from "@/components/NotConnectedState";
|
||||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||||
|
import {
|
||||||
|
formatShortcut,
|
||||||
|
useKeyboardShortcut,
|
||||||
|
} from "@/lib/useKeyboardShortcut";
|
||||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||||
import { PeersList } from "./PeersList";
|
import { PeersList } from "./PeersList";
|
||||||
|
|
||||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||||
|
|
||||||
|
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
|
||||||
|
|
||||||
export const Peers = () => {
|
export const Peers = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { status } = useStatus();
|
const { status } = useStatus();
|
||||||
@@ -23,6 +33,12 @@ export const Peers = () => {
|
|||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||||
|
searchRef.current?.focus();
|
||||||
|
searchRef.current?.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConnected = status?.status === "Connected";
|
||||||
const peers = status?.peers ?? [];
|
const peers = status?.peers ?? [];
|
||||||
|
|
||||||
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||||
@@ -54,6 +70,32 @@ export const Peers = () => {
|
|||||||
});
|
});
|
||||||
}, [peers, search, statusFilter]);
|
}, [peers, search, statusFilter]);
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className={"flex flex-col w-full h-full min-h-0"}>
|
||||||
|
<NotConnectedState />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"flex-1 flex items-center justify-center px-6 w-full h-full min-h-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
icon={LaptopIcon}
|
||||||
|
title={t("peers.empty.title")}
|
||||||
|
description={t("peers.empty.description")}
|
||||||
|
learnMoreUrl={"https://docs.netbird.io/how-to/getting-started"}
|
||||||
|
learnMoreTopic={t("nav.peers.title")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||||
<div className={"flex flex-col gap-3 px-6"}>
|
<div className={"flex flex-col gap-3 px-6"}>
|
||||||
@@ -62,6 +104,7 @@ export const Peers = () => {
|
|||||||
placeholder={t("peers.search.placeholder")}
|
placeholder={t("peers.search.placeholder")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||||
/>
|
/>
|
||||||
<PeerFilters
|
<PeerFilters
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
@@ -74,7 +117,11 @@ export const Peers = () => {
|
|||||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||||
>
|
>
|
||||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||||
<PeersList data={filtered} />
|
{filtered.length === 0 ? (
|
||||||
|
<NoResults />
|
||||||
|
) : (
|
||||||
|
<PeersList data={filtered} />
|
||||||
|
)}
|
||||||
</ScrollArea.Viewport>
|
</ScrollArea.Viewport>
|
||||||
<ScrollArea.Scrollbar
|
<ScrollArea.Scrollbar
|
||||||
orientation={"vertical"}
|
orientation={"vertical"}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { ChevronRightIcon } from "lucide-react";
|
||||||
import type { PeerStatus } from "@bindings/services/models.js";
|
import type { PeerStatus } from "@bindings/services/models.js";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||||
|
import { latencyColor } from "./format";
|
||||||
|
import { usePeerDetail } from "./PeerDetailContext";
|
||||||
|
|
||||||
const dotClass = (connStatus: string): string => {
|
const dotClass = (connStatus: string): string => {
|
||||||
switch (connStatus) {
|
switch (connStatus) {
|
||||||
@@ -15,34 +17,69 @@ const dotClass = (connStatus: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||||
const { t } = useTranslation();
|
const { setSelected } = usePeerDetail();
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>{t("peers.empty")}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={"flex flex-col"}>
|
<ul className={"flex flex-col"}>
|
||||||
{data.map((peer) => (
|
{data.map((peer) => {
|
||||||
<li key={peer.pubKey} className={"flex items-center gap-3 px-7 py-3 min-w-0"}>
|
const isConnected = peer.connStatus === "Connected";
|
||||||
<span
|
return (
|
||||||
className={cn("h-2 w-2 rounded-full shrink-0", dotClass(peer.connStatus))}
|
<li
|
||||||
title={peer.connStatus}
|
key={peer.pubKey}
|
||||||
/>
|
onClick={() => setSelected(peer)}
|
||||||
<CopyToClipboard message={peer.fqdn} className={"min-w-0 flex-1"}>
|
className={cn(
|
||||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
"group flex items-start gap-2.5 px-7 py-3 min-w-0",
|
||||||
{peer.fqdn}
|
"hover:bg-nb-gray-900/40 transition-colors",
|
||||||
</span>
|
"wails-no-draggable cursor-pointer",
|
||||||
</CopyToClipboard>
|
)}
|
||||||
<CopyToClipboard
|
|
||||||
message={peer.ip}
|
|
||||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
|
||||||
>
|
>
|
||||||
<span className={"text-xs font-mono text-nb-gray-400"}>{peer.ip}</span>
|
<span
|
||||||
</CopyToClipboard>
|
className={cn(
|
||||||
</li>
|
"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>
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||||
|
try {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return (
|
||||||
|
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) +
|
||||||
|
" " +
|
||||||
|
sizes[i]
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "0 B";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const latencyColor = (ms: number): string => {
|
||||||
|
if (ms <= 0) return "text-nb-gray-400";
|
||||||
|
if (ms < 100) return "text-green-400";
|
||||||
|
return "text-yellow-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatRelative = (
|
||||||
|
unixSeconds: number,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): string | null => {
|
||||||
|
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
|
||||||
|
const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
};
|
||||||
@@ -61,6 +61,11 @@
|
|||||||
"common.refresh": "Aktualisieren",
|
"common.refresh": "Aktualisieren",
|
||||||
"common.loading": "Lädt…",
|
"common.loading": "Lädt…",
|
||||||
"common.netbird": "NetBird",
|
"common.netbird": "NetBird",
|
||||||
|
"common.learnMoreAbout": "Mehr erfahren über",
|
||||||
|
"common.noResults.title": "Keine Ergebnisse gefunden",
|
||||||
|
"common.noResults.description": "Es konnten keine Ergebnisse gefunden werden. Bitte versuchen Sie es mit einem anderen Suchbegriff oder ändern Sie Ihre Filter.",
|
||||||
|
"notConnected.title": "Nicht verbunden",
|
||||||
|
"notConnected.description": "Verbinden Sie sich mit NetBird, um Peers, Netzwerke und Exit Nodes anzuzeigen.",
|
||||||
|
|
||||||
"connect.status.disconnected": "Getrennt",
|
"connect.status.disconnected": "Getrennt",
|
||||||
"connect.status.connecting": "Verbindet…",
|
"connect.status.connecting": "Verbindet…",
|
||||||
@@ -74,11 +79,12 @@
|
|||||||
"connect.error.disconnectTitle": "Trennen fehlgeschlagen",
|
"connect.error.disconnectTitle": "Trennen fehlgeschlagen",
|
||||||
|
|
||||||
"nav.peers.title": "Peers",
|
"nav.peers.title": "Peers",
|
||||||
"nav.peers.description": "{online} von {total} online",
|
"nav.peers.description": "{connected} von {total} verbunden",
|
||||||
"nav.resources.title": "Ressourcen",
|
"nav.resources.title": "Ressourcen",
|
||||||
"nav.resources.description": "{active} von {total} aktiv",
|
"nav.resources.description": "{active} von {total} aktiv",
|
||||||
"nav.exitNode.title": "Exit-Node",
|
"nav.exitNode.title": "Exit-Node",
|
||||||
"nav.exitNode.none": "{total} verfügbar",
|
"nav.exitNode.none": "Nicht aktiv",
|
||||||
|
"nav.exitNode.using": "Über {name}",
|
||||||
|
|
||||||
"header.openSettings": "Einstellungen öffnen",
|
"header.openSettings": "Einstellungen öffnen",
|
||||||
"header.togglePanel": "Seitenleiste umschalten",
|
"header.togglePanel": "Seitenleiste umschalten",
|
||||||
@@ -303,20 +309,42 @@
|
|||||||
"peers.filter.all": "Alle",
|
"peers.filter.all": "Alle",
|
||||||
"peers.filter.online": "Online",
|
"peers.filter.online": "Online",
|
||||||
"peers.filter.offline": "Offline",
|
"peers.filter.offline": "Offline",
|
||||||
"peers.empty": "Keine Peers entsprechen den aktuellen Filtern.",
|
"peers.empty.title": "Noch keine Peers",
|
||||||
|
"peers.empty.description": "Es sind keine Peers mit Ihrem Netzwerk verbunden. Fügen Sie einen Peer hinzu, um zu beginnen.",
|
||||||
|
"peers.details.domain": "Domain",
|
||||||
|
"peers.details.netbirdIp": "NetBird-IP",
|
||||||
|
"peers.details.publicKey": "Öffentlicher Schlüssel",
|
||||||
|
"peers.details.connection": "Verbindung",
|
||||||
|
"peers.details.latency": "Latenz",
|
||||||
|
"peers.details.lastHandshake": "Letzter Handshake",
|
||||||
|
"peers.details.statusSince": "Letzte Verbindungsaktualisierung",
|
||||||
|
"peers.details.bytes": "Bytes",
|
||||||
|
"peers.details.bytesSent": "Gesendet",
|
||||||
|
"peers.details.bytesReceived": "Empfangen",
|
||||||
|
"peers.details.localIce": "Lokales ICE",
|
||||||
|
"peers.details.remoteIce": "Remote ICE",
|
||||||
|
"peers.details.never": "Nie",
|
||||||
|
"peers.details.relayAddress": "Relay",
|
||||||
|
"peers.details.networks": "Netzwerke",
|
||||||
|
"peers.details.relayed": "Relayed",
|
||||||
|
"peers.details.p2p": "P2P",
|
||||||
|
"peers.details.rosenpass": "Rosenpass aktiviert",
|
||||||
|
|
||||||
"networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen",
|
"networks.search.placeholder": "Nach Netzwerk, Bereich oder Domain suchen",
|
||||||
"networks.filter.all": "Alle",
|
"networks.filter.all": "Alle",
|
||||||
"networks.filter.selected": "Ausgewählt",
|
"networks.filter.active": "Aktiv",
|
||||||
"networks.filter.overlapping": "Überlappend",
|
"networks.filter.overlapping": "Überlappend",
|
||||||
"networks.empty": "Keine Netzwerke entsprechen den aktuellen Filtern.",
|
"networks.empty.title": "Keine Netzwerke verfügbar",
|
||||||
|
"networks.empty.description": "Für diesen Peer wurden keine geleiteten Netzwerke freigegeben.",
|
||||||
"networks.selected": "Ausgewählt",
|
"networks.selected": "Ausgewählt",
|
||||||
"networks.unselected": "Nicht ausgewählt",
|
"networks.unselected": "Nicht ausgewählt",
|
||||||
|
"networks.ips.more": "+{count} weitere",
|
||||||
|
"networks.ips.heading": "Aufgelöste IPs",
|
||||||
|
|
||||||
"exitNodes.search.placeholder": "Exit Nodes suchen",
|
"exitNodes.search.placeholder": "Exit Nodes suchen",
|
||||||
"exitNodes.empty": "Keine Exit Nodes verfügbar.",
|
"exitNodes.none": "Keiner",
|
||||||
"exitNodes.active": "Aktiv",
|
"exitNodes.empty.title": "Keine Exit Nodes verfügbar",
|
||||||
"exitNodes.inactive": "Inaktiv",
|
"exitNodes.empty.description": "Für diesen Peer wurden keine Exit Nodes freigegeben.",
|
||||||
|
|
||||||
"quickActions.connect": "Verbinden",
|
"quickActions.connect": "Verbinden",
|
||||||
"quickActions.disconnect": "Trennen",
|
"quickActions.disconnect": "Trennen",
|
||||||
|
|||||||
@@ -61,6 +61,11 @@
|
|||||||
"common.refresh": "Refresh",
|
"common.refresh": "Refresh",
|
||||||
"common.loading": "Loading…",
|
"common.loading": "Loading…",
|
||||||
"common.netbird": "NetBird",
|
"common.netbird": "NetBird",
|
||||||
|
"common.learnMoreAbout": "Learn more about",
|
||||||
|
"common.noResults.title": "Could not find any results",
|
||||||
|
"common.noResults.description": "We couldn't find any results. Please try a different search term or change your filters.",
|
||||||
|
"notConnected.title": "Not connected",
|
||||||
|
"notConnected.description": "Connect to NetBird to view peers, networks, and exit nodes.",
|
||||||
|
|
||||||
"connect.status.disconnected": "Disconnected",
|
"connect.status.disconnected": "Disconnected",
|
||||||
"connect.status.connecting": "Connecting...",
|
"connect.status.connecting": "Connecting...",
|
||||||
@@ -74,11 +79,12 @@
|
|||||||
"connect.error.disconnectTitle": "Disconnect Failed",
|
"connect.error.disconnectTitle": "Disconnect Failed",
|
||||||
|
|
||||||
"nav.peers.title": "Peers",
|
"nav.peers.title": "Peers",
|
||||||
"nav.peers.description": "{online} of {total} Online",
|
"nav.peers.description": "{connected} of {total} connected",
|
||||||
"nav.resources.title": "Resources",
|
"nav.resources.title": "Resources",
|
||||||
"nav.resources.description": "{active} of {total} Active",
|
"nav.resources.description": "{active} of {total} active",
|
||||||
"nav.exitNode.title": "Exit Node",
|
"nav.exitNode.title": "Exit Node",
|
||||||
"nav.exitNode.none": "{total} available",
|
"nav.exitNode.none": "Not active",
|
||||||
|
"nav.exitNode.using": "Via {name}",
|
||||||
|
|
||||||
"header.openSettings": "Open settings",
|
"header.openSettings": "Open settings",
|
||||||
"header.togglePanel": "Toggle side panel",
|
"header.togglePanel": "Toggle side panel",
|
||||||
@@ -319,20 +325,42 @@
|
|||||||
"peers.filter.all": "All",
|
"peers.filter.all": "All",
|
||||||
"peers.filter.online": "Online",
|
"peers.filter.online": "Online",
|
||||||
"peers.filter.offline": "Offline",
|
"peers.filter.offline": "Offline",
|
||||||
"peers.empty": "No peers match the current filters.",
|
"peers.empty.title": "No peers yet",
|
||||||
|
"peers.empty.description": "No peers are connected to your network. Add a peer to get started.",
|
||||||
|
"peers.details.domain": "Domain",
|
||||||
|
"peers.details.netbirdIp": "NetBird IP",
|
||||||
|
"peers.details.publicKey": "Public key",
|
||||||
|
"peers.details.connection": "Connection",
|
||||||
|
"peers.details.latency": "Latency",
|
||||||
|
"peers.details.lastHandshake": "Last handshake",
|
||||||
|
"peers.details.statusSince": "Last connection update",
|
||||||
|
"peers.details.bytes": "Bytes",
|
||||||
|
"peers.details.bytesSent": "Sent",
|
||||||
|
"peers.details.bytesReceived": "Received",
|
||||||
|
"peers.details.localIce": "Local ICE",
|
||||||
|
"peers.details.remoteIce": "Remote ICE",
|
||||||
|
"peers.details.never": "Never",
|
||||||
|
"peers.details.relayAddress": "Relay",
|
||||||
|
"peers.details.networks": "Networks",
|
||||||
|
"peers.details.relayed": "Relayed",
|
||||||
|
"peers.details.p2p": "P2P",
|
||||||
|
"peers.details.rosenpass": "Rosenpass enabled",
|
||||||
|
|
||||||
"networks.search.placeholder": "Search by network, range or domain",
|
"networks.search.placeholder": "Search by network, range or domain",
|
||||||
"networks.filter.all": "All",
|
"networks.filter.all": "All",
|
||||||
"networks.filter.selected": "Selected",
|
"networks.filter.active": "Active",
|
||||||
"networks.filter.overlapping": "Overlapping",
|
"networks.filter.overlapping": "Overlapping",
|
||||||
"networks.empty": "No networks match the current filters.",
|
"networks.empty.title": "No networks available",
|
||||||
|
"networks.empty.description": "No routed networks have been shared with this peer.",
|
||||||
"networks.selected": "Selected",
|
"networks.selected": "Selected",
|
||||||
"networks.unselected": "Not selected",
|
"networks.unselected": "Not selected",
|
||||||
|
"networks.ips.more": "+{count} more",
|
||||||
|
"networks.ips.heading": "Resolved IPs",
|
||||||
|
|
||||||
"exitNodes.search.placeholder": "Search exit nodes",
|
"exitNodes.search.placeholder": "Search exit nodes",
|
||||||
"exitNodes.empty": "No exit nodes available.",
|
"exitNodes.none": "None",
|
||||||
"exitNodes.active": "Active",
|
"exitNodes.empty.title": "No exit nodes available",
|
||||||
"exitNodes.inactive": "Inactive",
|
"exitNodes.empty.description": "No exit nodes have been shared with this peer.",
|
||||||
|
|
||||||
"quickActions.connect": "Connect",
|
"quickActions.connect": "Connect",
|
||||||
"quickActions.disconnect": "Disconnect",
|
"quickActions.disconnect": "Disconnect",
|
||||||
|
|||||||
@@ -61,6 +61,11 @@
|
|||||||
"common.refresh": "Frissítés",
|
"common.refresh": "Frissítés",
|
||||||
"common.loading": "Betöltés…",
|
"common.loading": "Betöltés…",
|
||||||
"common.netbird": "NetBird",
|
"common.netbird": "NetBird",
|
||||||
|
"common.learnMoreAbout": "Tudjon meg többet erről:",
|
||||||
|
"common.noResults.title": "Nincs találat",
|
||||||
|
"common.noResults.description": "Nem találtunk eredményt. Próbáljon meg másik keresési kifejezést, vagy módosítsa a szűrőket.",
|
||||||
|
"notConnected.title": "Nincs csatlakozva",
|
||||||
|
"notConnected.description": "Csatlakozzon a NetBirdhöz a társak, hálózatok és kilépő csomópontok megtekintéséhez.",
|
||||||
|
|
||||||
"connect.status.disconnected": "Lekapcsolva",
|
"connect.status.disconnected": "Lekapcsolva",
|
||||||
"connect.status.connecting": "Csatlakozás…",
|
"connect.status.connecting": "Csatlakozás…",
|
||||||
@@ -74,11 +79,12 @@
|
|||||||
"connect.error.disconnectTitle": "Bontás sikertelen",
|
"connect.error.disconnectTitle": "Bontás sikertelen",
|
||||||
|
|
||||||
"nav.peers.title": "Társak",
|
"nav.peers.title": "Társak",
|
||||||
"nav.peers.description": "{online} / {total} online",
|
"nav.peers.description": "{connected} / {total} csatlakoztatva",
|
||||||
"nav.resources.title": "Erőforrások",
|
"nav.resources.title": "Erőforrások",
|
||||||
"nav.resources.description": "{active} / {total} aktív",
|
"nav.resources.description": "{active} / {total} aktív",
|
||||||
"nav.exitNode.title": "Kilépő csomópont",
|
"nav.exitNode.title": "Kilépő csomópont",
|
||||||
"nav.exitNode.none": "{total} elérhető",
|
"nav.exitNode.none": "Nem aktív",
|
||||||
|
"nav.exitNode.using": "Ezen át: {name}",
|
||||||
|
|
||||||
"header.openSettings": "Beállítások megnyitása",
|
"header.openSettings": "Beállítások megnyitása",
|
||||||
"header.togglePanel": "Oldalsó panel váltása",
|
"header.togglePanel": "Oldalsó panel váltása",
|
||||||
@@ -303,20 +309,42 @@
|
|||||||
"peers.filter.all": "Összes",
|
"peers.filter.all": "Összes",
|
||||||
"peers.filter.online": "Online",
|
"peers.filter.online": "Online",
|
||||||
"peers.filter.offline": "Offline",
|
"peers.filter.offline": "Offline",
|
||||||
"peers.empty": "Egyetlen társ sem felel meg a jelenlegi szűrőknek.",
|
"peers.empty.title": "Még nincsenek társak",
|
||||||
|
"peers.empty.description": "Egyetlen társ sem csatlakozott a hálózatához. Adjon hozzá egy társat a kezdéshez.",
|
||||||
|
"peers.details.domain": "Domain",
|
||||||
|
"peers.details.netbirdIp": "NetBird IP",
|
||||||
|
"peers.details.publicKey": "Publikus kulcs",
|
||||||
|
"peers.details.connection": "Kapcsolat",
|
||||||
|
"peers.details.latency": "Késleltetés",
|
||||||
|
"peers.details.lastHandshake": "Utolsó kézfogás",
|
||||||
|
"peers.details.statusSince": "Utolsó kapcsolati frissítés",
|
||||||
|
"peers.details.bytes": "Bájtok",
|
||||||
|
"peers.details.bytesSent": "Küldve",
|
||||||
|
"peers.details.bytesReceived": "Fogadva",
|
||||||
|
"peers.details.localIce": "Helyi ICE",
|
||||||
|
"peers.details.remoteIce": "Távoli ICE",
|
||||||
|
"peers.details.never": "Soha",
|
||||||
|
"peers.details.relayAddress": "Relay",
|
||||||
|
"peers.details.networks": "Hálózatok",
|
||||||
|
"peers.details.relayed": "Relayed",
|
||||||
|
"peers.details.p2p": "P2P",
|
||||||
|
"peers.details.rosenpass": "Rosenpass engedélyezve",
|
||||||
|
|
||||||
"networks.search.placeholder": "Keresés hálózat, tartomány vagy domain alapján",
|
"networks.search.placeholder": "Keresés hálózat, tartomány vagy domain alapján",
|
||||||
"networks.filter.all": "Összes",
|
"networks.filter.all": "Összes",
|
||||||
"networks.filter.selected": "Kiválasztott",
|
"networks.filter.active": "Aktív",
|
||||||
"networks.filter.overlapping": "Átfedő",
|
"networks.filter.overlapping": "Átfedő",
|
||||||
"networks.empty": "Egyetlen hálózat sem felel meg a jelenlegi szűrőknek.",
|
"networks.empty.title": "Nincs elérhető hálózat",
|
||||||
|
"networks.empty.description": "Ehhez a társhoz nem osztottak meg útvonalas hálózatokat.",
|
||||||
"networks.selected": "Kiválasztva",
|
"networks.selected": "Kiválasztva",
|
||||||
"networks.unselected": "Nincs kiválasztva",
|
"networks.unselected": "Nincs kiválasztva",
|
||||||
|
"networks.ips.more": "+{count} további",
|
||||||
|
"networks.ips.heading": "Feloldott IP-címek",
|
||||||
|
|
||||||
"exitNodes.search.placeholder": "Keresés a kilépő csomópontok között",
|
"exitNodes.search.placeholder": "Keresés a kilépő csomópontok között",
|
||||||
"exitNodes.empty": "Nincs elérhető kilépő csomópont.",
|
"exitNodes.none": "Egyik sem",
|
||||||
"exitNodes.active": "Aktív",
|
"exitNodes.empty.title": "Nincs elérhető kilépő csomópont",
|
||||||
"exitNodes.inactive": "Inaktív",
|
"exitNodes.empty.description": "Ehhez a társhoz nem osztottak meg kilépő csomópontokat.",
|
||||||
|
|
||||||
"quickActions.connect": "Csatlakozás",
|
"quickActions.connect": "Csatlakozás",
|
||||||
"quickActions.disconnect": "Bontás",
|
"quickActions.disconnect": "Bontás",
|
||||||
|
|||||||
Reference in New Issue
Block a user