mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
update about gui version, add mock data, truncate some strings
This commit is contained in:
@@ -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,
|
||||
|
||||
415
client/ui/frontend/src/lib/mock.ts
Normal file
415
client/ui/frontend/src/lib/mock.ts
Normal 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: {},
|
||||
}),
|
||||
];
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user