mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 11:09:15 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
// Options for errorDialog. Kept as a {Title, Message} object so the many
|
||||
// existing call sites read unchanged after the switch from the native OS
|
||||
// MessageBox to the custom window below.
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
// errorDialog surfaces a user-actionable failure. It opens the custom,
|
||||
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
|
||||
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
|
||||
// more, despite the name.
|
||||
//
|
||||
// Why the native box is gone: on Windows a native MessageBox attached to a
|
||||
// parent window disables that window (WS_DISABLED) for its lifetime, and the
|
||||
// main window's WindowClosing hook hides instead of closing — the two raced
|
||||
// and could leave the main window unable to process its close (X) button after
|
||||
// an error was shown. The custom window has its own chrome and never touches
|
||||
// another window's enabled state, so that class of bug is gone (and with it
|
||||
// the old `Detached: true` Windows-only workaround, plus the warning/info/
|
||||
// question wrappers that nothing called).
|
||||
//
|
||||
// Title and message must already be localised. Resolves as soon as the window
|
||||
// is opened (it does not block until the user dismisses it), so `await`ing
|
||||
// callers continue immediately after the dialog appears.
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
@@ -1,40 +1,34 @@
|
||||
// Shared error formatter for native dialog bodies.
|
||||
//
|
||||
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
|
||||
// wraps daemon errors in a ClientError struct exposed to the TS side as
|
||||
// {code, short, long}. Short is already localised (Go reads the current
|
||||
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
|
||||
// Long always carries the unwrapped raw daemon message so the operator can
|
||||
// see the JWT / mgm stack when the short text is too generic.
|
||||
//
|
||||
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
|
||||
// .message holds the JSON-stringified payload and the structured object
|
||||
// lives on .cause — Object.keys(err) is empty in that case. We therefore
|
||||
// probe .cause first, then fall back to parsing .message as JSON, then
|
||||
// to plain .message text for callers that still hand us a raw Error.
|
||||
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClientError = { short?: string; long?: string };
|
||||
|
||||
const asClientError = (obj: object): ClientError => {
|
||||
const withCause = obj as { cause?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
const parseMessageJson = (message: unknown): ClientError | null => {
|
||||
if (typeof message !== "string") return null;
|
||||
const m = message.trim();
|
||||
if (!m.startsWith("{") || !m.endsWith("}")) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") return asClientError(parsed);
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractClientError = (e: unknown): ClientError | null => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const withCause = e as { cause?: unknown; message?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause as { short?: string; long?: string };
|
||||
return withCause.cause;
|
||||
}
|
||||
if (typeof withCause.message === "string") {
|
||||
const m = withCause.message.trim();
|
||||
if (m.startsWith("{") && m.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
|
||||
return parsed.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parsed as { short?: string; long?: string };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to plain-message handling
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return parseMessageJson(withCause.message);
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
@@ -50,3 +44,12 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
try {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
|
||||
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) +
|
||||
" " +
|
||||
sizes[i]
|
||||
);
|
||||
} catch {
|
||||
return "0 B";
|
||||
}
|
||||
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
@@ -22,10 +14,7 @@ export const latencyColor = (ms: number): string => {
|
||||
return "text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (
|
||||
unixSeconds: number,
|
||||
nowMs: number = Date.now(),
|
||||
): string | null => {
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) return null;
|
||||
const diff = Math.max(0, Math.floor(nowMs / 1000 - unixSeconds));
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
@@ -34,13 +23,22 @@ export const formatRelative = (
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
// shortenDns drops the domain suffix off a DNS name, returning just the
|
||||
// leading host label ("misha.netbird.selfhosted" → "misha"). The base domain
|
||||
// is operator-configurable so we keep everything before the first dot rather
|
||||
// than matching against a known suffix. The full DNS name still lands on
|
||||
// the clipboard via the copy helpers' explicit message prop.
|
||||
// Base domain is operator-configurable, so cut at the first dot rather than match a known suffix.
|
||||
export const shortenDns = (fqdn: string | undefined | null): string => {
|
||||
if (!fqdn) return "";
|
||||
const dot = fqdn.indexOf(".");
|
||||
return dot === -1 ? fqdn : fqdn.slice(0, dot);
|
||||
};
|
||||
|
||||
// Countdown clock: mm:ss, widening to hh:mm:ss / dd:hh:mm:ss as the duration grows.
|
||||
export const formatRemaining = (seconds: number): string => {
|
||||
const s = Math.max(0, Math.trunc(seconds));
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const secs = s % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (days > 0) return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
return `${pad(minutes)}:${pad(secs)}`;
|
||||
};
|
||||
|
||||
@@ -5,21 +5,7 @@ import { Events } from "@wailsio/runtime";
|
||||
import { Preferences, I18n } from "@bindings/services";
|
||||
import { LanguageCode } from "@bindings/i18n/models.js";
|
||||
|
||||
// Vite glob-imports every shipped bundle at build time. The locales tree
|
||||
// lives outside `frontend/` (at `client/ui/i18n/locales`) so the Go tray
|
||||
// and the React app share one JSON source. Adding a language only
|
||||
// requires dropping the new folder there and the row in `_index.json` —
|
||||
// no edit to this file. The `eager: true` import keeps the bundles
|
||||
// inlined in the main JS chunk, same shape as a static import. Path is
|
||||
// relative on purpose — alias-based globs (`@/…`) silently resolve to an
|
||||
// empty match in some Vite dev-mode setups. `server.fs.allow` in
|
||||
// `vite.config.ts` whitelists the parent directory so the dev server
|
||||
// serves the JSON.
|
||||
//
|
||||
// Each bundle is Chrome-extension JSON: every key maps to
|
||||
// `{ message, description? }`. `description` exists only so Crowdin can
|
||||
// show translator context — it's stripped here and i18next sees a flat
|
||||
// key->message map exactly as before.
|
||||
// Relative path on purpose — alias globs (`@/…`) silently match nothing in some Vite dev setups.
|
||||
type BundleEntry = { message: string; description?: string };
|
||||
const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
|
||||
"../../../i18n/locales/*/common.json",
|
||||
@@ -28,7 +14,7 @@ const bundleModules = import.meta.glob<Record<string, BundleEntry>>(
|
||||
|
||||
const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = path.match(/locales\/([^/]+)\/common\.json$/);
|
||||
const match = /locales\/([^/]+)\/common\.json$/.exec(path);
|
||||
if (match) {
|
||||
const entries = bundleModules[path];
|
||||
const messages: Record<string, string> = {};
|
||||
@@ -39,11 +25,6 @@ for (const path in bundleModules) {
|
||||
}
|
||||
}
|
||||
|
||||
// detectBrowserLanguage walks navigator.language + navigator.languages
|
||||
// and returns the first shipped bundle that matches. We try an exact
|
||||
// case-insensitive match first (so "en-GB" picks the en-GB bundle when
|
||||
// shipped), then fall back to the base code ("de" from "de-DE"). Returns
|
||||
// null when nothing matches, so the caller can fall back to English.
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
@@ -59,13 +40,7 @@ function detectBrowserLanguage(available: string[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// initI18n is awaited from app.tsx before the first render. The Go-side
|
||||
// preferences.Store returns an empty language code when no preference has
|
||||
// ever been persisted — that's the signal for first-run browser-locale
|
||||
// detection. We pick a shipped bundle that matches navigator.language /
|
||||
// navigator.languages (falling back to "en" when nothing matches) and
|
||||
// fire-and-forget the persist via Preferences.SetLanguage so subsequent
|
||||
// launches read the value back without re-detecting.
|
||||
// An empty persisted language code is the Go-side signal for first run.
|
||||
export async function initI18n(): Promise<void> {
|
||||
const available = Object.keys(resources);
|
||||
let language = "en";
|
||||
@@ -79,13 +54,10 @@ export async function initI18n(): Promise<void> {
|
||||
language = detectBrowserLanguage(available) ?? "en";
|
||||
}
|
||||
} catch {
|
||||
// Daemon / preferences store unreachable — fall through with "en".
|
||||
}
|
||||
|
||||
if (firstRun) {
|
||||
// Fire-and-forget: the chosen language already drives this session;
|
||||
// persisting just locks it in so the next launch skips detection.
|
||||
void Preferences.SetLanguage(language as LanguageCode).catch(() => {});
|
||||
Preferences.SetLanguage(language as LanguageCode).catch(() => {});
|
||||
}
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
@@ -102,14 +74,12 @@ export async function initI18n(): Promise<void> {
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
// The event name + payload type come from Wails' generated module
|
||||
// augmentation (bindings/.../wails/v3/internal/eventdata.d.ts) which
|
||||
// extends @wailsio/runtime's CustomEvents interface, so e.data is
|
||||
// typed as UIPreferences without any hand-written cast.
|
||||
Events.On("netbird:preferences:changed", (e) => {
|
||||
const next = e.data?.language;
|
||||
if (next && next !== i18next.language) {
|
||||
void i18next.changeLanguage(next);
|
||||
i18next.changeLanguage(next).catch((err: unknown) => {
|
||||
console.error("changeLanguage failed", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { UILog } from "@bindings/services";
|
||||
|
||||
// Forwards browser console output and uncaught errors into the Go logrus
|
||||
// pipeline. Originals still fire, so DevTools is unchanged; the Go
|
||||
// --log-level does the gating.
|
||||
|
||||
type Level = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
const METHOD_LEVELS: Record<string, Level> = {
|
||||
@@ -15,10 +11,15 @@ const METHOD_LEVELS: Record<string, Level> = {
|
||||
error: "error",
|
||||
};
|
||||
|
||||
// Sources whose output is noise and shouldn't be forwarded.
|
||||
const IGNORED_SOURCES = new Set(["welcome.ts"]);
|
||||
|
||||
const RATE_LIMIT = 50;
|
||||
const RATE_WINDOW_MS = 1000;
|
||||
|
||||
let installed = false;
|
||||
let inForward = false;
|
||||
let windowStart = 0;
|
||||
let windowCount = 0;
|
||||
|
||||
function format(args: unknown[]): string {
|
||||
return args
|
||||
@@ -34,27 +35,35 @@ function format(args: unknown[]): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
// First stack frame outside this module as "<file>:<line>" (best-effort;
|
||||
// minified prod stacks degrade to chunk names).
|
||||
function callerSource(): string {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return "";
|
||||
for (const line of stack.split("\n").slice(1)) {
|
||||
if (line.includes("/logs.ts")) continue;
|
||||
const m = line.match(/([^/\\() ]+\.[a-z]+):(\d+):\d+/i);
|
||||
const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line);
|
||||
if (m) return `${m[1]}:${m[2]}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function forward(level: Level, args: unknown[]) {
|
||||
if (inForward) return;
|
||||
inForward = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (now - windowStart >= RATE_WINDOW_MS) {
|
||||
windowStart = now;
|
||||
windowCount = 0;
|
||||
}
|
||||
if (++windowCount > RATE_LIMIT) return;
|
||||
|
||||
const source = callerSource();
|
||||
if (IGNORED_SOURCES.has(source.split(":")[0])) return;
|
||||
// Fire-and-forget; don't touch console here (would recurse).
|
||||
void UILog.Log(level, source, format(args));
|
||||
// Don't touch console here — it would recurse back into forward().
|
||||
UILog.Log(level, source, format(args)).catch(() => {});
|
||||
} catch {
|
||||
// swallow
|
||||
} finally {
|
||||
inForward = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +80,10 @@ export function initLogForwarding() {
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener("error", (e) => {
|
||||
globalThis.addEventListener("error", (e) => {
|
||||
forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
globalThis.addEventListener("unhandledrejection", (e) => {
|
||||
forward("error", ["unhandled promise rejection:", e.reason]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
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",
|
||||
ipv6: "fd00:dead:beef::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: {},
|
||||
}),
|
||||
];
|
||||
@@ -1,42 +1,37 @@
|
||||
import { System } from "@wailsio/runtime";
|
||||
|
||||
export type Platform = {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
};
|
||||
|
||||
let cached: Platform | null = null;
|
||||
|
||||
export async function initPlatform(): Promise<void> {
|
||||
if (cached) return;
|
||||
if (cached) return;
|
||||
|
||||
// Sync getters read the page-injected `window._wails.environment`, which can
|
||||
// be empty if the injection hasn't landed yet — keep them only as a fallback.
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
|
||||
// The async Environment() call round-trips to the Go backend and is the
|
||||
// authoritative source for OS.
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
|
||||
// Prefer the async env.OS; fall back to the sync getters if it's missing.
|
||||
const os = (env?.OS ?? "").toLowerCase();
|
||||
cached = {
|
||||
isWindows: os ? os === "windows" : syncIsWindows,
|
||||
isMacOS: os ? os === "darwin" : syncIsMac,
|
||||
};
|
||||
const os = (env?.OS ?? "").toLowerCase();
|
||||
cached = {
|
||||
isWindows: os ? os === "windows" : syncIsWindows,
|
||||
isMacOS: os ? os === "darwin" : syncIsMac,
|
||||
};
|
||||
}
|
||||
|
||||
function get(): Platform {
|
||||
if (!cached) {
|
||||
throw new Error("platform: initPlatform() must complete before sync getters are used");
|
||||
}
|
||||
return cached;
|
||||
if (!cached) {
|
||||
throw new Error("platform: initPlatform() must complete before sync getters are used");
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const isWindows = (): boolean => get().isWindows;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Stable, order-preserving reconciliation for lists that re-fetch from the daemon
|
||||
// on every status push (peers, networks, profiles). Re-sorting on each refresh would
|
||||
// make rows jump around under the user, so instead:
|
||||
// - items already on screen keep their existing order (from `prev`),
|
||||
// - items that vanished are dropped,
|
||||
// - newly-arrived items are sorted among themselves (`compareFresh`) and appended.
|
||||
// Net effect: the only visible movement is new rows landing at the bottom.
|
||||
//
|
||||
// Must stay pure and idempotent: callers write the returned `order` into a ref
|
||||
// during render (useMemo), so a rerun must reproduce the first pass — never
|
||||
// branch on run count or read external mutable state.
|
||||
export function reconcileOrder<T>(
|
||||
prev: string[],
|
||||
items: T[],
|
||||
keyOf: (item: T) => string,
|
||||
compareFresh: (a: T, b: T) => number,
|
||||
): { order: string[]; items: T[] } {
|
||||
const byKey = new Map(items.map((i) => [keyOf(i), i]));
|
||||
const kept = prev.filter((k) => byKey.has(k));
|
||||
const known = new Set(kept);
|
||||
const fresh = items
|
||||
.filter((i) => !known.has(keyOf(i)))
|
||||
.sort(compareFresh)
|
||||
.map(keyOf);
|
||||
const order = [...kept, ...fresh];
|
||||
return { order, items: order.map((k) => byKey.get(k)!) };
|
||||
}
|
||||
Reference in New Issue
Block a user