diff --git a/nym-vpn-app/CHANGELOG.md b/nym-vpn-app/CHANGELOG.md index b38658e427..bce2a5ca38 100644 --- a/nym-vpn-app/CHANGELOG.md +++ b/nym-vpn-app/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show the picked gateway name the tunnel is connected to, when connecting/connected and the selected node is a country - Add QUIC mode and Domain-fronting (aka stealth API) settings options +- Add a new screen for gateway details ### Fixed diff --git a/nym-vpn-app/src-tauri/src/grpc/gateway.rs b/nym-vpn-app/src-tauri/src/grpc/gateway.rs index 502b58e8ae..f20be42d3f 100644 --- a/nym-vpn-app/src-tauri/src/grpc/gateway.rs +++ b/nym-vpn-app/src-tauri/src/grpc/gateway.rs @@ -1,4 +1,5 @@ use crate::country::Country; + use anyhow::{Result, anyhow}; use nym_vpn_proto::proto as p; use serde::{Deserialize, Serialize}; @@ -26,6 +27,46 @@ pub enum Score { High, } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, TS, Default)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum AsnType { + #[default] + Other, + Residential, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct Asn { + pub asn: String, + pub name: String, + #[serde(rename = "type")] + pub kind: AsnType, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct Location { + pub latitude: f64, + pub longitude: f64, + pub city: String, + pub region: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct Performance { + pub score: Score, + pub load: Score, + pub last_updated_utc: String, + /// uptime percentage on the last 24 hours + pub uptime_24h: f32, +} + #[derive(Serialize, Deserialize, Clone, Debug, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] @@ -35,8 +76,14 @@ pub struct Gateway { pub kind: GatewayType, pub name: String, pub country: Country, + pub location: Location, + pub asn: Option, pub mx_score: Score, pub wg_score: Score, + pub wg_performance: Option, + pub exit_ipv4: Option, + pub exit_ipv6: Option, + pub build_version: Option, } impl Gateway { @@ -62,6 +109,7 @@ impl Gateway { let wg_score = gateway .wg_performance + .as_ref() .map(|s| { p::Score::try_from(s.score) .inspect_err(|e| error!("failed to parse proto gw wireguard score: {}", e)) @@ -69,13 +117,23 @@ impl Gateway { .transpose()? .unwrap_or(p::Score::Offline); + let asn = location.asn.clone().map(|a| a.into()); + let exit_ipv4 = gateway.exit_ipv4s.first().cloned(); + let exit_ipv6 = gateway.exit_ipv6s.first().cloned(); + Ok(Self { id: id.id, kind: gw_type, name: gateway.moniker, country: Country::try_from(&location)?, + location: location.into(), + asn, mx_score: Score::from(mx_score), wg_score: Score::from(wg_score), + wg_performance: gateway.wg_performance.map(|p| p.into()), + exit_ipv4, + exit_ipv6, + build_version: gateway.build_version, }) } } @@ -111,6 +169,42 @@ impl From for p::GatewayType { } } +impl From for Location { + fn from(proto: p::RichLocation) -> Self { + Location { + latitude: proto.latitude, + longitude: proto.longitude, + city: proto.city, + region: proto.region, + } + } +} + +impl From for Asn { + fn from(proto: p::Asn) -> Self { + let asn_kind = &proto.kind(); + Asn { + asn: proto.asn, + name: proto.name, + kind: match asn_kind { + p::AsnKind::Residential => AsnType::Residential, + p::AsnKind::Other => AsnType::Other, + }, + } + } +} + +impl From for Performance { + fn from(proto: p::Performance) -> Self { + Performance { + score: Score::from(proto.score()), + load: Score::from(proto.load()), + last_updated_utc: proto.last_updated_utc, + uptime_24h: proto.uptime_percentage_last_24_hours, + } + } +} + impl fmt::Display for Gateway { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "[{}] ({}) {}", self.id, self.name, self.country) diff --git a/nym-vpn-app/src-tauri/src/grpc/node.rs b/nym-vpn-app/src-tauri/src/grpc/node.rs index e83975c782..1fc8ea6648 100644 --- a/nym-vpn-app/src-tauri/src/grpc/node.rs +++ b/nym-vpn-app/src-tauri/src/grpc/node.rs @@ -13,6 +13,7 @@ use crate::country::Country; #[serde(rename_all = "lowercase")] #[serde(untagged)] #[ts(export)] +#[allow(clippy::large_enum_variant)] pub enum NodeConnect { Country(Country), Gateway(Gateway), diff --git a/nym-vpn-app/src/constants.ts b/nym-vpn-app/src/constants.ts index 3c708dcc92..80f1016db5 100644 --- a/nym-vpn-app/src/constants.ts +++ b/nym-vpn-app/src/constants.ts @@ -41,3 +41,7 @@ export const SentryPrivacyPolicyUrl = 'https://sentry.io/privacy/'; export const AnonNetworkStatsUrl = 'https://nym.com/anonymous-stats'; export const QuicUrl = 'https://nym.com/features/quic'; export const DomainFrontingUrl = 'https://nym.com/features/stealth-api-connect'; +export const IpInfoIoUrl = 'https://ipinfo.io'; +export const SupportServerLocationUrl = + 'https://support.nym.com/hc/en-us/articles/26448676449297-How-is-server-location-determined-by-NymVPN'; +export const NetworkExplorerNodeUrl = 'https://nym.com/explorer/nym-node'; diff --git a/nym-vpn-app/src/dev/mocked/wg-gw.json b/nym-vpn-app/src/dev/mocked/wg-gw.json index 69cacd935b..5797f9886e 100644 --- a/nym-vpn-app/src/dev/mocked/wg-gw.json +++ b/nym-vpn-app/src/dev/mocked/wg-gw.json @@ -10,11 +10,31 @@ "code": "YY", "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaa" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "id": "VERYLONNNNNGGGGG4h283ohKqPRq7dakfXgVgWYidXoSatvy4fXXJdZpfhA24h283ohKqPRq7dakfXgVgWYidXoSatvy4fXXJdZpfhA24h283ohKqPRq7dakfXgVgWYidXoSatvy4fXXJdZpfhA2", "mxScore": "low", "name": "__anon__", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null } ] }, @@ -29,33 +49,93 @@ "code": "FR", "name": "France" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "id": "3UBiq22tkNSRhyRNjL5mnw5Yk4z6FvgvjizT4ukeEaeB", "mxScore": "high", "name": "la porte en bois", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null }, { "country": { "code": "FR", "name": "France" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "id": "4ksetqeuV9uDXzgVqqwHUnaSLaS69zVSpKGAp4DFNMgC", "mxScore": "high", "name": "Le comté est le roi des fromages", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null }, { "country": { "code": "FR", "name": "France" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "id": "5XhDUMyN9AZfMJD7kLNVFAxrUePV5yZY1MEsc7g7Acrh", "mxScore": "high", "name": "Bonjour", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null } ], "type": "wg" @@ -71,22 +151,62 @@ "code": "RU", "name": "Russian Federation" }, - "id": "4namLaLcqCZ6irhn9yoUpLjk5hZeRcjqoLFSS89iGmQp", + "location": { + "city": "Elektrozavodsk", + "region": "Northwestern Economic Region", + "latitude": 10.0, + "longitude": 10.0 + }, + "id": "72SCrUZ3u81QrryUVL65pr8jWfQC3LpC3CyQgqLgJnrQ", "mxScore": "high", - "name": "Пётр", + "name": "Mikhaïl Boulgakov 😼", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "medium", + "score": "high", + "uptime24h": 1.0 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.1.256.256", + "exitIpv6": "ff:00:11:ee" }, { "country": { "code": "RU", "name": "Russian Federation" }, - "id": "72SCrUZ3u81QrryUVL65pr8jWfQC3LpC3CyQgqLgJnrQ", + "location": { + "city": "Chernogorsk", + "region": "Northwestern Economic Region", + "latitude": 10.0, + "longitude": 10.0 + }, + "id": "4namLaLcqCZ6irhn9yoUpLjk5hZeRcjqoLFSS89iGmQp", "mxScore": "high", - "name": "Mikhaïl Boulgakov 😼", + "name": "Пётр", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.255", + "exitIpv6": null } ], "type": "wg" @@ -96,6 +216,12 @@ "code": "US", "name": "United States of America" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "gateways": [ { "country": { @@ -106,18 +232,52 @@ "mxScore": "high", "name": "High speed gateway", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null }, { "country": { "code": "US", "name": "United States of America" }, + "location": { + "city": "Sydney", + "latitude": -33.8678, + "longitude": 151.2073, + "region": "New South Wales" + }, "id": "2eRyEtCzjvDNZNY1JmTQVDqfEgjjXsUYQEuiDtLUZvvp", "mxScore": "high", "name": "BLAZINGLY FAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAST", "type": "wg", - "wgScore": "high" + "wgScore": "high", + "wgPerformance": { + "lastUpdatedUtc": "2025-09-19T11:19:27Z", + "load": "low", + "score": "high", + "uptime24h": 1 + }, + "asn": { + "asn": "AS63949", + "name": "Akamai Connected Cloud", + "type": "other" + }, + "buildVersion": "1.18.0", + "exitIpv4": "11.01.256.256", + "exitIpv6": null } ], "type": "wg" diff --git a/nym-vpn-app/src/dev/setup.ts b/nym-vpn-app/src/dev/setup.ts index 3984178674..a498f61408 100644 --- a/nym-vpn-app/src/dev/setup.ts +++ b/nym-vpn-app/src/dev/setup.ts @@ -6,7 +6,6 @@ import { Cli, Country, DbKey, - Gateway, GatewayType, GatewaysByCountry, NetworkCompat, @@ -39,8 +38,8 @@ const daemon: VpndStatus = { network: 'mainnet', }, }; -// const tunnelState: TunnelStateIpc = 'disconnected'; -const tunnelState: TunnelStateIpc = { connected: wgTunnel }; +const tunnelState: TunnelStateIpc = 'disconnected'; +// const tunnelState: TunnelStateIpc = { connected: wgTunnel }; // const tunnelState: TunnelStateIpc = { connecting: null }; // const tunnelState: TunnelStateIpc = { disconnecting: null }; // const tunnelState: TunnelStateIpc = { offline: { reconnect: false } }; @@ -58,17 +57,7 @@ const savedEntry: Country = { code: 'FR', name: 'France', }; -const savedExit: Gateway = { - country: { - code: 'RU', - name: 'Russian Federation', - }, - id: '72SCrUZ3u81QrryUVL65pr8jWfQC3LpC3CyQgqLgJnrQ', - mxScore: 'high', - name: 'Mikhaïl Boulgakov 😼', - type: 'wg', - wgScore: 'high', -}; +const savedExit = (wgGwJson as GatewaysByCountry[])[2].gateways[0]; export function mockTauriIPC() { mockWindows('main'); diff --git a/nym-vpn-app/src/hooks/index.ts b/nym-vpn-app/src/hooks/index.ts index 69c9f27ca4..a41f6e3797 100644 --- a/nym-vpn-app/src/hooks/index.ts +++ b/nym-vpn-app/src/hooks/index.ts @@ -8,3 +8,4 @@ export { default as useDesktopNotifications } from './useDesktopNotifications'; export { default as useLang } from './useLang'; export { default as useClipboard } from './useClipboard'; export { default as useClickAway } from './useClickAway'; +export { default as useScore } from './useScore'; diff --git a/nym-vpn-app/src/hooks/useScore.ts b/nym-vpn-app/src/hooks/useScore.ts new file mode 100644 index 0000000000..e97d93ddcc --- /dev/null +++ b/nym-vpn-app/src/hooks/useScore.ts @@ -0,0 +1,43 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Score } from '../types'; + +function useScore() { + const { t } = useTranslation('nodeLocation'); + + const getProps = useCallback( + (score: Score) => { + switch (score) { + case 'offline': + return { + icon: 'signal_cellular_alt_1_bar', + color: 'text-iron', + label: t('node-details.score.offline'), + }; + case 'low': + return { + icon: 'signal_cellular_alt_1_bar', + color: 'text-aphrodisiac', + label: t('node-details.score.low'), + }; + case 'medium': + return { + icon: 'signal_cellular_alt_2_bar', + color: 'text-king-nacho', + label: t('node-details.score.medium'), + }; + case 'high': + return { + icon: 'signal_cellular_alt', + color: 'text-malachite', + label: t('node-details.score.high'), + }; + } + }, + [t], + ); + + return { style: getProps }; +} + +export default useScore; diff --git a/nym-vpn-app/src/i18n/en/common.json b/nym-vpn-app/src/i18n/en/common.json index 5a35a26b48..d974771afa 100644 --- a/nym-vpn-app/src/i18n/en/common.json +++ b/nym-vpn-app/src/i18n/en/common.json @@ -24,5 +24,6 @@ "light": "Light mode" }, "appearance": "Appearance", - "language": "Language" + "language": "Language", + "server-details": "Server details" } diff --git a/nym-vpn-app/src/i18n/en/node-location.json b/nym-vpn-app/src/i18n/en/node-location.json index 1e9f1d5404..fa2012b0d1 100644 --- a/nym-vpn-app/src/i18n/en/node-location.json +++ b/nym-vpn-app/src/i18n/en/node-location.json @@ -15,6 +15,42 @@ "exit": "Selected exit location not available, switched to {{location}}" }, "node-details": { - "id-label": "Identity key:" + "id-label": "Identity key:", + "data": { + "advanced-privacy": "Advanced privacy", + "with-mixnet": "With mixnet (5-hop)", + "ip-type": "Streaming & content", + "ip-residential": "Residential IP", + "ip-datacenter": "Datacenter IP", + "anti-censorship": "Anti-censorship", + "quic-protocol": "QUIC protocol", + "standard-protocol": "Standard protocol", + "overall-performance": "Overall performance", + "server-load": "Server load", + "speed": "Speed", + "uptime": "Uptime", + "exit-ipv4": "Exit IPv4", + "exit-ipv6": "Exit IPv6", + "asn": "ASN", + "asn-name": "ASN name", + "build-version": "Nym build version", + "identity-key": "Identity key:" + }, + "score": { + "high": "Good", + "medium": "Mid", + "low": "Low", + "offline": "Offline" + }, + "notes": { + "anti-censorship": "Enable “QUIC protocol” in Anti-censorship Settings to use this feature", + "performance_with_date": "Performance score calculated from server load and uptime. Uptime as last 24-hours average. Last update {{date}}.", + "performance": "Performance score calculated from server load and uptime. Uptime as last 24-hours average." + }, + "links": { + "missing-info": "Why is there missing or incorrect info", + "explorer": "More details in the Nym " + }, + "select-button": "Select server" } } diff --git a/nym-vpn-app/src/router.tsx b/nym-vpn-app/src/router.tsx index 4982e324d2..8bfcc5dd9e 100644 --- a/nym-vpn-app/src/router.tsx +++ b/nym-vpn-app/src/router.tsx @@ -17,6 +17,7 @@ import { Login, Logs, MainLayout, + NodeDetails, NodeEntry, SelectPlan, Settings, @@ -48,6 +49,7 @@ export const routes = { dev: '/settings/dev', entryNodeLocation: '/entry-node-location', exitNodeLocation: '/exit-node-location', + nodeDetails: '/node-details', hideout: '/hideout', welcome: '/hideout/welcome', } as const; @@ -57,94 +59,94 @@ export const routes = { const router = createBrowserRouter([ { path: routes.root, - element: , + Component: MainLayout, children: [ { - element: , + Component: Home, errorElement: , index: true, }, { path: routes.login, - element: , + Component: Login, errorElement: , }, { path: routes.account, - element: , + Component: AccountRouteIndex, errorElement: , children: [ { path: routes.selectPlan, - element: , + Component: SelectPlan, errorElement: , }, ], }, { path: routes.settings, - element: , + Component: SettingsRouteIndex, errorElement: , children: [ { - element: , + Component: Settings, errorElement: , index: true, }, { path: routes.dev, - element: , + Component: Dev, errorElement: , }, { path: routes.appearance, - element: , + Component: AppearanceRouteIndex, errorElement: , children: [ { - element: , + Component: Appearance, errorElement: , index: true, }, { path: routes.lang, - element: , + Component: Lang, errorElement: , }, { path: routes.display, - element: , + Component: Display, errorElement: , }, ], }, { path: routes.dataPrivacy, - element: , + Component: DataAndPrivacy, errorElement: , }, { path: routes.logs, - element: , + Component: Logs, errorElement: , }, { path: routes.antiCensorship, - element: , + Component: AntiCensorship, errorElement: , }, { path: routes.support, - element: , + Component: Support, errorElement: , }, { path: routes.legal, - element: , + Component: LegalRouteIndex, errorElement: , children: [ { - element: , + Component: Legal, errorElement: , index: true, }, @@ -160,7 +162,7 @@ const router = createBrowserRouter([ }, { path: routes.licenseDetails, - element: , + Component: LicenseDetails, errorElement: , }, ], @@ -177,6 +179,11 @@ const router = createBrowserRouter([ element: , errorElement: , }, + { + path: routes.nodeDetails, + Component: NodeDetails, + errorElement: , + }, ], }, { @@ -185,7 +192,7 @@ const router = createBrowserRouter([ children: [ { path: routes.welcome, - element: , + Component: Welcome, errorElement: , }, ], diff --git a/nym-vpn-app/src/screens/node/Node.tsx b/nym-vpn-app/src/screens/node/Node.tsx index c8765af5d1..b55375869b 100644 --- a/nym-vpn-app/src/screens/node/Node.tsx +++ b/nym-vpn-app/src/screens/node/Node.tsx @@ -19,7 +19,6 @@ import { useI18nError } from '../../hooks'; import { routes } from '../../router'; import LocationDetailsDialog from './LocationDetailsDialog'; import { NodeList } from './list'; -import NodeDetailsDialog from './NodeDetailsDialog'; function Node({ node }: { node: NodeHop }) { const { vpnMode } = useMainState(); @@ -28,7 +27,6 @@ function Node({ node }: { node: NodeHop }) { const { isOpen, close } = useDialog(); const { nodes, loading, gateways, error } = useNodesState(); const { tE } = useI18nError(); - const [nodeDetailsOpen, setNodeDetailsOpen] = useState(false); const nodeDetailsRef = useRef(null); const [uiNodes, setUiNodes] = useState(nodes); @@ -82,9 +80,9 @@ function Node({ node }: { node: NodeHop }) { navigate(routes.root); }; - const handleNodeDetails = (node: UiGateway | UiCountry) => { - nodeDetailsRef.current = node; - setNodeDetailsOpen(true); + const handleNodeDetails = (selected: UiGateway | UiCountry) => { + nodeDetailsRef.current = selected; + navigate(routes.nodeDetails, { state: { gateway: selected, hop: node } }); }; if (error) { @@ -114,11 +112,6 @@ function Node({ node }: { node: NodeHop }) { return ( <> - setNodeDetailsOpen(false)} - ref={nodeDetailsRef} - /> close('location-info')} diff --git a/nym-vpn-app/src/screens/node/details/DataCard.tsx b/nym-vpn-app/src/screens/node/details/DataCard.tsx new file mode 100644 index 0000000000..73fc43a656 --- /dev/null +++ b/nym-vpn-app/src/screens/node/details/DataCard.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import clsx from 'clsx'; + +export type DataCardProps = { + rows: ( + | { row: React.ReactNode; key: string } + | undefined + | false + | null + | '' + )[]; + footer?: React.ReactNode; +}; + +function DataCard({ rows, footer }: DataCardProps) { + const filtered = rows.filter( + (row) => typeof row === 'object' && row !== null, + ); + + return ( +
+
    + {filtered.map(({ row, key }) => ( +
  • + {row} +
  • + ))} + {footer && ( +
    + {footer} +
    + )} +
