add peer details

This commit is contained in:
Eduard Gert
2026-05-27 14:52:19 +02:00
parent 004a305e46
commit 0e83d2ad94
35 changed files with 1549 additions and 345 deletions
+5 -2
View File
@@ -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",
)}
+85 -79
View File
@@ -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>
);
};
+42 -33
View File
@@ -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>
);
};
+64 -60
View File
@@ -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 />