From 3f989f69cbdf81977f95ee270c9e451b372fe696 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Mon, 1 Jun 2026 17:14:30 +0200 Subject: [PATCH] update about gui version, add mock data, truncate some strings --- .../frontend/src/contexts/SettingsContext.tsx | 20 +- client/ui/frontend/src/lib/mock.ts | 415 ++++++++++++++++++ .../main/MainConnectionStatusSwitch.tsx | 45 +- .../src/modules/main/advanced/Navigation.tsx | 2 +- .../main/advanced/exit-nodes/ExitNodes.tsx | 4 +- .../main/advanced/networks/Networks.tsx | 8 +- .../main/advanced/peers/PeerDetailPanel.tsx | 253 ++++++++++- .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 118 +++-- .../src/modules/settings/SettingsAbout.tsx | 15 +- client/ui/i18n/locales/de/common.json | 9 +- client/ui/i18n/locales/en/common.json | 9 +- client/ui/i18n/locales/hu/common.json | 9 +- client/ui/main.go | 1 + 14 files changed, 844 insertions(+), 66 deletions(-) create mode 100644 client/ui/frontend/src/lib/mock.ts diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 86eed4cd0..90d1d25eb 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import { Dialogs } from "@wailsio/runtime"; -import { Autostart, Settings as SettingsSvc } from "@bindings/services"; +import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; import { useProfile } from "@/contexts/ProfileContext.tsx"; @@ -21,6 +21,7 @@ export type AutostartState = { supported: boolean; enabled: boolean }; type SettingsContextValue = { config: Config; + guiVersion: string; setField: (k: K, v: Config[K]) => void; saveField: (k: K, v: Config[K]) => Promise; saveFields: (partial: Partial) => Promise; @@ -56,6 +57,7 @@ export const useAutostartSetting = () => { const useSettingsState = () => { const { username, activeProfile, loaded: profileLoaded } = useProfile(); const [config, setConfig] = useState(null); + const [guiVersion, setGuiVersion] = useState("—"); const saveTimer = useRef | null>(null); useEffect(() => { @@ -76,6 +78,16 @@ const useSettingsState = () => { })(); }, [profileLoaded, activeProfile, username]); + useEffect(() => { + let cancelled = false; + Version.GUI().then((v) => { + if (!cancelled) setGuiVersion(v); + }); + return () => { + cancelled = true; + }; + }, []); + useEffect( () => () => { if (saveTimer.current) clearTimeout(saveTimer.current); @@ -159,11 +171,12 @@ const useSettingsState = () => { [config, save], ); - return { config, setField, saveField, saveFields, saveNow }; + return { config, guiVersion, setField, saveField, saveFields, saveNow }; }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, setField, saveField, saveFields, saveNow } = useSettingsState(); + const { config, guiVersion, setField, saveField, saveFields, saveNow } = + useSettingsState(); return (
@@ -173,6 +186,7 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { (real: T, mock: T): T => (MOCK_ENABLED ? mock : real); + +const SECONDS = (s: number) => Math.floor(Date.now() / 1000) - s; +const MINUTES = (m: number) => SECONDS(m * 60); +const HOURS = (h: number) => MINUTES(h * 60); +const DAYS = (d: number) => HOURS(d * 24); + +export const mockPeers: PeerStatus[] = [ + // Kitchen-sink peer — every optional field populated at the same time so + // the detail panel renders every Row (latency + bytes + handshake + ICE + // local/remote + relay + networks + rosenpass + pubkey). Useful for + // eyeballing layout at maximum density. + new PeerStatus({ + ip: "100.64.0.1", + pubKey: "MockKeyEverythingMaxedOutForLayoutTestingAA=", + connStatus: "Connected", + connStatusUpdateUnix: SECONDS(4), + relayed: true, + localIceCandidateType: "prflx", + remoteIceCandidateType: "relay", + localIceCandidateEndpoint: "[2001:db8:abcd:0012:0000:0000:0000:0001]:51820", + remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", + fqdn: "everything-maxed-out-kitchen-sink-peer-with-long-name.subdomain.dev.example-company.netbird.cloud", + bytesRx: 87_654_321_098, + bytesTx: 43_210_987_654, + latencyMs: 213, + relayAddress: + "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment/with/many/parts", + lastHandshakeUnix: SECONDS(3), + rosenpassEnabled: true, + networks: [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.100.0.0/16", + "10.50.50.0/24", + "2001:db8::/32", + "203.0.113.0/24", + "198.51.100.0/24", + ], + }), + new PeerStatus({ + ip: "100.64.0.2", + pubKey: "MockKeyAlpha000000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: MINUTES(7), + relayed: false, + localIceCandidateType: "host", + remoteIceCandidateType: "srflx", + localIceCandidateEndpoint: "192.168.1.10:51820", + remoteIceCandidateEndpoint: "203.0.113.42:51820", + fqdn: "alpha.netbird.cloud", + bytesRx: 12_345_678, + bytesTx: 9_876_543, + latencyMs: 18, + relayAddress: "", + lastHandshakeUnix: SECONDS(12), + rosenpassEnabled: false, + networks: ["10.0.0.0/24"], + }), + new PeerStatus({ + ip: "100.64.0.3", + pubKey: "MockKeyLongFqdn000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: HOURS(2), + relayed: false, + localIceCandidateType: "srflx", + remoteIceCandidateType: "srflx", + localIceCandidateEndpoint: "198.51.100.7:41234", + remoteIceCandidateEndpoint: "203.0.113.99:51820", + fqdn: "very-long-hostname-with-many-segments-to-test-overflow-handling.subdomain.example-company.netbird.cloud", + bytesRx: 4_500_000_000, + bytesTx: 1_200_000_000, + latencyMs: 87, + relayAddress: "", + lastHandshakeUnix: SECONDS(45), + rosenpassEnabled: false, + networks: [], + }), + new PeerStatus({ + ip: "100.64.0.4", + pubKey: "MockKeyConnecting00000000000000000000000000=", + connStatus: "Connecting", + connStatusUpdateUnix: SECONDS(3), + relayed: false, + localIceCandidateType: "", + remoteIceCandidateType: "", + localIceCandidateEndpoint: "", + remoteIceCandidateEndpoint: "", + fqdn: "edge-server.netbird.cloud", + bytesRx: 0, + bytesTx: 0, + latencyMs: 0, + relayAddress: "", + lastHandshakeUnix: 0, + rosenpassEnabled: false, + networks: [], + }), + new PeerStatus({ + ip: "100.64.0.5", + pubKey: "MockKeyOfflineOld0000000000000000000000000=", + connStatus: "Idle", + connStatusUpdateUnix: DAYS(4), + relayed: false, + localIceCandidateType: "", + remoteIceCandidateType: "", + localIceCandidateEndpoint: "", + remoteIceCandidateEndpoint: "", + fqdn: "old-peer-offline.netbird.cloud", + bytesRx: 0, + bytesTx: 0, + latencyMs: 0, + relayAddress: "", + lastHandshakeUnix: DAYS(4), + rosenpassEnabled: false, + networks: [], + }), + new PeerStatus({ + ip: "100.64.0.6", + pubKey: "MockKeyRelayed00000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: MINUTES(30), + relayed: true, + localIceCandidateType: "relay", + remoteIceCandidateType: "relay", + localIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", + remoteIceCandidateEndpoint: "relay-eu-central-1.netbird.io:443", + fqdn: "relayed-host-behind-strict-nat.corp.example.com", + bytesRx: 250_000, + bytesTx: 180_000, + latencyMs: 142, + relayAddress: "rels://relay-eu-central-1.netbird.io:443/very/long/relay/path/segment", + lastHandshakeUnix: SECONDS(8), + rosenpassEnabled: false, + networks: ["10.10.0.0/16", "192.168.50.0/24"], + }), + new PeerStatus({ + ip: "100.64.0.7", + pubKey: "MockKeyIPv6000000000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: HOURS(1), + relayed: false, + localIceCandidateType: "host", + remoteIceCandidateType: "host", + localIceCandidateEndpoint: "[2001:db8:85a3:0000:0000:8a2e:0370:7334]:51820", + remoteIceCandidateEndpoint: "[fe80::1ff:fe23:4567:890a]:51820", + fqdn: "ipv6-only-host.netbird.cloud", + bytesRx: 999_999, + bytesTx: 1_500_000, + latencyMs: 64, + relayAddress: "", + lastHandshakeUnix: SECONDS(22), + rosenpassEnabled: false, + networks: [], + }), + new PeerStatus({ + ip: "100.64.0.8", + pubKey: "MockKeyRosenpass0000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: MINUTES(15), + relayed: false, + localIceCandidateType: "prflx", + remoteIceCandidateType: "prflx", + localIceCandidateEndpoint: "10.0.0.50:51820", + remoteIceCandidateEndpoint: "203.0.113.200:51820", + fqdn: "rosenpass-secure.netbird.cloud", + bytesRx: 50_000, + bytesTx: 50_000, + latencyMs: 24, + relayAddress: "", + lastHandshakeUnix: SECONDS(5), + rosenpassEnabled: true, + networks: [], + }), + new PeerStatus({ + ip: "100.64.0.9", + pubKey: "MockKeyMultiNet00000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: HOURS(5), + relayed: false, + localIceCandidateType: "host", + remoteIceCandidateType: "srflx", + localIceCandidateEndpoint: "10.0.0.51:51820", + remoteIceCandidateEndpoint: "203.0.113.201:51820", + fqdn: "multi-network-router.netbird.cloud", + bytesRx: 12_000_000_000, + bytesTx: 8_000_000_000, + latencyMs: 31, + relayAddress: "", + lastHandshakeUnix: SECONDS(2), + rosenpassEnabled: false, + networks: [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.100.0.0/16", + "10.50.50.0/24", + "2001:db8::/32", + ], + }), + new PeerStatus({ + ip: "100.64.0.10", + pubKey: "MockKeyA000000000000000000000000000000000000=", + connStatus: "Idle", + connStatusUpdateUnix: MINUTES(45), + relayed: false, + localIceCandidateType: "", + remoteIceCandidateType: "", + localIceCandidateEndpoint: "", + remoteIceCandidateEndpoint: "", + fqdn: "a.nb", + bytesRx: 0, + bytesTx: 0, + latencyMs: 0, + relayAddress: "", + lastHandshakeUnix: MINUTES(45), + rosenpassEnabled: false, + networks: [], + }), + // IPv6-NetBird-IP peer: the daemon assigns a v6 ULA inside fd00::/8 when + // running on a v6-native overlay. Exercises the row layout when the IP + // column is wide. + new PeerStatus({ + ip: "fd00:1234:5678:abcd::42", + pubKey: "MockKeyV6Native0000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: MINUTES(20), + relayed: false, + localIceCandidateType: "host", + remoteIceCandidateType: "host", + localIceCandidateEndpoint: "[2001:db8:cafe:0001::10]:51820", + remoteIceCandidateEndpoint: "[2001:db8:cafe:0002::20]:51820", + fqdn: "ipv6-overlay-peer.netbird.cloud", + bytesRx: 800_000_000, + bytesTx: 950_000_000, + latencyMs: 41, + relayAddress: "", + lastHandshakeUnix: SECONDS(7), + rosenpassEnabled: false, + networks: ["2001:db8:1::/48", "2001:db8:2::/48", "fc00:dead:beef::/48"], + }), + // Dual-stack peer with mixed IPv4 / IPv6 ICE endpoints to test rows + // where local and remote columns differ in width. + new PeerStatus({ + ip: "100.64.0.11", + pubKey: "MockKeyDualStack0000000000000000000000000000=", + connStatus: "Connected", + connStatusUpdateUnix: MINUTES(10), + relayed: false, + localIceCandidateType: "host", + remoteIceCandidateType: "srflx", + localIceCandidateEndpoint: "10.0.0.99:51820", + remoteIceCandidateEndpoint: "[2606:4700:4700:0000:0000:0000:0000:1111]:51820", + fqdn: "dual-stack.netbird.cloud", + bytesRx: 320_000_000, + bytesTx: 410_000_000, + latencyMs: 28, + relayAddress: "", + lastHandshakeUnix: SECONDS(14), + rosenpassEnabled: false, + networks: ["10.20.30.0/24", "2001:db8:beef::/48"], + }), +]; + +// Resources / routed networks. Mixes the three resource types the UI knows +// about (host = /32 or /128, subnet, domain) plus a deliberately overlapping +// pair to exercise the "overlapping" badge in NetworkFilters. +export const mockNetworkRoutes: Network[] = [ + new Network({ + id: "host-jenkins", + range: "10.0.0.1/32", + selected: true, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "subnet-corp-lan", + range: "192.168.1.0/24", + selected: true, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "subnet-wide-internal", + range: "10.0.0.0/8", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "subnet-overlap-a", + range: "172.16.0.0/16", + selected: true, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "subnet-overlap-b", + range: "172.16.0.0/16", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "dns-example", + range: "invalid Prefix", + selected: true, + domains: ["example.com"], + resolvedIps: { "example.com": ["93.184.216.34"] }, + }), + new Network({ + id: "dns-very-long-internal-domain-with-many-segments", + range: "invalid Prefix", + selected: false, + domains: [ + "very-long-internal-service-name.dev.subdomain.example-company.internal", + "another-long-domain-for-overflow-testing.example-company.internal", + "third-long-domain-in-the-list.example-company.internal", + ], + resolvedIps: { + "very-long-internal-service-name.dev.subdomain.example-company.internal": [ + "10.20.30.40", + "10.20.30.41", + ], + }, + }), + new Network({ + id: "ipv6-host", + range: "2001:db8::1/128", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "ipv6-subnet-corp", + range: "2001:db8:abcd::/48", + selected: true, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "ipv6-subnet-large", + range: "fc00:dead:beef::/32", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "dns-dual-stack", + range: "invalid Prefix", + selected: true, + domains: ["dual-stack.internal.example.com"], + resolvedIps: { + "dual-stack.internal.example.com": [ + "10.20.30.40", + "10.20.30.41", + "2001:db8:abcd::40", + "2001:db8:abcd::41", + ], + }, + }), + new Network({ + id: "dns-ipv6-only", + range: "invalid Prefix", + selected: false, + domains: ["ipv6-only-service.example.com"], + resolvedIps: { + "ipv6-only-service.example.com": ["2606:4700:4700::1111", "2606:4700:4700::1001"], + }, + }), +]; + +// Exit nodes are radio-style (mutually exclusive in the UI). Include one +// selected and one absurdly-long-id to test row truncation. +export const mockExitNodes: Network[] = [ + new Network({ + id: "us-east-1", + range: "0.0.0.0/0", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "eu-central-frankfurt-primary", + range: "0.0.0.0/0", + selected: true, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "very-long-exit-node-region-identifier-with-multiple-segments-and-numbers-12345-test", + range: "0.0.0.0/0", + selected: false, + domains: [], + resolvedIps: {}, + }), + new Network({ + id: "ap-southeast-2", + range: "0.0.0.0/0", + selected: false, + domains: [], + resolvedIps: {}, + }), +]; diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 18f36f45f..9959ba7a8 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -32,6 +32,10 @@ const EVENT_TRIGGER_LOGIN = "trigger-login"; const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]); +// Re-enable the switch after this long in a transitioning state so the user +// can force a Connection.Down on a stuck Connecting/Disconnecting flow. +const FORCE_TOGGLE_DELAY_MS = 7000; + const errorMessage = formatErrorMessage; // startLogin drives the daemon's SSO login end-to-end. The BrowserLogin @@ -280,7 +284,12 @@ export const MainConnectionStatusSwitch = () => { }, [driveLogin]); const handleSwitch = (next: boolean) => { - if (unreachable || action !== null) return; + if (unreachable) return; + if (isTransitioning) { + if (canForceCancel) void forceCancel(); + return; + } + if (action !== null) return; if (needsLogin) { driveLogin(); return; @@ -296,6 +305,38 @@ export const MainConnectionStatusSwitch = () => { connState === ConnectionState.Connecting || connState === ConnectionState.Disconnecting; const isOn = connState === ConnectionState.Connected || connState === ConnectionState.Connecting; + + // When the daemon hangs in Connecting/Disconnecting, give the user an + // escape hatch: after the delay, the switch becomes clickable again so a + // tap fires Connection.Down (plus cancels any in-flight SSO flow). + const [canForceCancel, setCanForceCancel] = useState(false); + useEffect(() => { + if (!isTransitioning) { + setCanForceCancel(false); + return; + } + const id = setTimeout(() => setCanForceCancel(true), FORCE_TOGGLE_DELAY_MS); + return () => clearTimeout(id); + }, [isTransitioning]); + + const forceCancel = async () => { + if (action === "logging-in") { + void Events.Emit(EVENT_BROWSER_LOGIN_CANCEL); + } + WindowManager.CloseBrowserLogin().catch(() => {}); + setAction("disconnect"); + try { + await Connection.Down(); + await refresh(); + } catch (e) { + setAction(null); + await refresh(); + await Dialogs.Error({ + Title: t("connect.error.disconnectTitle"), + Message: errorMessage(e), + }); + } + }; const showLocal = connState === ConnectionState.Connected; const fqdn = status?.local.fqdn || ""; const ip = status?.local.ip || ""; @@ -313,7 +354,7 @@ export const MainConnectionStatusSwitch = () => { size={"large"} checked={isOn} onCheckedChange={handleSwitch} - disabled={isTransitioning || unreachable} + disabled={(isTransitioning && !canForceCancel) || unreachable} className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")} /> diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index 6455b576b..bf9f52edd 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -52,7 +52,7 @@ export const Navigation = () => { "gap-2.5 px-5 py-3.5", "outline-none transition-all", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", - isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer", + isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default", )} > diff --git a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx index 1c520ebf9..bc17b5160 100644 --- a/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx +++ b/client/ui/frontend/src/modules/main/advanced/exit-nodes/ExitNodes.tsx @@ -10,6 +10,7 @@ import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; +import { mockExitNodes, mockOr } from "@/lib/mock"; const NONE_VALUE = "__none__"; @@ -17,7 +18,8 @@ export const ExitNodes = () => { const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; - const { exitNodes, toggleExitNode } = useNetworks(); + const { exitNodes: realExitNodes, toggleExitNode } = useNetworks(); + const exitNodes = mockOr(realExitNodes, mockExitNodes); const [search, setSearch] = useState(""); const searchRef = useRef(null); diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index 4d3b0bba6..2a1aa850f 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -11,6 +11,7 @@ import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; +import { mockNetworkRoutes, mockOr } from "@/lib/mock"; import { NetworkFilter, NetworkFilters } from "./NetworkFilters"; // The daemon stringifies route.Network via netip.Prefix.String(). For @@ -75,7 +76,12 @@ export const Networks = () => { const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; - const { networkRoutes, toggleNetwork, setNetworksSelected } = useNetworks(); + const { + networkRoutes: realNetworkRoutes, + toggleNetwork, + setNetworksSelected, + } = useNetworks(); + const networkRoutes = mockOr(realNetworkRoutes, mockNetworkRoutes); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("all"); const searchRef = useRef(null); diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 1927e5a53..4977618b3 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -1,6 +1,15 @@ -import { ComponentType, ReactNode, useEffect } from "react"; +import { + ComponentType, + ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { AnimatePresence, motion, type Transition } from "framer-motion"; +import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { ArrowDownIcon, @@ -18,14 +27,18 @@ import { MonitorIcon, NetworkIcon, Radio, + RefreshCwIcon, ZapIcon, } from "lucide-react"; import type { PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { CopyToClipboard } from "@/components/CopyToClipboard"; +import { Tooltip } from "@/components/Tooltip"; import { formatBytes, formatRelative, latencyColor } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { mockOr, mockPeers } from "@/lib/mock"; +import { peerStatusLabelKey } from "./Peers"; const DEFAULT_TRANSITION: Transition = { duration: 0.32, @@ -52,14 +65,14 @@ type Props = { export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { t } = useTranslation(); const { selected, setSelected } = usePeerDetail(); - const { status } = useStatus(); + const { status, refresh } = 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 peers = mockOr(status?.peers ?? [], mockPeers); const fresh = peers.find((p) => p.pubKey === selected.pubKey); if (!fresh) { setSelected(null); @@ -68,6 +81,34 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { if (fresh !== selected) setSelected(fresh); }, [status, selected, setSelected]); + // Re-render every second so the relative timestamps in PeerDetails + // ("Xs ago", "Xm ago") tick. The daemon updates latency/bytes/handshake + // silently without pushing a fresh status snapshot — see + // status.go UpdateLatency / UpdateWireGuardPeerState — so without this + // the displayed age would freeze for a stably-Connected peer. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!selected) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [selected]); + + const [refreshing, setRefreshing] = useState(false); + const onRefresh = useCallback(async () => { + if (refreshing) return; + setRefreshing(true); + // Refresh over the unix socket usually completes in <50ms, faster + // than the spin animation can show. Hold the spinning state for at + // least one full rotation so the click feels responsive. + const MIN_SPIN_MS = 600; + const minDelay = new Promise((r) => setTimeout(r, MIN_SPIN_MS)); + try { + await Promise.all([refresh(), minDelay]); + } finally { + setRefreshing(false); + } + }, [refresh, refreshing]); + // Esc closes the panel. useEffect(() => { if (!selected) return; @@ -107,20 +148,48 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { > - - - {selected.fqdn || selected.ip} - + + + + + + {selected.fqdn || selected.ip} + + + + +
- + { ); }; -const PeerDetails = ({ peer }: { peer: PeerStatus }) => { +const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { const { t } = useTranslation(); - const lastHandshake = formatRelative(peer.lastHandshakeUnix) ?? t("peers.details.never"); - const statusSince = formatRelative(peer.connStatusUpdateUnix) ?? DASH; + const formatAge = (unix: number, fallback: string): string => { + if (!Number.isFinite(unix) || unix <= 0) return fallback; + const diff = Math.floor(now / 1000 - unix); + if (diff < 1) return t("peers.details.justNow"); + return formatRelative(unix, now) ?? fallback; + }; + const lastHandshake = formatAge(peer.lastHandshakeUnix, t("peers.details.never")); + const statusSince = formatAge(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"); @@ -225,10 +300,10 @@ const PeerDetails = ({ peer }: { peer: PeerStatus }) => { - {peer.relayAddress} + ) : ( DASH @@ -237,7 +312,7 @@ const PeerDetails = ({ peer }: { peer: PeerStatus }) => { )} {peer.networks.length > 0 && ( - {peer.networks.join(", ")} + )} @@ -245,10 +320,10 @@ const PeerDetails = ({ peer }: { peer: PeerStatus }) => { - {peer.pubKey} + ) : ( DASH @@ -283,10 +358,10 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { - {endpoint} + ) : ( {capitalize(type)} @@ -295,6 +370,136 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { ); }; +// "{N} Resources" trigger that opens a hover popover listing each routed +// network on its own line with a click-to-copy entry. Mirrors the resolved-IPs +// popover in the Resources tab (Networks.tsx ResolvedIpsPopover). +// Row value: first network inline, plus a "+N more" hover pill opening a +// popover with the full list. Mirrors the resolved-IPs pattern in +// Networks.tsx so the Resources tab and the peer detail row look consistent. +const ResourcesValue = ({ networks }: { networks: string[] }) => { + const first = networks[0]; + const extra = networks.length - 1; + return ( +
+ + + + {extra > 0 && } +
+ ); +}; + +const ResourcesMorePopover = ({ + networks, + extra, +}: { + networks: string[]; + extra: number; +}) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const closeTimer = useRef | null>(null); + + const cancelClose = () => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + // Close with a small delay so the mouse can cross the sideOffset gap + // between trigger and content without the popover snapping shut. + const scheduleClose = () => { + cancelClose(); + closeTimer.current = setTimeout(() => setOpen(false), 300); + }; + useEffect(() => () => cancelClose(), []); + + return ( + + + + + + e.preventDefault()} + 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", + )} + > +
    + {networks.map((n) => ( +
  • + + + {n} + + +
  • + ))} +
+
+
+
+ ); +}; + +// Truncates the value to one line with the row's available width; on hover, +// shows the full string in a tooltip — but only when actually clipped. Same +// pattern as TruncatedName in Peers.tsx / TruncatedEmail in ProfileDropdown. +const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [value]); + + const span = ( + + {value} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; + const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
  • diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx index 949f2880f..d0e97c03f 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -42,7 +42,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { "text-sm text-nb-gray-100", "outline-none hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900 transition-colors duration-150", "disabled:opacity-50 disabled:pointer-events-none", - "wails-no-draggable", + "wails-no-draggable cursor-default", )} > diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 33c9651dc..1fe21be51 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { ChevronRightIcon, LaptopIcon } from "lucide-react"; @@ -11,6 +11,8 @@ import { NoResults } from "@/components/empty-state/NoResults"; import { latencyColor } from "@/lib/formatters"; import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; +import { Tooltip } from "@/components/Tooltip"; +import { mockOr, mockPeers } from "@/lib/mock"; import { PeerFilters, StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; @@ -26,6 +28,19 @@ const dotClass = (connStatus: string): string => { } }; +// The daemon reports "Idle" for not-connected peers; surface it as +// "Disconnected" in the UI. Connected / Connecting pass through. +export const peerStatusLabelKey = (connStatus: string): string => { + switch (connStatus) { + case "Connected": + return "peers.status.connected"; + case "Connecting": + return "peers.status.connecting"; + default: + return "peers.status.disconnected"; + } +}; + export const Peers = () => { const { t } = useTranslation(); const { status } = useStatus(); @@ -41,7 +56,7 @@ export const Peers = () => { }, []); const isConnected = status?.status === "Connected"; - const peers = status?.peers ?? []; + const peers = mockOr(status?.peers ?? [], mockPeers); const counts = useMemo>(() => { const online = peers.filter((p) => isOnline(p.connStatus)).length; @@ -52,9 +67,35 @@ export const Peers = () => { }; }, [peers]); + // Initial order: online-first, then alphabetically by fqdn / ip. After + // that, positions are sticky — a peer flipping Connected→Connecting→Idle + // no longer jumps groups. Newly discovered peers append at the end + // (sorted online-first / by-name among themselves). Mirrors the + // networks-list and exit-nodes-list orderRef pattern. + const orderRef = useRef([]); + const ordered = useMemo(() => { + const byKey = new Map(peers.map((p) => [p.pubKey, p])); + const kept = orderRef.current.filter((k) => byKey.has(k)); + const known = new Set(kept); + const fresh = peers + .filter((p) => !known.has(p.pubKey)) + .sort((a, b) => { + const aOnline = isOnline(a.connStatus); + const bOnline = isOnline(b.connStatus); + if (aOnline !== bOnline) return aOnline ? -1 : 1; + const aName = (a.fqdn || a.ip).toLowerCase(); + const bName = (b.fqdn || b.ip).toLowerCase(); + return aName.localeCompare(bName); + }) + .map((p) => p.pubKey); + const next = [...kept, ...fresh]; + orderRef.current = next; + return next.map((k) => byKey.get(k)!); + }, [peers]); + const filtered = useMemo(() => { const q = search.trim().toLowerCase(); - const matches = peers.filter((p) => { + return ordered.filter((p) => { if (statusFilter === "online" && !isOnline(p.connStatus)) return false; if (statusFilter === "offline" && isOnline(p.connStatus)) return false; if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) { @@ -62,15 +103,7 @@ export const Peers = () => { } return true; }); - return matches.sort((a, b) => { - const aOnline = isOnline(a.connStatus); - const bOnline = isOnline(b.connStatus); - if (aOnline !== bOnline) return aOnline ? -1 : 1; - const aName = (a.fqdn || a.ip).toLowerCase(); - const bName = (b.fqdn || b.ip).toLowerCase(); - return aName.localeCompare(bName); - }); - }, [peers, search, statusFilter]); + }, [ordered, search, statusFilter]); if (isConnected && peers.length === 0) { return ( @@ -126,6 +159,7 @@ export const Peers = () => { }; const PeersList = ({ data }: { data: PeerStatus[] }) => { + const { t } = useTranslation(); const { setSelected } = usePeerDetail(); return ( @@ -139,26 +173,24 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { className={cn( "group flex items-start gap-2.5 px-7 py-3 min-w-0 first:mt-2", "hover:bg-nb-gray-900/40 transition-colors", - "wails-no-draggable cursor-pointer", + "wails-no-draggable cursor-default", )} > - + + +
    - - {peer.fqdn} - +
    @@ -192,3 +224,35 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => { ); }; + +// Same pattern as TruncatedEmail in ProfileDropdown: render the span with a +// fixed max-width + truncate, measure scrollWidth vs clientWidth after layout, +// and wrap in a Tooltip only when the text actually overflows. Avoids the +// "tooltip on hover even though everything fits" annoyance. +const TruncatedName = ({ name }: { name: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [name]); + + const span = ( + + {name} + + ); + if (!overflowing) return span; + return ( + + {span} + + ); +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 9f8624b74..1f6477077 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -3,7 +3,7 @@ import { Browser } from "@wailsio/runtime"; import { BookOpen, Github, MessageSquareText, MessagesSquare, Slack } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import netbirdFull from "@/assets/logos/netbird-full.svg"; -import pkg from "../../../package.json"; +import { useSettings } from "@/contexts/SettingsContext.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; @@ -15,7 +15,7 @@ function openUrl(url: string) { export function SettingsAbout() { const { t } = useTranslation(); const { status } = useStatus(); - const guiVersion = pkg.version; + const { guiVersion } = useSettings(); const daemonVersion = status?.daemonVersion ?? "—"; const handleVersionClick = useAccentTrigger(); @@ -79,7 +79,16 @@ export function SettingsAbout() { )}

    - {t("settings.about.gui", { version: guiVersion })} + {guiVersion === "development" ? ( + + {t("settings.about.guiName")}{" "} + + {t("settings.about.development")} + + + ) : ( + t("settings.about.gui", { version: guiVersion }) + )}

    diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index edac5c6a6..09dbaf42a 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -249,6 +249,7 @@ "settings.about.clientName": "NetBird Client", "settings.about.development": "[Entwicklung]", "settings.about.gui": "Oberfläche v{version}", + "settings.about.guiName": "Oberfläche", "settings.about.copyright": "© {year} NetBird. Alle Rechte vorbehalten.", "settings.about.links.imprint": "Impressum", "settings.about.links.privacy": "Datenschutz", @@ -345,8 +346,14 @@ "peers.details.localIce": "Lokales ICE", "peers.details.remoteIce": "Remote ICE", "peers.details.never": "Nie", + "peers.details.justNow": "Gerade eben", + "peers.details.refresh": "Aktualisieren", + "peers.status.connected": "Verbunden", + "peers.status.connecting": "Verbinde", + "peers.status.disconnected": "Getrennt", "peers.details.relayAddress": "Relay", - "peers.details.networks": "Netzwerke", + "peers.details.networks": "Ressourcen", + "peers.details.more": "+{count} weitere", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass aktiviert", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index bae9f250e..bf1850575 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -267,6 +267,7 @@ "settings.about.clientName": "NetBird Client", "settings.about.development": "[Development]", "settings.about.gui": "GUI v{version}", + "settings.about.guiName": "GUI", "settings.about.copyright": "© {year} NetBird. All Rights Reserved.", "settings.about.links.imprint": "Imprint", "settings.about.links.privacy": "Privacy", @@ -361,8 +362,14 @@ "peers.details.localIce": "Local ICE", "peers.details.remoteIce": "Remote ICE", "peers.details.never": "Never", + "peers.details.justNow": "Just now", + "peers.details.refresh": "Refresh", + "peers.status.connected": "Connected", + "peers.status.connecting": "Connecting", + "peers.status.disconnected": "Disconnected", "peers.details.relayAddress": "Relay", - "peers.details.networks": "Networks", + "peers.details.networks": "Resources", + "peers.details.more": "+{count} more", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass enabled", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index eba5faa7b..b82d5dcc9 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -249,6 +249,7 @@ "settings.about.clientName": "NetBird Kliens", "settings.about.development": "[Fejlesztés]", "settings.about.gui": "Felület v{version}", + "settings.about.guiName": "Felület", "settings.about.copyright": "© {year} NetBird. Minden jog fenntartva.", "settings.about.links.imprint": "Impresszum", "settings.about.links.privacy": "Adatvédelem", @@ -345,8 +346,14 @@ "peers.details.localIce": "Helyi ICE", "peers.details.remoteIce": "Távoli ICE", "peers.details.never": "Soha", + "peers.details.justNow": "Épp most", + "peers.details.refresh": "Frissítés", + "peers.status.connected": "Csatlakozva", + "peers.status.connecting": "Csatlakozás", + "peers.status.disconnected": "Lecsatlakozva", "peers.details.relayAddress": "Relay", - "peers.details.networks": "Hálózatok", + "peers.details.networks": "Erőforrások", + "peers.details.more": "+{count} további", "peers.details.relayed": "Relayed", "peers.details.p2p": "P2P", "peers.details.rosenpass": "Rosenpass engedélyezve", diff --git a/client/ui/main.go b/client/ui/main.go index dfb98e0da..11afa35e5 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -299,6 +299,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewI18n(s.bundle))) app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) + app.RegisterService(application.NewService(services.NewVersion())) } // newMainWindow creates the hidden main window, sized to the user's last view