+
+ ); +} + +export default DataCard; diff --git a/nym-vpn-app/src/screens/node/details/NodeDetails.tsx b/nym-vpn-app/src/screens/node/details/NodeDetails.tsx new file mode 100644 index 0000000000..7a40258c9e --- /dev/null +++ b/nym-vpn-app/src/screens/node/details/NodeDetails.tsx @@ -0,0 +1,296 @@ +import React from 'react'; +import clsx from 'clsx'; +import * as H from 'history'; +import { Trans, useTranslation } from 'react-i18next'; +import { useLocation, useNavigate } from 'react-router'; +import { UiGateway, useMainDispatch } from '../../../contexts'; +import { + Button, + ButtonIcon, + FlagIcon, + Link, + MsIcon, + PageAnim, + countryCode, +} from '../../../ui'; +import { useClipboard, useLang, useScore } from '../../../hooks'; +import { Score, StateDispatch } from '../../../types'; +import { + IpInfoIoUrl, + NetworkExplorerNodeUrl, + SupportServerLocationUrl, +} from '../../../constants'; +import { kvSet } from '../../../kvStore'; +import { uiNodeToRaw } from '../../../contexts/nodes/util'; +import { routes } from '../../../router'; +import DataCard from './DataCard'; + +type RouteState = { + gateway: UiGateway; + hop: 'entry' | 'exit'; +}; + +function NodeDetails() { + const dispatch = useMainDispatch() as StateDispatch; + const location = useLocation() as H.Location; + const { t } = useTranslation('nodeLocation'); + const navigate = useNavigate(); + + const { getCountryName } = useLang(); + const { copy } = useClipboard(); + const { style } = useScore(); + + const { gateway, hop } = location.state; + const { country, exitIpv4, exitIpv6, asn, buildVersion } = gateway; + const isGoodIp = asn?.type === 'residential'; + const serverLoad = gateway?.wgPerformance?.load; + const uptime = gateway?.wgPerformance?.uptime24h; + const lastUpdate = gateway.wgPerformance?.lastUpdatedUtc; + const asnValue = asn?.asn; + const asnName = asn?.name; + const showCard3 = exitIpv4 || exitIpv6 || asnValue || asnName; + const isSelected = + gateway.isSelected === 'exit' || gateway.isSelected === 'entry'; + + const DataRow = ({ + children, + label, + }: { + children: React.ReactNode; + label: string; + }) => ( +
+

{label}

+
+ {children} +
+
+ ); + + const featureRow = ( + label: string, + feature: string, + status: 'green' | 'orange' = 'green', + ) => ( + + {status === 'green' ? ( + + ) : ( + + )} +

{feature}

+
+ ); + + const scoreRow = (label: string, score: Score) => { + const { icon, color, label: iconLabel } = style(score); + + return ( + +
+ +

{iconLabel}

+
+
+ ); + }; + + const identityKey = ( +
+

+ {t('node-details.data.identity-key')} +

+
+

+ {gateway.id} +

+ copy(gateway.id, false)} + clickFeedback + noDefaultSize + /> +
+
+ ); + + const handleSelect = async () => { + if (isSelected) { + return; + } + await kvSet( + hop === 'entry' ? 'entry-node' : 'exit-node', + uiNodeToRaw(gateway), + ); + dispatch({ + type: 'set-node', + payload: { hop, node: gateway }, + }); + navigate(routes.root); + }; + + const card1 = [ + { + row: featureRow( + t('node-details.data.advanced-privacy'), + t('node-details.data.with-mixnet'), + ), + key: 'privacy', + }, + { + row: featureRow( + t('node-details.data.ip-type'), + isGoodIp + ? t('node-details.data.ip-residential') + : t('node-details.data.ip-datacenter'), + isGoodIp ? 'green' : 'orange', + ), + key: 'ip-type', + }, + ]; + const card2 = [ + { + row: scoreRow( + t('node-details.data.overall-performance'), + gateway.type === 'wg' ? gateway.wgScore : gateway.mxScore, + ), + key: 'overall-perf', + }, + serverLoad && { + row: scoreRow(t('node-details.data.server-load'), serverLoad), + key: 'load-score', + }, + uptime !== undefined && { + row: ( + +

{`${uptime * 100}%`}

+
+ ), + key: 'uptime', + }, + ]; + const card3 = [ + exitIpv4 && { + row: ( + + + + ), + key: 'exitIpv4', + }, + exitIpv6 && { + row: ( + + + + ), + key: 'exitIpv6', + }, + asnValue && { + row: ( + +
{asnValue}
+
+ ), + key: 'asn-value', + }, + asnName && { + row: ( + +
{asnName}
+
+ ), + key: 'asn-name', + }, + ]; + const card4 = [ + buildVersion && { + row: ( + +
{buildVersion}
+
+ ), + key: 'build-version', + }, + { row: identityKey, key: 'id-key' }, + ]; + + const card2Footer = lastUpdate + ? t('node-details.notes.performance_with_date', { + date: lastUpdate, + }) + : t('node-details.notes.performance'); + + return ( + +

{gateway.name}

+
+ +
+ {getCountryName(country.code) || country.name} +
+
+ + + {showCard3 && } + +
+ +

+ + ), + }} + /> +

