mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-31 20:11:31 +02:00
add peer details
This commit is contained in:
@@ -1,13 +1,26 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
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 { ExitNodesList } from "./ExitNodesList";
|
||||
|
||||
export const ExitNodes = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { exitNodes, toggleExitNode } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
@@ -16,14 +29,16 @@ export const ExitNodes = () => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const matches = exitNodes.filter((r) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
r.id.toLowerCase().includes(q) ||
|
||||
r.range.toLowerCase().includes(q)
|
||||
);
|
||||
return r.id.toLowerCase().includes(q);
|
||||
});
|
||||
return matches.sort((a, b) => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
@@ -31,6 +46,32 @@ export const ExitNodes = () => {
|
||||
});
|
||||
}, [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 (
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-6"}>
|
||||
@@ -39,6 +80,7 @@ export const ExitNodes = () => {
|
||||
placeholder={t("exitNodes.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea.Root
|
||||
@@ -46,7 +88,11 @@ export const ExitNodes = () => {
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ExitNodesList data={filtered} onToggle={toggleExitNode} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
|
||||
const dotClass = (selected: boolean): string =>
|
||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
type Props = {
|
||||
data: Network[];
|
||||
@@ -13,49 +12,64 @@ type Props = {
|
||||
|
||||
export const ExitNodesList = ({ data, onToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
|
||||
{t("exitNodes.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const active = data.find((n) => n.selected) ?? null;
|
||||
const value = active?.id ?? NONE_VALUE;
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
if (next === value) return;
|
||||
if (next === NONE_VALUE) {
|
||||
if (active) onToggle(active.id, true);
|
||||
return;
|
||||
}
|
||||
onToggle(next, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<li
|
||||
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>
|
||||
<Row key={n.id} value={n.id} label={n.id} />
|
||||
))}
|
||||
</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 { SwitchItemGroup } from "@/components/SwitchItemGroup";
|
||||
|
||||
export type NetworkFilter = "all" | "selected" | "overlapping";
|
||||
export type NetworkFilter = "all" | "active" | "overlapping";
|
||||
|
||||
type Props = {
|
||||
value: NetworkFilter;
|
||||
onChange: (value: NetworkFilter) => void;
|
||||
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 filters: { value: NetworkFilter; label: string }[] = [
|
||||
{ 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") },
|
||||
];
|
||||
|
||||
@@ -23,6 +24,7 @@ export const NetworkFilters = ({ value, onChange, counts }: Props) => {
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as NetworkFilter)}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
{filters.map((f) => (
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { NetworkIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SearchInput } from "@/components/SearchInput";
|
||||
import { 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 { 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[]>();
|
||||
for (const r of routes) {
|
||||
if (r.domains.length > 0) continue;
|
||||
@@ -15,15 +31,17 @@ const collectOverlapping = (routes: { id: string; range: string; domains: string
|
||||
arr.push(r.id);
|
||||
byRange.set(r.range, arr);
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const ids of byRange.values()) {
|
||||
if (ids.length > 1) ids.forEach((id) => out.add(id));
|
||||
const out = new Map<string, string[]>();
|
||||
for (const [range, ids] of byRange) {
|
||||
if (ids.length > 1) out.set(range, ids);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const Networks = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const isConnected = status?.status === "Connected";
|
||||
const { networkRoutes, toggleNetwork } = useNetworks();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<NetworkFilter>("all");
|
||||
@@ -33,25 +51,38 @@ export const Networks = () => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlappingIds = useMemo(
|
||||
() => collectOverlapping(networkRoutes),
|
||||
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
});
|
||||
|
||||
const overlapGroups = useMemo(
|
||||
() => buildOverlapMap(networkRoutes),
|
||||
[networkRoutes],
|
||||
);
|
||||
|
||||
const overlapById = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const ids of overlapGroups.values()) {
|
||||
for (const id of ids) map.set(id, ids);
|
||||
}
|
||||
return map;
|
||||
}, [overlapGroups]);
|
||||
|
||||
const counts = useMemo<Record<NetworkFilter, number>>(
|
||||
() => ({
|
||||
all: networkRoutes.length,
|
||||
selected: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlappingIds.size,
|
||||
active: networkRoutes.filter((r) => r.selected).length,
|
||||
overlapping: overlapById.size,
|
||||
}),
|
||||
[networkRoutes, overlappingIds],
|
||||
[networkRoutes, overlapById],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const matches = networkRoutes.filter((r) => {
|
||||
if (filter === "selected" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlappingIds.has(r.id)) return false;
|
||||
if (filter === "active" && !r.selected) return false;
|
||||
if (filter === "overlapping" && !overlapById.has(r.id)) return false;
|
||||
if (q) {
|
||||
const haystack = [r.id, r.range, ...r.domains]
|
||||
.join(" ")
|
||||
@@ -64,7 +95,33 @@ export const Networks = () => {
|
||||
if (a.selected !== b.selected) return a.selected ? -1 : 1;
|
||||
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 (
|
||||
<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")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||
/>
|
||||
<NetworkFilters
|
||||
value={filter}
|
||||
@@ -86,7 +144,14 @@ export const Networks = () => {
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<NetworksList data={filtered} onToggle={toggleNetwork} />
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<NetworksList
|
||||
data={filtered}
|
||||
onToggle={toggleNetwork}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
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 type { Network } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
|
||||
const dotClass = (selected: boolean): string =>
|
||||
selected ? "bg-green-400" : "bg-nb-gray-500";
|
||||
// The daemon stringifies route.Network via netip.Prefix.String(). For
|
||||
// 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 = {
|
||||
data: Network[];
|
||||
@@ -13,13 +50,6 @@ type Props = {
|
||||
|
||||
export const NetworksList = ({ data, onToggle }: Props) => {
|
||||
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 (
|
||||
<ul className={"flex flex-col"}>
|
||||
@@ -28,30 +58,177 @@ export const NetworksList = ({ data, onToggle }: Props) => {
|
||||
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("networks.selected") : t("networks.unselected")}
|
||||
<NetworkToggle
|
||||
checked={n.selected}
|
||||
onChange={() => onToggle(n.id, n.selected)}
|
||||
label={
|
||||
n.selected
|
||||
? t("networks.selected")
|
||||
: t("networks.unselected")
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<span className={"shrink-0 text-nb-gray-400"}>
|
||||
<ResourceIcon type={resourceTypeOf(n)} />
|
||||
</span>
|
||||
<div className={"min-w-0 flex-1 flex flex-col gap-0.5"}>
|
||||
<CopyToClipboard message={n.id}>
|
||||
<span
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
{n.id}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<Subtitle network={n} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
28
client/ui/frontend/src/modules/peers/PeerDetailContext.tsx
Normal file
28
client/ui/frontend/src/modules/peers/PeerDetailContext.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
122
client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx
Normal file
122
client/ui/frontend/src/modules/peers/PeerDetailPanel.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
198
client/ui/frontend/src/modules/peers/PeerDetails.tsx
Normal file
198
client/ui/frontend/src/modules/peers/PeerDetails.tsx
Normal file
@@ -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;
|
||||
onChange: (value: StatusFilter) => void;
|
||||
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 filters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "all", label: t("peers.filter.all") },
|
||||
@@ -23,6 +24,7 @@ export const PeerFilters = ({ value, onChange, counts }: Props) => {
|
||||
key={i18n.language}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v as StatusFilter)}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
{filters.map((f) => (
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { LaptopIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
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";
|
||||
import { PeerFilters, StatusFilter } from "./PeerFilters";
|
||||
import { PeersList } from "./PeersList";
|
||||
|
||||
const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
|
||||
const SEARCH_SHORTCUT = { key: "k", cmd: true } as const;
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
@@ -23,6 +33,12 @@ export const Peers = () => {
|
||||
searchRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useKeyboardShortcut(SEARCH_SHORTCUT, () => {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
});
|
||||
|
||||
const isConnected = status?.status === "Connected";
|
||||
const peers = status?.peers ?? [];
|
||||
|
||||
const counts = useMemo<Record<StatusFilter, number>>(() => {
|
||||
@@ -54,6 +70,32 @@ export const Peers = () => {
|
||||
});
|
||||
}, [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 (
|
||||
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
|
||||
<div className={"flex flex-col gap-3 px-6"}>
|
||||
@@ -62,6 +104,7 @@ export const Peers = () => {
|
||||
placeholder={t("peers.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
shortcut={formatShortcut(SEARCH_SHORTCUT)}
|
||||
/>
|
||||
<PeerFilters
|
||||
value={statusFilter}
|
||||
@@ -74,7 +117,11 @@ export const Peers = () => {
|
||||
className={"flex-1 min-h-0 overflow-hidden mt-3"}
|
||||
>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeersList data={filtered} />
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<PeersList data={filtered} />
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
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 { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { latencyColor } from "./format";
|
||||
import { usePeerDetail } from "./PeerDetailContext";
|
||||
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
@@ -15,34 +17,69 @@ const dotClass = (connStatus: string): string => {
|
||||
};
|
||||
|
||||
export const PeersList = ({ data }: { data: PeerStatus[] }) => {
|
||||
const { t } = useTranslation();
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={"py-12 text-center text-sm text-nb-gray-400"}>{t("peers.empty")}</div>
|
||||
);
|
||||
}
|
||||
const { setSelected } = usePeerDetail();
|
||||
|
||||
return (
|
||||
<ul className={"flex flex-col"}>
|
||||
{data.map((peer) => (
|
||||
<li key={peer.pubKey} className={"flex items-center gap-3 px-7 py-3 min-w-0"}>
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full shrink-0", dotClass(peer.connStatus))}
|
||||
title={peer.connStatus}
|
||||
/>
|
||||
<CopyToClipboard message={peer.fqdn} className={"min-w-0 flex-1"}>
|
||||
<span className={"text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{peer.fqdn}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
<CopyToClipboard
|
||||
message={peer.ip}
|
||||
className={cn("ml-auto shrink-0", "relative left-2.5")}
|
||||
{data.map((peer) => {
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
return (
|
||||
<li
|
||||
key={peer.pubKey}
|
||||
onClick={() => setSelected(peer)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2.5 px-7 py-3 min-w-0",
|
||||
"hover:bg-nb-gray-900/40 transition-colors",
|
||||
"wails-no-draggable cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className={"text-xs font-mono text-nb-gray-400"}>{peer.ip}</span>
|
||||
</CopyToClipboard>
|
||||
</li>
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0 mt-2",
|
||||
dotClass(peer.connStatus),
|
||||
)}
|
||||
title={peer.connStatus}
|
||||
/>
|
||||
<div className={"min-w-0 flex-1 flex flex-col leading-tight"}>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.fqdn}>
|
||||
<span
|
||||
className={
|
||||
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
|
||||
}
|
||||
>
|
||||
{peer.fqdn}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
<div>
|
||||
<CopyToClipboard message={peer.ip}>
|
||||
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
|
||||
{peer.ip}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</div>
|
||||
{isConnected && peer.latencyMs > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 self-center text-xs tabular-nums",
|
||||
latencyColor(peer.latencyMs),
|
||||
)}
|
||||
>
|
||||
{peer.latencyMs} ms
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={16}
|
||||
className={cn(
|
||||
"shrink-0 self-center text-nb-gray-300",
|
||||
"opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
35
client/ui/frontend/src/modules/peers/format.ts
Normal file
35
client/ui/frontend/src/modules/peers/format.ts
Normal file
@@ -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`;
|
||||
};
|
||||
Reference in New Issue
Block a user