diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index 8376b82..3863e7c 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -26,23 +26,28 @@ import TextWithTooltip from "@components/ui/TextWithTooltip"; import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; import dayjs from "dayjs"; -import { trim } from "lodash"; +import { isEmpty, trim } from "lodash"; import { Cpu, + FlagIcon, Globe, History, MapPin, MonitorSmartphoneIcon, + NetworkIcon, PencilIcon, TerminalSquare, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { toASCII } from "punycode"; import React, { useMemo, useState } from "react"; +import Skeleton from "react-loading-skeleton"; import { useSWRConfig } from "swr"; +import RoundedFlag from "@/assets/countries/RoundedFlag"; import CircleIcon from "@/assets/icons/CircleIcon"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; +import { useCountries } from "@/contexts/CountryProvider"; import PeerProvider, { usePeer } from "@/contexts/PeerProvider"; import RoutesProvider from "@/contexts/RoutesProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; @@ -139,7 +144,7 @@ function PeerOverview() { @@ -291,6 +296,12 @@ function PeerOverview() { } function PeerInformationCard({ peer }: { peer: Peer }) { + const { isLoading, getRegionByPeer } = useCountries(); + + const countryText = useMemo(() => { + return getRegionByPeer(peer); + }, [getRegionByPeer, peer]); + return ( @@ -304,6 +315,44 @@ function PeerInformationCard({ peer }: { peer: Peer }) { value={peer.ip} /> + + + Public IP-Address + + } + value={peer.connection_ip} + /> + + + + Region + + } + tooltip={false} + value={ + isEmpty(peer.country_code) ? ( + "Unknown" + ) : ( + <> + {isLoading ? ( + + ) : ( +
+
+ +
+ {countryText} +
+ )} + + ) + } + /> + @@ -347,6 +396,7 @@ function PeerInformationCard({ peer }: { peer: Peer }) { ")" } /> + diff --git a/src/components/Card.tsx b/src/components/Card.tsx index 4df4214..6de46e6 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -30,6 +30,7 @@ type CardListItemProps = { value: React.ReactNode; className?: string; copy?: boolean; + tooltip?: boolean; }; function CardListItem({ @@ -37,6 +38,7 @@ function CardListItem({ value, className, copy = false, + tooltip = true, }: CardListItemProps) { const [, copyToClipBoard] = useCopyToClipboard(value as string); @@ -57,7 +59,11 @@ function CardListItem({ copy && copyToClipBoard(`${label} has been copied to clipboard.`) } > - + {tooltip ? ( + + ) : ( + value + )} {copy && } diff --git a/src/components/CopyToClipboardText.tsx b/src/components/CopyToClipboardText.tsx index fa65783..9a4564d 100644 --- a/src/components/CopyToClipboardText.tsx +++ b/src/components/CopyToClipboardText.tsx @@ -28,12 +28,16 @@ export default function CopyToClipboardText({ children, message }: Props) { {copied ? ( ) : ( )} diff --git a/src/components/FullTooltip.tsx b/src/components/FullTooltip.tsx index 3868a57..e21463a 100644 --- a/src/components/FullTooltip.tsx +++ b/src/components/FullTooltip.tsx @@ -50,6 +50,7 @@ export default function FullTooltip({ className={cn( isAction ? "cursor-pointer" : "cursor-default", "inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md", + className, )} > {children} diff --git a/src/components/ui/CountrySelector.tsx b/src/components/ui/CountrySelector.tsx index 4c2e915..30b25de 100644 --- a/src/components/ui/CountrySelector.tsx +++ b/src/components/ui/CountrySelector.tsx @@ -2,19 +2,16 @@ import { SelectDropdown, SelectOption, } from "@components/select/SelectDropdown"; -import useFetchApi from "@utils/api"; import { createElement, useMemo } from "react"; import RoundedFlag from "@/assets/countries/RoundedFlag"; -import { Country } from "@/interfaces/Country"; +import { useCountries } from "@/contexts/CountryProvider"; type Props = { value: string; onChange: (value: string) => void; }; export const CountrySelector = ({ value, onChange }: Props) => { - const { data: countries, isLoading } = useFetchApi( - "/locations/countries", - ); + const { countries, isLoading } = useCountries(); const countryList = useMemo(() => { return countries?.map((country) => { diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx index 2ad4904..951fcf7 100644 --- a/src/components/ui/GroupBadge.tsx +++ b/src/components/ui/GroupBadge.tsx @@ -27,7 +27,7 @@ export default function GroupBadge({ className={cn("transition-all group whitespace-nowrap", className)} onClick={onClick} > - + {children} {showX && ( diff --git a/src/components/ui/LoginExpiredBadge.tsx b/src/components/ui/LoginExpiredBadge.tsx index 919486c..d5a6a39 100644 --- a/src/components/ui/LoginExpiredBadge.tsx +++ b/src/components/ui/LoginExpiredBadge.tsx @@ -10,7 +10,7 @@ export default function LoginExpiredBadge({ loginExpired }: Props) { - + Login required diff --git a/src/components/ui/TextWithTooltip.tsx b/src/components/ui/TextWithTooltip.tsx index eb861f9..fcfb18c 100644 --- a/src/components/ui/TextWithTooltip.tsx +++ b/src/components/ui/TextWithTooltip.tsx @@ -24,11 +24,12 @@ export default function TextWithTooltip({ {text} } > - + {charCount > maxChars ? text && `${text.slice(0, maxChars)}...` : text} diff --git a/src/contexts/CountryProvider.tsx b/src/contexts/CountryProvider.tsx new file mode 100644 index 0000000..f4304c7 --- /dev/null +++ b/src/contexts/CountryProvider.tsx @@ -0,0 +1,47 @@ +import useFetchApi from "@utils/api"; +import React, { useCallback } from "react"; +import { Country } from "@/interfaces/Country"; +import { Peer } from "@/interfaces/Peer"; + +type Props = { + children: React.ReactNode; +}; + +const CountryContext = React.createContext( + {} as { + countries: Country[] | undefined; + isLoading: boolean; + getRegionByPeer: (peer: Peer) => string; + }, +); + +export default function CountryProvider({ children }: Props) { + const { data: countries, isLoading } = useFetchApi( + "/locations/countries", + false, + false, + ); + + const getRegionByPeer = useCallback( + (peer: Peer) => { + if (!countries) return "Unknown"; + const country = countries.find( + (c) => c.country_code === peer.country_code, + ); + if (!country) return "Unknown"; + if (!peer.city_name) return country.country_name; + return `${country.country_name}, ${peer.city_name}`; + }, + [countries], + ); + + return ( + + {children} + + ); +} + +export const useCountries = () => { + return React.useContext(CountryContext); +}; diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts index f836931..9e09bc8 100644 --- a/src/interfaces/Peer.ts +++ b/src/interfaces/Peer.ts @@ -20,4 +20,7 @@ export interface Peer { login_expired: boolean; login_expiration_enabled: boolean; approval_required: boolean; + city_name: string; + country_code: string; + connection_ip: string; } diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index c0765bb..237f6b7 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -12,6 +12,7 @@ import React from "react"; import ApplicationProvider, { useApplicationContext, } from "@/contexts/ApplicationProvider"; +import CountryProvider from "@/contexts/CountryProvider"; import GroupsProvider from "@/contexts/GroupsProvider"; import UsersProvider from "@/contexts/UsersProvider"; import Navigation from "@/layouts/Navigation"; @@ -26,7 +27,9 @@ export default function DashboardLayout({ - {children} + + {children} + diff --git a/src/modules/peers/PeerAddressCell.tsx b/src/modules/peers/PeerAddressCell.tsx index 5c84eb0..145046b 100644 --- a/src/modules/peers/PeerAddressCell.tsx +++ b/src/modules/peers/PeerAddressCell.tsx @@ -1,43 +1,61 @@ import CopyToClipboardText from "@components/CopyToClipboardText"; -import TextWithTooltip from "@components/ui/TextWithTooltip"; +import FullTooltip from "@components/FullTooltip"; import { cn } from "@utils/helpers"; +import { isEmpty } from "lodash"; import { GlobeIcon } from "lucide-react"; +import React from "react"; +import RoundedFlag from "@/assets/countries/RoundedFlag"; import { Peer } from "@/interfaces/Peer"; +import { PeerAddressTooltipContent } from "@/modules/peers/PeerAddressTooltipContent"; type Props = { peer: Peer; }; export default function PeerAddressCell({ peer }: Props) { return ( -
+ } + >
{ + e.stopPropagation(); + e.preventDefault(); + }} > - -
-
- - - - - - - - {peer.ip} - - + {isEmpty(peer.country_code) ? ( + + ) : ( + + )} +
+
+ + {peer.dns_label} + + + + {peer.ip} + + +
- + ); } diff --git a/src/modules/peers/PeerAddressTooltipContent.tsx b/src/modules/peers/PeerAddressTooltipContent.tsx new file mode 100644 index 0000000..912f767 --- /dev/null +++ b/src/modules/peers/PeerAddressTooltipContent.tsx @@ -0,0 +1,74 @@ +import { FlagIcon, GlobeIcon, MapPin, NetworkIcon } from "lucide-react"; +import * as React from "react"; +import { useMemo } from "react"; +import Skeleton from "react-loading-skeleton"; +import { useCountries } from "@/contexts/CountryProvider"; +import { Peer } from "@/interfaces/Peer"; + +type Props = { + peer: Peer; +}; +export const PeerAddressTooltipContent = ({ peer }: Props) => { + const { isLoading, getRegionByPeer } = useCountries(); + + const countryText = useMemo(() => { + return getRegionByPeer(peer); + }, [getRegionByPeer, peer]); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + } + label={"NetBird IP"} + value={peer.ip} + /> + } + label={"Public IP"} + value={peer.connection_ip} + /> + } + label={"Domain"} + value={peer.dns_label} + /> + } + label={"Region"} + value={ + isLoading && !countryText ? : countryText + } + /> +
+ ); +}; + +const ListItem = ({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string | React.ReactNode; +}) => { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +}; diff --git a/src/utils/api.tsx b/src/utils/api.tsx index 388047f..22b9b00 100644 --- a/src/utils/api.tsx +++ b/src/utils/api.tsx @@ -77,7 +77,11 @@ export function useNetBirdFetch(ignoreError: boolean = false) { }; } -export default function useFetchApi(url: string, ignoreError = false) { +export default function useFetchApi( + url: string, + ignoreError = false, + revalidate = true, +) { const { fetch } = useNetBirdFetch(ignoreError); const handleErrors = useApiErrorHandling(ignoreError); @@ -90,6 +94,9 @@ export default function useFetchApi(url: string, ignoreError = false) { }, { keepPreviousData: true, + revalidateOnFocus: revalidate, + revalidateIfStale: revalidate, + revalidateOnReconnect: revalidate, }, );