mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 13:39:07 +02:00
[management,client] 0.75.0 release with new desktop UI (#6473)
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Compat } from "@bindings/services";
|
||||
|
||||
let cached: boolean | null = null;
|
||||
|
||||
/**
|
||||
* isDaemonCompatible probes whether the running daemon implements the WailsUIReady
|
||||
* RPC. A false result means the daemon predates this UI (Unimplemented) and is too
|
||||
* old to drive it. The Go side returns an error instead when the daemon is simply
|
||||
* unreachable, so a throw here is NOT an outdated daemon — treat it as "unknown"
|
||||
* and let the normal connection flow report it.
|
||||
*
|
||||
* The result is cached for the session: daemon identity does not change without a
|
||||
* UI restart, and a freshly started daemon is reachable again under the same socket.
|
||||
*/
|
||||
export async function isDaemonCompatible(): Promise<boolean> {
|
||||
if (cached !== null) return cached;
|
||||
cached = await Compat.DaemonReady();
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
export const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
|
||||
let connectionInFlight = false;
|
||||
|
||||
type SsoState = {
|
||||
cancelled: boolean;
|
||||
offCancel?: () => void;
|
||||
offSignal?: () => void;
|
||||
};
|
||||
|
||||
async function openBrowserLoginUri(uri: string): Promise<void> {
|
||||
try {
|
||||
await WindowManager.OpenBrowserLogin(uri);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
|
||||
state.cancelled = true;
|
||||
resolve();
|
||||
});
|
||||
if (!signal) return;
|
||||
const onAbort = () => {
|
||||
state.cancelled = true;
|
||||
resolve();
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", onAbort);
|
||||
state.offSignal = () => signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSsoLogin(
|
||||
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
|
||||
state: SsoState,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const uri = result.verificationUriComplete || result.verificationUri;
|
||||
if (uri) await openBrowserLoginUri(uri);
|
||||
|
||||
const cancelPromise = buildSsoCancelPromise(state, signal);
|
||||
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
|
||||
|
||||
try {
|
||||
await Promise.race([waitPromise, cancelPromise]);
|
||||
} finally {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
}
|
||||
|
||||
if (state.cancelled) {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
|
||||
if (connectionInFlight || signal?.aborted) {
|
||||
onSettled?.();
|
||||
return;
|
||||
}
|
||||
connectionInFlight = true;
|
||||
|
||||
const state: SsoState = { cancelled: false };
|
||||
let connectError: unknown;
|
||||
|
||||
try {
|
||||
const result = await Connection.Login({
|
||||
profileName: "",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
setupKey: "",
|
||||
preSharedKey: "",
|
||||
hostname: "",
|
||||
hint: "",
|
||||
});
|
||||
|
||||
if (signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled && result.needsSsoLogin) {
|
||||
await runSsoLogin(result, state, signal);
|
||||
}
|
||||
|
||||
if (!state.cancelled && signal?.aborted) state.cancelled = true;
|
||||
|
||||
if (!state.cancelled) {
|
||||
await Connection.Up({ profileName: "", username: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
if (!state.cancelled) connectError = e;
|
||||
} finally {
|
||||
state.offCancel?.();
|
||||
state.offSignal?.();
|
||||
connectionInFlight = false;
|
||||
onSettled?.();
|
||||
}
|
||||
|
||||
if (connectError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(connectError),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.cancelled && signal) {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string };
|
||||
|
||||
const asObject = (v: unknown): Record<string, unknown> | null =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
|
||||
const parseJsonObject = (s: unknown): Record<string, unknown> | null => {
|
||||
if (typeof s !== "string") return null;
|
||||
const t = s.trim();
|
||||
if (!t.startsWith("{") || !t.endsWith("}")) return null;
|
||||
try {
|
||||
return asObject(JSON.parse(t));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
const obj = asObject(e);
|
||||
if (!obj) return null;
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long } from wherever the classified error sits in the envelope
|
||||
const toClassifiedError = (v: unknown): ClassifiedError | null => {
|
||||
const o = asObject(v);
|
||||
if (!o) return null;
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
return short || long ? { short, long } : null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const envelope = toWailsEnvelope(e);
|
||||
|
||||
// Prefer the structured { short, long } the daemon classifier produced.
|
||||
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
|
||||
if (classified) {
|
||||
const { short, long } = classified;
|
||||
if (short && long && long !== short) return `${short} Details: ${long}`;
|
||||
if (short) return short;
|
||||
if (long) return long;
|
||||
}
|
||||
|
||||
// Unclassified (a service returned the raw daemon error)
|
||||
const message = envelope?.message;
|
||||
if (typeof message === "string" && message) return message;
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
|
||||
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 Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
if (ms <= 0) return "text-nb-gray-400";
|
||||
if (ms < 100) return "text-green-400";
|
||||
return "text-yellow-400";
|
||||
};
|
||||
|
||||
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`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
// 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)}`;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
|
||||
import { Preferences, I18n } from "@bindings/services";
|
||||
import { type LanguageCode } from "@bindings/i18n/models.js";
|
||||
|
||||
// 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",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const resources: Record<string, { common: Record<string, string> }> = {};
|
||||
for (const path in bundleModules) {
|
||||
const match = /locales\/([^/]+)\/common\.json$/.exec(path);
|
||||
if (match) {
|
||||
const entries = bundleModules[path];
|
||||
const messages: Record<string, string> = {};
|
||||
for (const key in entries) {
|
||||
messages[key] = entries[key].message;
|
||||
}
|
||||
resources[match[1]] = { common: messages };
|
||||
}
|
||||
}
|
||||
|
||||
function detectBrowserLanguage(available: string[]): string | null {
|
||||
const tags = [navigator.language, ...(navigator.languages ?? [])].filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
const byLower = new Map(available.map((code) => [code.toLowerCase(), code]));
|
||||
for (const tag of tags) {
|
||||
const lower = tag.toLowerCase();
|
||||
const exact = byLower.get(lower);
|
||||
if (exact) return exact;
|
||||
const base = byLower.get(lower.split("-")[0]);
|
||||
if (base) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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";
|
||||
let firstRun = false;
|
||||
try {
|
||||
const prefs = await Preferences.Get();
|
||||
if (prefs?.language) {
|
||||
language = prefs.language;
|
||||
} else {
|
||||
firstRun = true;
|
||||
language = detectBrowserLanguage(available) ?? "en";
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("read preferences for language failed, defaulting to en", e);
|
||||
}
|
||||
|
||||
if (firstRun) {
|
||||
Preferences.SetLanguage(language as LanguageCode).catch((err: unknown) =>
|
||||
console.warn("persist detected language failed", err),
|
||||
);
|
||||
}
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: language,
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
resources,
|
||||
interpolation: {
|
||||
prefix: "{",
|
||||
suffix: "}",
|
||||
escapeValue: false,
|
||||
},
|
||||
returnNull: false,
|
||||
});
|
||||
|
||||
syncDocumentLang();
|
||||
i18next.on("languageChanged", syncDocumentLang);
|
||||
|
||||
Events.On("netbird:preferences:changed", (e) => {
|
||||
const next = e.data?.language;
|
||||
if (next && next !== i18next.language) {
|
||||
i18next.changeLanguage(next).catch((err: unknown) => {
|
||||
console.error("changeLanguage failed", err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncDocumentLang() {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = i18next.language;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLanguages() {
|
||||
return I18n.Languages();
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { UILog } from "@bindings/services";
|
||||
|
||||
type Level = "trace" | "debug" | "info" | "warn" | "error";
|
||||
|
||||
const METHOD_LEVELS: Record<string, Level> = {
|
||||
trace: "trace",
|
||||
debug: "debug",
|
||||
log: "info",
|
||||
info: "info",
|
||||
warn: "warn",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
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 describeCause(rawCause: unknown): string {
|
||||
if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`;
|
||||
if (typeof rawCause === "object" && rawCause !== null) {
|
||||
try {
|
||||
return JSON.stringify(rawCause);
|
||||
} catch {
|
||||
// Circular ref — fall through to a tag instead of "[object Object]".
|
||||
return `<${rawCause.constructor?.name ?? "object"}>`;
|
||||
}
|
||||
}
|
||||
return String(rawCause);
|
||||
}
|
||||
|
||||
function formatCause(rawCause: unknown): string {
|
||||
if (rawCause === undefined) return "";
|
||||
return `\ncaused by ${describeCause(rawCause)}`;
|
||||
}
|
||||
|
||||
// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack,
|
||||
// so a bare stack hides the real cause. Prepend name+message, then the stack.
|
||||
function formatError(e: Error): string {
|
||||
const head = `${e.name}: ${e.message}`;
|
||||
const cause = formatCause((e as { cause?: unknown }).cause);
|
||||
if (!e.stack) return `${head}${cause}`;
|
||||
if (e.stack.startsWith(head)) return `${head}${cause}`;
|
||||
return `${head}${cause}\n${e.stack}`;
|
||||
}
|
||||
|
||||
function format(args: unknown[]): string {
|
||||
return args
|
||||
.map((a) => {
|
||||
if (typeof a === "string") return a;
|
||||
if (a instanceof Error) return formatError(a);
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function parseStackLine(line: string): string {
|
||||
// Find the file:line:col tail at the end of the path.
|
||||
const colonCol = line.lastIndexOf(":");
|
||||
if (colonCol <= 0) return "";
|
||||
const colonLine = line.lastIndexOf(":", colonCol - 1);
|
||||
if (colonLine <= 0) return "";
|
||||
const col = line.slice(colonCol + 1);
|
||||
const lineNo = line.slice(colonLine + 1, colonCol);
|
||||
if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return "";
|
||||
const before = line.slice(0, colonLine);
|
||||
const sep = Math.max(
|
||||
before.lastIndexOf("/"),
|
||||
before.lastIndexOf("\\"),
|
||||
before.lastIndexOf("("),
|
||||
before.lastIndexOf(" "),
|
||||
);
|
||||
const file = before.slice(sep + 1);
|
||||
if (!file.includes(".")) return "";
|
||||
return `${file}:${lineNo}`;
|
||||
}
|
||||
|
||||
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 parsed = parseStackLine(line);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
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;
|
||||
// Don't touch console here — it would recurse back into forward().
|
||||
UILog.Log(level, source, format(args)).catch(() => {});
|
||||
} catch {
|
||||
// Swallow — log forwarding must never throw back into the caller.
|
||||
} finally {
|
||||
inForward = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initLogForwarding() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const c = console as unknown as Record<string, (...a: unknown[]) => void>;
|
||||
for (const [method, level] of Object.entries(METHOD_LEVELS)) {
|
||||
const original = c[method]?.bind(console);
|
||||
c[method] = (...args: unknown[]) => {
|
||||
original?.(...args);
|
||||
forward(level, args);
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.addEventListener("error", (e) => {
|
||||
forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]);
|
||||
});
|
||||
globalThis.addEventListener("unhandledrejection", (e) => {
|
||||
forward("error", ["unhandled promise rejection:", e.reason]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { System } from "@wailsio/runtime";
|
||||
|
||||
export type Platform = {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
};
|
||||
|
||||
let cached: Platform | null = null;
|
||||
|
||||
export async function initPlatform(): Promise<void> {
|
||||
if (cached) return;
|
||||
|
||||
const syncIsMac = System.IsMac();
|
||||
const syncIsWindows = System.IsWindows();
|
||||
|
||||
let env: Awaited<ReturnType<typeof System.Environment>> | null = null;
|
||||
try {
|
||||
env = await System.Environment();
|
||||
} catch (e) {
|
||||
console.error("[platform] System.Environment() threw:", e);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const isWindows = (): boolean => get().isWindows;
|
||||
export const isMacOS = (): boolean => get().isMacOS;
|
||||
export const isLinux = (): boolean => !get().isWindows && !get().isMacOS;
|
||||
@@ -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)!) };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const ART = `
|
||||
_ __ __ ____ _ __ ______ __ __ __
|
||||
/ | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / /
|
||||
/ |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ /
|
||||
/ /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ /
|
||||
/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/
|
||||
`;
|
||||
|
||||
export function welcome() {
|
||||
const message = `%c${ART}%c
|
||||
NetBird — The Only Secure Access Platform You'll Ever Need.
|
||||
|
||||
WEBSITE: https://netbird.io/
|
||||
WE'RE HIRING: https://netbird.io/careers
|
||||
OPEN SOURCE: https://github.com/netbirdio/netbird
|
||||
`;
|
||||
|
||||
// Intentional NetBird ASCII banner in the devtools console.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
message,
|
||||
"color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;",
|
||||
"color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user