mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-28 17:49:08 +02:00
add peer details
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "@/layouts/Header.tsx";
|
||||
import { NavSectionProvider } from "@/lib/navSection";
|
||||
import { ViewModeProvider } from "@/lib/viewMode";
|
||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||
import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
@@ -14,8 +15,10 @@ export const AppLayout = () => {
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<ViewModeProvider>
|
||||
<Header />
|
||||
<Outlet />
|
||||
<NavSectionProvider>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</NavSectionProvider>
|
||||
</ViewModeProvider>
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
|
||||
@@ -319,7 +319,7 @@ export const ConnectionStatusSwitch = () => {
|
||||
<div className={"flex flex-col items-center"}>
|
||||
<h1
|
||||
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])}
|
||||
@@ -339,7 +339,7 @@ export const ConnectionStatusSwitch = () => {
|
||||
<CopyToClipboard
|
||||
message={ip}
|
||||
className={cn(
|
||||
"min-h-[1em] transition-opacity duration-300 ",
|
||||
"min-h-[1em] transition-opacity duration-300",
|
||||
"relative left-[0.55rem]",
|
||||
showLocal && ip ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
|
||||
@@ -48,91 +48,97 @@ export const Header = () => {
|
||||
setViewMode(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable grid grid-cols-3 items-center",
|
||||
//"bg-gradient-to-b from-nb-gray-850/30",
|
||||
//"bg-nb-gray-935 border border-b border-nb-gray-850",
|
||||
"py-3 px-3",
|
||||
)}
|
||||
>
|
||||
<div />
|
||||
<div className={"flex justify-center ml-4"}>
|
||||
<ProfileDropdown onManageProfiles={openManageProfiles} />
|
||||
</div>
|
||||
<div className={"flex justify-end"}>
|
||||
<div className={"relative"}>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
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}>
|
||||
const profileSlot = <ProfileDropdown onManageProfiles={openManageProfiles} />;
|
||||
|
||||
const settingsSlot = (
|
||||
<div className={"relative"}>
|
||||
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
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">
|
||||
<Settings size={14} />
|
||||
{t("header.menu.settings")}
|
||||
<ArrowUpCircleIcon size={14} className={"text-netbird"} />
|
||||
<span className={"text-netbird"}>
|
||||
{t("header.menu.updateAvailable")}
|
||||
</span>
|
||||
</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>
|
||||
<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 className={"absolute right-3 top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.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 { Peers } from "@/modules/peers/Peers";
|
||||
import { Networks } from "@/modules/networks/Networks";
|
||||
import { ExitNodes } from "@/modules/exit-nodes/ExitNodes";
|
||||
import { NetworksProvider } from "@/modules/networks/NetworksContext";
|
||||
import {
|
||||
PeerDetailProvider,
|
||||
usePeerDetail,
|
||||
} from "@/modules/peers/PeerDetailContext";
|
||||
import { PeerDetailPanel } from "@/modules/peers/PeerDetailPanel";
|
||||
|
||||
export const Main = () => {
|
||||
const { viewMode } = useViewMode();
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
const [section, setSection] = useState<NavSection>("peers");
|
||||
|
||||
return (
|
||||
<NetworksProvider>
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
{/* Fixed-width column for the connection switch. Navigation
|
||||
is rendered absolutely at the bottom in advanced view so
|
||||
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>
|
||||
<PeerDetailProvider>
|
||||
<MainBody />
|
||||
</PeerDetailProvider>
|
||||
</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 { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
|
||||
type Props = {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable",
|
||||
"bg-nb-gray-935 border border-nb-gray-910",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
"wails-no-draggable relative",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,75 +1,79 @@
|
||||
import { useMemo } from "react";
|
||||
import { ComponentType } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { CardNavItem } from "@/components/CardNavItem.tsx";
|
||||
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon, SquareArrowUpRight } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/lib/navSection";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||
import { useNetworks } from "@/modules/networks/NetworksContext";
|
||||
|
||||
export type NavSection = "peers" | "networks" | "exitNode";
|
||||
|
||||
type Props = {
|
||||
active: NavSection;
|
||||
onSelect: (section: NavSection) => void;
|
||||
type TabEntry = {
|
||||
value: NavSection;
|
||||
label: string;
|
||||
icon: ComponentType<LucideProps>;
|
||||
};
|
||||
|
||||
export const Navigation = ({ active, onSelect }: Props) => {
|
||||
export const Navigation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { section, setSection } = useNavSection();
|
||||
const { status } = useStatus();
|
||||
const { networkRoutes, exitNodes, activeExitNode } = useNetworks();
|
||||
const isConnected = status?.status === "Connected";
|
||||
|
||||
const peerCounts = useMemo(() => {
|
||||
const peers = status?.peers ?? [];
|
||||
const online = peers.filter((p) => p.connStatus === "Connected").length;
|
||||
return { online, total: peers.length };
|
||||
}, [status?.peers]);
|
||||
|
||||
const networkCounts = useMemo(
|
||||
() => ({
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
total: networkRoutes.length,
|
||||
}),
|
||||
[networkRoutes],
|
||||
);
|
||||
|
||||
const exitNodeDescription = activeExitNode
|
||||
? activeExitNode.id
|
||||
: t("nav.exitNode.none", { total: exitNodes.length });
|
||||
const tabs: TabEntry[] = [
|
||||
{
|
||||
value: "peers",
|
||||
label: t("nav.peers.title"),
|
||||
icon: MonitorSmartphoneIcon,
|
||||
},
|
||||
{
|
||||
value: "networks",
|
||||
label: t("nav.resources.title"),
|
||||
icon: Layers3Icon,
|
||||
},
|
||||
{
|
||||
value: "exitNode",
|
||||
label: t("nav.exitNode.title"),
|
||||
icon: ExitNodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className={"w-full flex flex-col gap-1 mt-auto"}>
|
||||
<CardNavItem
|
||||
icon={MonitorSmartphoneIcon}
|
||||
title={t("nav.peers.title")}
|
||||
description={t("nav.peers.description", peerCounts)}
|
||||
active={active === "peers"}
|
||||
onClick={() => onSelect("peers")}
|
||||
/>
|
||||
<CardNavItem
|
||||
icon={Layers3Icon}
|
||||
title={t("nav.resources.title")}
|
||||
description={t("nav.resources.description", networkCounts)}
|
||||
iconSize={14}
|
||||
active={active === "networks"}
|
||||
onClick={() => onSelect("networks")}
|
||||
/>
|
||||
<CardNavItem
|
||||
iconNode={
|
||||
<SquareArrowUpRight
|
||||
size={14}
|
||||
<div className={"wails-no-draggable shrink-0 flex items-stretch"}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.value === section;
|
||||
const isDisabled = !isConnected && !isActive;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type={"button"}
|
||||
onClick={() => setSection(tab.value)}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"transition-colors duration-150 rotate-45",
|
||||
active === "exitNode"
|
||||
? "text-nb-gray-200"
|
||||
: "text-nb-gray-400",
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3",
|
||||
"outline-none transition-all",
|
||||
isActive ? "text-netbird" : "text-nb-gray-500 hover:text-nb-gray-400",
|
||||
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
title={t("nav.exitNode.title")}
|
||||
description={exitNodeDescription}
|
||||
active={active === "exitNode"}
|
||||
onClick={() => onSelect("exitNode")}
|
||||
/>
|
||||
</nav>
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 h-px transition-all",
|
||||
isActive
|
||||
? "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
|
||||
// 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
|
||||
// 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
|
||||
// content. The strip is `wails-draggable` so users can move the window by
|
||||
// dragging it.
|
||||
// top-left corner. The height also mirrors the main window's Header so the
|
||||
// MainRightSide panel ends up the same height in both windows.
|
||||
export const SettingsLayout = () => {
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col select-none"}>
|
||||
@@ -24,7 +23,7 @@ export const SettingsLayout = () => {
|
||||
<ClientVersionProvider>
|
||||
<div
|
||||
className={
|
||||
"wails-draggable cursor-default select-none h-[38px] shrink-0"
|
||||
"wails-draggable cursor-default select-none h-12 shrink-0"
|
||||
}
|
||||
/>
|
||||
<Outlet />
|
||||
|
||||
Reference in New Issue
Block a user