+
+ {!isSelected && ( + + )} +
+ ); +} + +export default NodeDetails; diff --git a/nym-vpn-app/src/screens/node/details/index.ts b/nym-vpn-app/src/screens/node/details/index.ts new file mode 100644 index 0000000000..fac7885a80 --- /dev/null +++ b/nym-vpn-app/src/screens/node/details/index.ts @@ -0,0 +1 @@ +export { default as NodeDetails } from './NodeDetails'; diff --git a/nym-vpn-app/src/screens/node/index.ts b/nym-vpn-app/src/screens/node/index.ts index b4a7158855..7aed91b330 100644 --- a/nym-vpn-app/src/screens/node/index.ts +++ b/nym-vpn-app/src/screens/node/index.ts @@ -1 +1,2 @@ export { default as NodeEntry } from './NodeEntry'; +export * from './details'; diff --git a/nym-vpn-app/src/types/tauri.ts b/nym-vpn-app/src/types/tauri.ts index 0941042a1f..b60d818078 100644 --- a/nym-vpn-app/src/types/tauri.ts +++ b/nym-vpn-app/src/types/tauri.ts @@ -97,13 +97,35 @@ export type GatewayType = 'mx-entry' | 'mx-exit' | 'wg'; export type Score = 'offline' | 'low' | 'medium' | 'high'; +export type AsnType = 'other' | 'residential'; +export type Asn = { asn: string; name: string; type: AsnType }; +export type Performance = { + score: Score; + load: Score; + lastUpdatedUtc: string; + // uptime percentage on the last 24 hours + uptime24h: number; +}; +export type Location = { + latitude: number; + longitude: number; + city: string; + region: string; +}; + export type Gateway = { id: string; type: GatewayType; name: string; country: Country; + location: Location; + asn: Asn | null; mxScore: Score; wgScore: Score; + wgPerformance: Performance | null; + exitIpv4: string | null; + exitIpv6: string | null; + buildVersion: string | null; }; export type GatewaysByCountry = { diff --git a/nym-vpn-app/src/ui/ButtonIcon.tsx b/nym-vpn-app/src/ui/ButtonIcon.tsx index b44490053b..d6093cfd32 100644 --- a/nym-vpn-app/src/ui/ButtonIcon.tsx +++ b/nym-vpn-app/src/ui/ButtonIcon.tsx @@ -7,6 +7,7 @@ import { MsIcon } from './index'; export type ButtonIconProps = { icon: string; + color?: 'malachite' | 'chalk'; clickedIcon?: string; onClick: () => void; clickFeedback?: boolean; @@ -15,12 +16,14 @@ export type ButtonIconProps = { iconClassName?: string; clickedIconClassName?: string; clickDuration?: number; + noDefaultSize?: boolean; 'data-testid'?: string; }; function ButtonIcon({ onClick, icon, + color = 'malachite', clickedIcon = 'check', clickFeedback = false, disabled, @@ -28,6 +31,7 @@ function ButtonIcon({ iconClassName, clickedIconClassName, clickDuration = 500, + noDefaultSize, ...rest }: ButtonIconProps) { const [isClicked, click] = useTransition(); @@ -42,13 +46,20 @@ function ButtonIcon({ return ( { if (clickFeedback) { @@ -74,7 +85,8 @@ function ButtonIcon({ openUrl(url)} data-testid={testId} @@ -54,7 +59,10 @@ function Link({ {icon && ( diff --git a/nym-vpn-app/src/ui/TopBar.tsx b/nym-vpn-app/src/ui/TopBar.tsx index 2346abdd11..51c03e4a99 100644 --- a/nym-vpn-app/src/ui/TopBar.tsx +++ b/nym-vpn-app/src/ui/TopBar.tsx @@ -210,6 +210,13 @@ export default function TopBar() { show('location-info'); }, }, + '/node-details': { + title: t('server-details'), + leftIcon: 'arrow_back', + handleLeftNav: () => { + navigate(-1); + }, + }, '/account/select-a-plan': { leftIcon: 'arrow_back', handleLeftNav: () => {