update about gui version, add mock data, truncate some strings

This commit is contained in:
Eduard Gert
2026-06-01 17:14:30 +02:00
parent a4ad93008b
commit 3f989f69cb
14 changed files with 844 additions and 66 deletions

View File

@@ -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 extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>) => Promise<void>;
@@ -56,6 +57,7 @@ export const useAutostartSetting = () => {
const useSettingsState = () => {
const { username, activeProfile, loaded: profileLoaded } = useProfile();
const [config, setConfig] = useState<Config | null>(null);
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<div className={"flex-1 min-h-0 overflow-y-auto"}>
@@ -173,6 +186,7 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
<SettingsContext.Provider
value={{
config,
guiVersion,
setField,
saveField,
saveFields,

View File

@@ -0,0 +1,415 @@
import { Network, PeerStatus } from "@bindings/services/models.js";
// Flip to true to override the live daemon data in the Peers / Resources /
// Exit Nodes tabs with the hand-crafted fixtures below. The fixtures are
// designed to surface overflow / truncation bugs (very long FQDNs, ICE
// endpoints, domain lists, network ids) and exercise the three connection
// states + relayed / P2P / Rosenpass variants.
export const MOCK_ENABLED = false;
// Replace `real` with `mock` when MOCK_ENABLED. Pulled out so call sites read
// as "use the live X, or the mock if enabled" without per-site if/else noise.
export const mockOr = <T>(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: {},
}),
];

View File

@@ -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")}
/>

View File

@@ -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",
)}
>
<Icon size={14} />

View File

@@ -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<HTMLInputElement>(null);

View File

@@ -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<NetworkFilter>("all");
const searchRef = useRef<HTMLInputElement>(null);

View File

@@ -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<void>((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) => {
>
<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>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus),
)}
/>
</Tooltip>
<CopyToClipboard
message={selected.fqdn || selected.ip}
size={11}
className={"flex-1 min-w-0"}
iconClassName={"top-[2px]"}
>
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
{selected.fqdn || selected.ip}
</span>
</CopyToClipboard>
<Tooltip content={t("peers.details.refresh")}>
<button
type={"button"}
onClick={onRefresh}
disabled={refreshing}
aria-label={t("peers.details.refresh")}
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",
"disabled:opacity-50 disabled:hover:bg-transparent",
)}
>
<RefreshCwIcon
size={14}
className={refreshing ? "animate-spin" : undefined}
/>
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} />
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
@@ -142,10 +211,16 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
);
};
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 }) => {
<CopyToClipboard
message={peer.relayAddress}
alwaysShowIcon
className={"max-w-full"}
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<span className={"font-mono truncate"}>{peer.relayAddress}</span>
<TruncatedRowValue value={peer.relayAddress} mono />
</CopyToClipboard>
) : (
DASH
@@ -237,7 +312,7 @@ const PeerDetails = ({ peer }: { peer: PeerStatus }) => {
)}
{peer.networks.length > 0 && (
<Row icon={Layers3Icon} label={t("peers.details.networks")}>
<span className={"break-words"}>{peer.networks.join(", ")}</span>
<ResourcesValue networks={peer.networks} />
</Row>
)}
<Row icon={KeyRoundIcon} label={t("peers.details.publicKey")}>
@@ -245,10 +320,10 @@ const PeerDetails = ({ peer }: { peer: PeerStatus }) => {
<CopyToClipboard
message={peer.pubKey}
alwaysShowIcon
className={"max-w-full"}
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<span className={"font-mono truncate"}>{peer.pubKey}</span>
<TruncatedRowValue value={peer.pubKey} mono />
</CopyToClipboard>
) : (
DASH
@@ -283,10 +358,10 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => {
<CopyToClipboard
message={endpoint}
alwaysShowIcon
className={"max-w-full"}
className={"max-w-full min-w-0"}
iconClassName={"top-0"}
>
<span className={"font-mono truncate"}>{endpoint}</span>
<TruncatedRowValue value={endpoint} mono />
</CopyToClipboard>
) : (
<span className={"truncate"}>{capitalize(type)}</span>
@@ -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 (
<div className={"flex items-center gap-1.5 min-w-0 justify-end"}>
<CopyToClipboard message={first}>
<TruncatedRowValue value={first} mono />
</CopyToClipboard>
{extra > 0 && <ResourcesMorePopover networks={networks} extra={extra} />}
</div>
);
};
const ResourcesMorePopover = ({
networks,
extra,
}: {
networks: string[];
extra: number;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
onMouseEnter={() => {
cancelClose();
setOpen(true);
}}
onMouseLeave={scheduleClose}
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-default outline-none",
)}
>
{t("peers.details.more", { count: extra })}
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={"bottom"}
align={"end"}
sideOffset={6}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onOpenAutoFocus={(e) => 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",
)}
>
<ul className={"flex flex-col"}>
{networks.map((n) => (
<li key={n}>
<CopyToClipboard message={n} className={"px-1 py-0.5"}>
<span
className={
"font-mono text-[0.72rem] text-nb-gray-200 break-all"
}
>
{n}
</span>
</CopyToClipboard>
</li>
))}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
// 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<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [value]);
const span = (
<span
ref={ref}
className={cn(
"inline-block truncate align-middle min-w-0 max-w-[260px]",
mono && "font-mono",
)}
>
{value}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={value} delayDuration={600}>
{span}
</Tooltip>
);
};
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)} />

View File

@@ -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",
)}
>
<ListFilter size={14} className={"shrink-0"} />

View File

@@ -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<Record<StatusFilter, number>>(() => {
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<string[]>([]);
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",
)}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
dotClass(peer.connStatus),
)}
title={peer.connStatus}
/>
<Tooltip
content={t(peerStatusLabelKey(peer.connStatus))}
side={"left"}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<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>
<TruncatedName name={peer.fqdn} />
</CopyToClipboard>
</div>
<div>
@@ -192,3 +224,35 @@ const PeersList = ({ data }: { data: PeerStatus[] }) => {
</ul>
);
};
// 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<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [name]);
const span = (
<span
ref={ref}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
>
{name}
</span>
);
if (!overflowing) return span;
return (
<Tooltip content={name} delayDuration={600}>
{span}
</Tooltip>
);
};

View File

@@ -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() {
)}
</p>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{t("settings.about.gui", { version: guiVersion })}
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={" text-yellow-400 font-mono"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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