Merge branch 'ui-refactor' into ui-refactor-ui

This commit is contained in:
Eduard Gert
2026-05-11 15:15:11 +02:00
641 changed files with 28033 additions and 11804 deletions

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import { Button } from "@/components/Button";
import { useStatus } from "@/hooks/useStatus";
import { cn } from "@/lib/cn";
import { Update as UpdateSvc } from "@bindings/services";
// TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled
export const UpdateAvailableBanner = () => {
const { status } = useStatus();
const [dismissed, setDismissed] = useState(false);
if (import.meta.env.DEV) return null;
const updateVersion = (status?.events ?? [])
.map((e) => e.metadata?.["new_version_available"])
.find((v): v is string => Boolean(v));
if (!updateVersion || dismissed) return null;
const triggerUpdate = () => {
UpdateSvc.Trigger().catch(() => {});
};
return (
<div
className={cn(
"absolute bottom-4 left-1/2 -translate-x-1/2 z-50",
"w-[calc(100%-2rem)] max-w-xl",
"flex items-center justify-between gap-3",
"rounded-xl border border-nb-gray-800 bg-white backdrop-blur",
"px-2 py-2 shadow-lg",
)}
>
<p className={"text-sm text-nb-gray-900 pr-4 pl-2 font-medium"}>
NetBird will update when you restart the app.
</p>
<div className={"flex gap-2"}>
<Button variant={"subtle"} size={"xs"} onClick={() => setDismissed(true)}>
Later
</Button>
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
Restart now
</Button>
</div>
</div>
);
};
export default UpdateAvailableBanner;

View File

@@ -0,0 +1,16 @@
import { createContext, type ReactNode } from "react";
import { useDebugBundle } from "@/modules/debug-bundle/useDebugBundle.ts";
export type DebugBundleContextValue = ReturnType<typeof useDebugBundle>;
export const DebugBundleContext =
createContext<DebugBundleContextValue | null>(null);
export const DebugBundleProvider = ({ children }: { children: ReactNode }) => {
const value = useDebugBundle();
return (
<DebugBundleContext.Provider value={value}>
{children}
</DebugBundleContext.Provider>
);
};

View File

@@ -0,0 +1,190 @@
import { useRef, useState } from "react";
import {
Connection as ConnectionSvc,
Debug as DebugSvc,
} from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
const TRACE_LOG_FILE_COUNT = 5;
const PLAIN_LOG_FILE_COUNT = 1;
export type DebugStage =
| { kind: "idle" }
| { kind: "preparing-trace" }
| { kind: "reconnecting" }
| { kind: "capturing"; remainingSec: number; totalSec: number }
| { kind: "restoring-level" }
| { kind: "bundling" }
| { kind: "uploading" }
| { kind: "cancelling" }
| { kind: "done"; result: DebugBundleResult; uploadAttempted: boolean }
| { kind: "error"; message: string };
const sleep = (ms: number, signal: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("aborted", "AbortError"));
return;
}
const onAbort = () => {
clearTimeout(id);
reject(new DOMException("aborted", "AbortError"));
};
const id = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal.addEventListener("abort", onAbort);
});
const isAbort = (e: unknown) =>
e instanceof DOMException && e.name === "AbortError";
export const useDebugBundle = () => {
const { activeProfile, username } = useProfile();
const [anonymize, setAnonymize] = useState(false);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(true);
const [trace, setTrace] = useState(true);
const [traceMinutes, setTraceMinutes] = useState(1);
const [stage, setStage] = useState<DebugStage>({ kind: "idle" });
const [lastBundlePath, setLastBundlePath] = useState<string>("");
const abortRef = useRef<AbortController | null>(null);
const isRunning =
stage.kind !== "idle" &&
stage.kind !== "done" &&
stage.kind !== "error";
const reset = () => setStage({ kind: "idle" });
const cancel = () => {
if (!abortRef.current || abortRef.current.signal.aborted) return;
abortRef.current.abort();
setStage({ kind: "cancelling" });
};
const run = async () => {
const ctrl = new AbortController();
abortRef.current = ctrl;
const signal = ctrl.signal;
const checkAbort = () => {
if (signal.aborted)
throw new DOMException("aborted", "AbortError");
};
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
let originalLevel = "info";
let raisedLevel = false;
try {
if (trace) {
setStage({ kind: "preparing-trace" });
try {
const cur = await DebugSvc.GetLogLevel();
if (cur?.level) originalLevel = cur.level;
} catch {
// best effort
}
checkAbort();
await DebugSvc.SetLogLevel({ level: "trace" });
raisedLevel = true;
checkAbort();
setStage({ kind: "reconnecting" });
try {
await ConnectionSvc.Down();
} catch {
// already down
}
checkAbort();
await ConnectionSvc.Up({
profileName: activeProfile,
username,
});
const totalSec =
Math.max(1, Math.min(30, traceMinutes)) * 60;
for (let remaining = totalSec; remaining > 0; remaining--) {
setStage({
kind: "capturing",
remainingSec: remaining,
totalSec,
});
await sleep(1000, signal);
}
setStage({ kind: "restoring-level" });
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
raisedLevel = false;
} catch {
// restore is best-effort
}
}
checkAbort();
setStage({ kind: "bundling" });
const logFileCount = trace
? TRACE_LOG_FILE_COUNT
: PLAIN_LOG_FILE_COUNT;
if (uploadUrl) setStage({ kind: "uploading" });
const result = await DebugSvc.Bundle({
anonymize,
systemInfo,
uploadUrl,
logFileCount,
});
checkAbort();
if (result.path) setLastBundlePath(result.path);
setStage({
kind: "done",
result,
uploadAttempted: Boolean(uploadUrl),
});
} catch (e) {
if (isAbort(e)) {
if (raisedLevel) {
try {
await DebugSvc.SetLogLevel({ level: originalLevel });
} catch {
// best effort
}
}
setStage({ kind: "idle" });
return;
}
setStage({ kind: "error", message: String(e) });
} finally {
if (abortRef.current === ctrl) abortRef.current = null;
}
};
const openBundleDir = () => {
if (!lastBundlePath) return;
void DebugSvc.RevealFile(lastBundlePath).catch(() => {});
};
return {
anonymize,
setAnonymize,
systemInfo,
setSystemInfo,
upload,
setUpload,
trace,
setTrace,
traceMinutes,
setTraceMinutes,
stage,
isRunning,
lastBundlePath,
run,
cancel,
reset,
openBundleDir,
};
};

View File

@@ -0,0 +1,12 @@
import { useContext } from "react";
import { DebugBundleContext } from "@/modules/debug-bundle/DebugBundleContext.tsx";
export const useDebugBundleContext = () => {
const ctx = useContext(DebugBundleContext);
if (!ctx) {
throw new Error(
"useDebugBundleContext must be used inside DebugBundleProvider",
);
}
return ctx;
};

View File

@@ -0,0 +1,53 @@
import { cn } from "@/lib/cn";
export type StatusFilter = "all" | "online" | "offline";
const FILTERS: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "online", label: "Online" },
{ value: "offline", label: "Offline" },
];
type Props = {
value: StatusFilter;
onChange: (value: StatusFilter) => void;
counts: Record<StatusFilter, number>;
};
export const PeerFilters = ({ value, onChange, counts }: Props) => {
return (
<div
className={
"flex w-full rounded-md border border-nb-gray-900 bg-nb-gray-940 p-0.5"
}
>
{FILTERS.map((f) => {
const active = value === f.value;
return (
<button
key={f.value}
type={"button"}
onClick={() => onChange(f.value)}
className={cn(
"flex-1 inline-flex items-center justify-center gap-1.5 rounded px-2.5 py-2 text-xs font-medium",
"transition-colors duration-150 cursor-default outline-none",
active
? "bg-nb-gray-800 text-nb-gray-100"
: "text-nb-gray-400 hover:text-nb-gray-200",
)}
>
{f.label}
<span
className={cn(
"text-[0.65rem] font-mono",
active ? "text-nb-gray-300" : "text-nb-gray-500",
)}
>
{counts[f.value]}
</span>
</button>
);
})}
</div>
);
};

View File

@@ -0,0 +1,73 @@
import { useMemo, useState } from "react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
import { SearchInput } from "@/components/SearchInput";
import { mockPeers } from "./mockPeers";
import { PeerFilters, StatusFilter } from "./PeerFilters";
import { PeersList } from "./PeersList";
const isOnline = (status: string) => status === "connected";
export const Peers = () => {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const counts = useMemo<Record<StatusFilter, number>>(() => {
const online = mockPeers.filter((p) => isOnline(p.status)).length;
return {
all: mockPeers.length,
online,
offline: mockPeers.length - online,
};
}, []);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return mockPeers.filter((p) => {
if (statusFilter === "online" && !isOnline(p.status)) return false;
if (statusFilter === "offline" && isOnline(p.status)) return false;
if (q && !p.fqdn.toLowerCase().includes(q) && !p.ip.includes(q)) {
return false;
}
return true;
});
}, [search, statusFilter]);
return (
<div className={"flex flex-col w-full h-full min-h-0 pt-4"}>
<div className={"flex flex-col gap-3 px-4"}>
<SearchInput
placeholder={"Search by FQDN or IP…"}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<PeerFilters
value={statusFilter}
onChange={setStatusFilter}
counts={counts}
/>
</div>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden mt-3"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeersList data={filtered} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { cn } from "@/lib/cn";
import { Peer, PeerStatus } from "./types";
const DOT: Record<PeerStatus, string> = {
connected: "bg-green-400",
connecting: "bg-yellow-300 animate-pulse-slow",
disconnected: "bg-nb-gray-500",
};
export const PeersList = ({ data }: { data: Peer[] }) => {
if (data.length === 0) {
return (
<div className={"py-12 text-center text-sm text-nb-gray-400"}>
No peers match the current filters.
</div>
);
}
return (
<ul className={"flex flex-col"}>
{data.map((peer) => (
<li
key={peer.id}
className={"flex items-center gap-3 px-4 py-3 min-w-0"}
>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
DOT[peer.status],
)}
title={peer.status}
/>
<span
className={
"text-[0.81rem] font-medium text-nb-gray-100 truncate"
}
>
{peer.fqdn}
</span>
<span
className={
"ml-auto text-xs font-mono text-nb-gray-400 shrink-0"
}
>
{peer.ip}
</span>
</li>
))}
</ul>
);
};

View File

@@ -0,0 +1,385 @@
import { Peer } from "./types";
const minutesAgo = (m: number) => new Date(Date.now() - m * 60 * 1000);
export const mockPeers: Peer[] = [
{
id: "p-001",
fqdn: "alice-laptop.netbird.cloud",
ip: "100.64.0.12",
status: "connected",
lastHandshake: minutesAgo(1),
latencyMs: 18,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 84,
bytesTx: 1024 * 1024 * 12,
endpointLocal: "192.168.1.24:51820",
endpointRemote: "203.0.113.45:51820",
},
{
id: "p-002",
fqdn: "bob-desktop.netbird.cloud",
ip: "100.64.0.21",
status: "connected",
lastHandshake: minutesAgo(3),
latencyMs: 42,
relayed: true,
relayAddress: "rel.eu-central.netbird.io:443",
iceLocalCandidate: "relay",
iceRemoteCandidate: "relay",
bytesRx: 1024 * 380,
bytesTx: 1024 * 940,
endpointLocal: "10.0.0.8:51820",
endpointRemote: "198.51.100.7:51820",
},
{
id: "p-003",
fqdn: "build-runner-01.netbird.cloud",
ip: "100.64.0.34",
status: "connecting",
lastHandshake: minutesAgo(15),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 0,
bytesTx: 0,
endpointLocal: "192.168.1.45:51820",
endpointRemote: "—",
},
{
id: "p-004",
fqdn: "carol-phone.netbird.cloud",
ip: "100.64.0.55",
status: "disconnected",
lastHandshake: minutesAgo(620),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 5,
bytesTx: 1024 * 1024 * 2,
endpointLocal: "—",
endpointRemote: "—",
},
{
id: "p-005",
fqdn: "exit-berlin.netbird.cloud",
ip: "100.64.0.2",
status: "connected",
lastHandshake: minutesAgo(0.2),
latencyMs: 9,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 1024 * 2,
bytesTx: 1024 * 1024 * 512,
endpointLocal: "10.10.0.4:51820",
endpointRemote: "203.0.113.99:51820",
},
{
id: "p-006",
fqdn: "db-replica-eu.netbird.cloud",
ip: "100.64.0.78",
status: "connected",
lastHandshake: minutesAgo(7),
latencyMs: 64,
relayed: true,
relayAddress: "rel.us-east.netbird.io:443",
iceLocalCandidate: "relay",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 240,
bytesTx: 1024 * 1024 * 90,
endpointLocal: "172.16.0.10:51820",
endpointRemote: "198.51.100.42:51820",
},
{
id: "p-007",
fqdn: "dev-vm-mac.netbird.cloud",
ip: "100.64.0.91",
status: "disconnected",
lastHandshake: minutesAgo(2880),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 0,
bytesTx: 0,
endpointLocal: "—",
endpointRemote: "—",
},
{
id: "p-008",
fqdn: "ci-worker-03.netbird.cloud",
ip: "100.64.0.103",
status: "connected",
lastHandshake: minutesAgo(0.5),
latencyMs: 27,
relayed: false,
iceLocalCandidate: "prflx",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 14,
bytesTx: 1024 * 1024 * 3,
endpointLocal: "192.168.50.7:51820",
endpointRemote: "203.0.113.61:51820",
},
{
id: "p-009",
fqdn: "k8s-control-plane.netbird.cloud",
ip: "100.64.0.110",
status: "connected",
lastHandshake: minutesAgo(2),
latencyMs: 12,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 410,
bytesTx: 1024 * 1024 * 380,
endpointLocal: "10.0.1.10:51820",
endpointRemote: "10.0.1.11:51820",
},
{
id: "p-010",
fqdn: "k8s-worker-01.netbird.cloud",
ip: "100.64.0.111",
status: "connected",
lastHandshake: minutesAgo(2),
latencyMs: 14,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 220,
bytesTx: 1024 * 1024 * 190,
endpointLocal: "10.0.1.20:51820",
endpointRemote: "10.0.1.21:51820",
},
{
id: "p-011",
fqdn: "k8s-worker-02.netbird.cloud",
ip: "100.64.0.112",
status: "connecting",
lastHandshake: minutesAgo(8),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "srflx",
bytesRx: 0,
bytesTx: 0,
endpointLocal: "10.0.1.22:51820",
endpointRemote: "—",
},
{
id: "p-012",
fqdn: "monitoring-prom.netbird.cloud",
ip: "100.64.0.130",
status: "connected",
lastHandshake: minutesAgo(0.3),
latencyMs: 22,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 56,
bytesTx: 1024 * 1024 * 18,
endpointLocal: "10.20.0.5:51820",
endpointRemote: "203.0.113.122:51820",
},
{
id: "p-013",
fqdn: "grafana.netbird.cloud",
ip: "100.64.0.131",
status: "connected",
lastHandshake: minutesAgo(0.4),
latencyMs: 19,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 32,
bytesTx: 1024 * 1024 * 8,
endpointLocal: "10.20.0.6:51820",
endpointRemote: "203.0.113.123:51820",
},
{
id: "p-014",
fqdn: "loki-log-aggregator.netbird.cloud",
ip: "100.64.0.132",
status: "disconnected",
lastHandshake: minutesAgo(45),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 12,
bytesTx: 1024 * 1024 * 4,
endpointLocal: "—",
endpointRemote: "—",
},
{
id: "p-015",
fqdn: "dave-laptop.netbird.cloud",
ip: "100.64.0.140",
status: "connected",
lastHandshake: minutesAgo(1),
latencyMs: 38,
relayed: true,
relayAddress: "rel.eu-west.netbird.io:443",
iceLocalCandidate: "relay",
iceRemoteCandidate: "relay",
bytesRx: 1024 * 720,
bytesTx: 1024 * 410,
endpointLocal: "192.168.43.21:51820",
endpointRemote: "198.51.100.88:51820",
},
{
id: "p-016",
fqdn: "eve-iphone.netbird.cloud",
ip: "100.64.0.150",
status: "connecting",
lastHandshake: minutesAgo(20),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "host",
bytesRx: 0,
bytesTx: 0,
endpointLocal: "—",
endpointRemote: "—",
},
{
id: "p-017",
fqdn: "frank-windows.netbird.cloud",
ip: "100.64.0.155",
status: "connected",
lastHandshake: minutesAgo(4),
latencyMs: 76,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 6,
bytesTx: 1024 * 1024 * 2,
endpointLocal: "192.168.1.55:51820",
endpointRemote: "203.0.113.200:51820",
},
{
id: "p-018",
fqdn: "exit-frankfurt.netbird.cloud",
ip: "100.64.0.3",
status: "connected",
lastHandshake: minutesAgo(0.1),
latencyMs: 6,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 1024 * 5,
bytesTx: 1024 * 1024 * 1024 * 1,
endpointLocal: "10.10.0.5:51820",
endpointRemote: "203.0.113.150:51820",
},
{
id: "p-019",
fqdn: "exit-singapore.netbird.cloud",
ip: "100.64.0.4",
status: "disconnected",
lastHandshake: minutesAgo(180),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 880,
bytesTx: 1024 * 1024 * 220,
endpointLocal: "—",
endpointRemote: "—",
},
{
id: "p-020",
fqdn: "nas-home.netbird.cloud",
ip: "100.64.0.180",
status: "connected",
lastHandshake: minutesAgo(0.7),
latencyMs: 31,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 1024 * 3,
bytesTx: 1024 * 1024 * 480,
endpointLocal: "192.168.0.50:51820",
endpointRemote: "203.0.113.45:51820",
},
{
id: "p-021",
fqdn: "raspberrypi-iot.netbird.cloud",
ip: "100.64.0.181",
status: "connected",
lastHandshake: minutesAgo(5),
latencyMs: 54,
relayed: true,
relayAddress: "rel.eu-central.netbird.io:443",
iceLocalCandidate: "relay",
iceRemoteCandidate: "host",
bytesRx: 1024 * 240,
bytesTx: 1024 * 110,
endpointLocal: "192.168.0.121:51820",
endpointRemote: "198.51.100.42:51820",
},
{
id: "p-022",
fqdn: "staging-api.netbird.cloud",
ip: "100.64.0.200",
status: "connected",
lastHandshake: minutesAgo(0.2),
latencyMs: 16,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 92,
bytesTx: 1024 * 1024 * 140,
endpointLocal: "10.30.0.10:51820",
endpointRemote: "10.30.0.11:51820",
},
{
id: "p-023",
fqdn: "prod-api-eu.netbird.cloud",
ip: "100.64.0.201",
status: "connected",
lastHandshake: minutesAgo(0.1),
latencyMs: 8,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 1024 * 1024 * 1024 * 12,
bytesTx: 1024 * 1024 * 1024 * 3,
endpointLocal: "10.40.0.10:51820",
endpointRemote: "10.40.0.11:51820",
},
{
id: "p-024",
fqdn: "prod-api-us.netbird.cloud",
ip: "100.64.0.202",
status: "connected",
lastHandshake: minutesAgo(0.1),
latencyMs: 92,
relayed: false,
iceLocalCandidate: "srflx",
iceRemoteCandidate: "srflx",
bytesRx: 1024 * 1024 * 1024 * 8,
bytesTx: 1024 * 1024 * 1024 * 2,
endpointLocal: "10.50.0.10:51820",
endpointRemote: "203.0.113.210:51820",
},
{
id: "p-025",
fqdn: "old-jenkins.netbird.cloud",
ip: "100.64.0.220",
status: "disconnected",
lastHandshake: minutesAgo(8640),
latencyMs: 0,
relayed: false,
iceLocalCandidate: "host",
iceRemoteCandidate: "host",
bytesRx: 0,
bytesTx: 0,
endpointLocal: "—",
endpointRemote: "—",
},
];

View File

@@ -0,0 +1,20 @@
export type PeerStatus = "connected" | "connecting" | "disconnected";
export type IceCandidateType = "host" | "srflx" | "relay" | "prflx";
export type Peer = {
id: string;
fqdn: string;
ip: string;
status: PeerStatus;
lastHandshake: Date;
latencyMs: number;
relayed: boolean;
relayAddress?: string;
iceLocalCandidate: IceCandidateType;
iceRemoteCandidate: IceCandidateType;
bytesRx: number;
bytesTx: number;
endpointLocal: string;
endpointRemote: string;
};

View File

@@ -0,0 +1,76 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { Profiles as ProfilesSvc } from "@bindings/services";
type ProfileContextValue = {
username: string;
activeProfile: string;
loaded: boolean;
error: string | null;
refresh: () => Promise<void>;
switchProfile: (name: string) => Promise<void>;
};
const ProfileContext = createContext<ProfileContextValue | null>(null);
export const useProfile = () => {
const ctx = useContext(ProfileContext);
if (!ctx) {
throw new Error("useProfile must be used inside ProfileProvider");
}
return ctx;
};
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
const [username, setUsername] = useState("");
const [activeProfile, setActiveProfile] = useState("");
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const u = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
setUsername(u);
setActiveProfile(active.profileName || "default");
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoaded(true);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const switchProfile = useCallback(
async (name: string) => {
await ProfilesSvc.Switch({ profileName: name, username });
setActiveProfile(name);
},
[username],
);
return (
<ProfileContext.Provider
value={{
username,
activeProfile,
loaded,
error,
refresh,
switchProfile,
}}
>
{children}
</ProfileContext.Provider>
);
};

View File

@@ -0,0 +1,21 @@
import netbirdLogo from "@/assets/logos/netbird.svg";
import { SwitchItem } from "@/components/SwitchItem";
import { SwitchItemGroup } from "@/components/SwitchItemGroup";
import { ManagementMode } from "@/modules/settings/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
};
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
return (
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
<SwitchItem value={ManagementMode.Cloud}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
Cloud
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
</SwitchItemGroup>
);
};

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import { SettingsNavigationTriggers } from "@/modules/settings/SettingsNavigationTriggers.tsx";
import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
export const Settings = () => {
const [active, setActive] = useState("general");
return (
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
<SettingsNavigationTriggers />
<MainRightSide>
<ScrollArea.Root
type={"auto"}
className={"flex-1 min-h-0 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
)}
>
<ScrollArea.Thumb
className={
"flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</MainRightSide>
</VerticalTabs>
);
};

View File

@@ -0,0 +1,66 @@
import { Browser } from "@wailsio/runtime";
import netbirdFull from "@/assets/logos/netbird-full.svg";
import pkg from "../../../package.json";
import { useStatus } from "@/hooks/useStatus";
import { NetBirdVersionCard } from "@/components/NetBirdVersionCard";
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
const LEGAL_LINKS: { label: string; url: string }[] = [
{ label: "Imprint", url: "https://netbird.io/imprint" },
{ label: "Privacy", url: "https://netbird.io/privacy" },
{ label: "CLA", url: "https://netbird.io/cla" },
{ label: "Terms of Service", url: "https://netbird.io/terms" },
];
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
}
export function SettingsAbout() {
const { status } = useStatus();
const guiVersion = pkg.version;
const daemonVersion = status?.daemonVersion ?? "—";
const handleVersionClick = useAccentTrigger();
return (
<div
className={
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-10rem)]"
}
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p
className={"text-sm font-semibold text-nb-gray-100 cursor-default select-none"}
onClick={handleVersionClick}
>
NetBird Client v{daemonVersion}
</p>
<p className={"text-sm text-nb-gray-300"}>GUI v{guiVersion}</p>
</div>
<NetBirdVersionCard />
<p className={"text-sm text-nb-gray-300 text-center"}>
© {new Date().getFullYear()} NetBird. All Rights Reserved.
</p>
<div
className={"flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-nb-gray-200"}
>
{LEGAL_LINKS.map((link) => (
<button
key={link.url}
type={"button"}
onClick={() => openUrl(link.url)}
className={
"decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
}
>
{link.label}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
export function useAccentTrigger() {
const clicksRef = useRef(0);
const lastClickRef = useRef(0);
return useCallback(() => {
const now = performance.now();
if (now - lastClickRef.current > 400) {
clicksRef.current = 0;
}
lastClickRef.current = now;
clicksRef.current += 1;
if (clicksRef.current >= 10) {
clicksRef.current = 0;
triggerAccent();
}
}, []);
}
function triggerAccent() {
if (document.getElementById("nb-accent-root")) return;
const container = document.createElement("div");
container.id = "nb-accent-root";
document.body.appendChild(container);
const root = createRoot(container);
const cleanup = () => {
root.unmount();
container.remove();
};
root.render(<Accent onDone={cleanup} />);
}
function Accent({ onDone }: { onDone: () => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(raf);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0, 0, 0, 0.12)";
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
}
drops[i]++;
}
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
const timeout = window.setTimeout(() => {
setVisible(false);
window.setTimeout(onDone, 500);
}, 9000);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timeout);
window.removeEventListener("resize", resize);
};
}, [onDone]);
return (
<div
className={`fixed inset-0 z-50 bg-black/5 transition-opacity duration-500 pointer-events-none ${visible ? "opacity-100" : "opacity-0"}`}
>
<canvas ref={canvasRef} className={"block"} />
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import Button from "@/components/Button";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export function SettingsAdvanced() {
const { config, saveFields } = useSettings();
const [values, setValues] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
const [saving, setSaving] = useState(false);
const hasChanges =
values.interfaceName !== config.interfaceName ||
values.wireguardPort !== config.wireguardPort ||
values.mtu !== config.mtu ||
values.preSharedKey !== config.preSharedKey;
const handleSave = async () => {
if (!hasChanges || saving) return;
setSaving(true);
try {
await saveFields(values);
} finally {
setSaving(false);
}
};
return (
<>
<SectionGroup title={"Interface"}>
<Input
label={"Name"}
value={values.interfaceName}
onChange={(e) =>
setValues((v) => ({ ...v, interfaceName: e.target.value }))
}
/>
<div className={"grid grid-cols-2 gap-4"}>
<Input
label={"Port"}
type={"number"}
value={values.wireguardPort}
onChange={(e) =>
setValues((v) => ({
...v,
wireguardPort: Number(e.target.value),
}))
}
/>
<Input
label={"MTU"}
type={"number"}
value={values.mtu}
onChange={(e) =>
setValues((v) => ({ ...v, mtu: Number(e.target.value) }))
}
/>
</div>
</SectionGroup>
<SectionGroup title={"Security"}>
<div>
<Label as={"div"}>Pre-shared Key</Label>
<HelpText>
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
NetBird Setup Key. You will only communicate with peers that use the same
pre-shared key.
</HelpText>
<Input
type={"password"}
showPasswordToggle
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={values.preSharedKey}
onChange={(e) =>
setValues((v) => ({ ...v, preSharedKey: e.target.value }))
}
/>
</div>
</SectionGroup>
<div className={"absolute bottom-0 left-0 w-full"}>
<div className={"w-full flex justify-end px-8 py-5 border-t border-nb-gray-910"}>
<Button
variant={"primary"}
size={"md"}
disabled={!hasChanges || saving}
onClick={handleSave}
>
Save Changes
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,167 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Settings as SettingsSvc } from "@bindings/services";
import type { Config } from "@bindings/services/models.js";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
const SAVE_DEBOUNCE_MS = 400;
type SettingsContextValue = {
config: Config;
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>;
saveNow: () => Promise<void>;
};
const SettingsContext = createContext<SettingsContextValue | null>(null);
export const useSettings = () => {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error("useSettings must be used inside SettingsProvider");
}
return ctx;
};
const useSettingsState = () => {
const { username, activeProfile, loaded: profileLoaded } = useProfile();
const [config, setConfig] = useState<Config | null>(null);
const [error, setError] = useState<string | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!profileLoaded || !activeProfile) return;
(async () => {
try {
const c = await SettingsSvc.GetConfig({
profileName: activeProfile,
username,
});
setConfig(c);
setError(null);
} catch (e) {
setError(String(e));
}
})();
}, [profileLoaded, activeProfile, username]);
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
},
[],
);
const save = useCallback(
async (next: Config) => {
// The daemon masks an existing PSK as "**********" in GetConfig.
// Sending the mask back round-trips it into the saved config and
// wgtypes.ParseKey fails on the next connect. Drop the mask so
// unrelated toggles don't corrupt the stored PSK.
const { preSharedKey, ...rest } = next;
try {
await SettingsSvc.SetConfig({
...rest,
...(preSharedKey === "**********" ? {} : { preSharedKey }),
profileName: activeProfile,
username,
});
setError(null);
} catch (e) {
setError(String(e));
}
},
[activeProfile, username],
);
const setField = useCallback(
<K extends keyof Config>(k: K, v: Config[K]) => {
setConfig((c) => {
if (!c) return c;
const next = { ...c, [k]: v };
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
void save(next);
}, SAVE_DEBOUNCE_MS);
return next;
});
},
[save],
);
const saveNow = useCallback(async () => {
if (!config) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
await save(config);
}, [config, save]);
const saveField = useCallback(
async <K extends keyof Config>(k: K, v: Config[K]) => {
if (!config) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const next = { ...config, [k]: v };
setConfig(next);
await save(next);
},
[config, save],
);
const saveFields = useCallback(
async (partial: Partial<Config>) => {
if (!config) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const next = { ...config, ...partial };
setConfig(next);
await save(next);
},
[config, save],
);
return { config, error, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, error, setField, saveField, saveFields, saveNow } = useSettingsState();
// TODO: Better displaying of errors
return (
<>
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<SkeletonSettings />
) : (
<SettingsContext.Provider
value={{
config,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";
import { Button } from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx";
import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts";
export function SettingsGeneral() {
const { config, setField } = useSettings();
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
const inputRef = useRef<HTMLInputElement>(null);
const prevMode = useRef(mode);
useEffect(() => {
if (
prevMode.current === ManagementMode.Cloud &&
mode === ManagementMode.SelfHosted
) {
inputRef.current?.focus();
}
prevMode.current = mode;
}, [mode]);
return (
<>
<SectionGroup title={"General"}>
<FancyToggleSwitch
value={!config.disableAutoConnect}
onChange={(v) => setField("disableAutoConnect", !v)}
label={"Connect on Startup"}
helpText={"Automatically establish a connection when the service starts."}
/>
<FancyToggleSwitch
value={!config.disableNotifications}
onChange={(v) => setField("disableNotifications", !v)}
label={"Desktop Notifications"}
helpText={"Show desktop notifications for new updates and connection events."}
/>
</SectionGroup>
<SectionGroup title={"Connection"}>
<div>
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>Management Server</Label>
<HelpText>
Connect to NetBird Cloud or your own self-hosted management server.
Changes will reconnect the client.
</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}>
<Input
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
placeholder={"https://netbird.selfhosted.com:443"}
error={
showError
? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443"
: undefined
}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
onClick={() => save()}
>
Save
</Button>
</div>
)}
</div>
</SectionGroup>
</>
);
}

View File

@@ -0,0 +1,54 @@
import { VerticalTabs } from "@/components/VerticalTabs.tsx";
import {
BoltIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
<VerticalTabs.List>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}
title={"General"}
/>
<VerticalTabs.Trigger
value={"network"}
icon={NetworkIcon}
title={"Network"}
/>
<VerticalTabs.Trigger
value={"security"}
icon={ShieldIcon}
title={"Security"}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}
title={"SSH"}
/>
<VerticalTabs.Trigger
value={"advanced"}
icon={BoltIcon}
title={"Advanced"}
/>
<VerticalTabs.Trigger
value={"troubleshooting"}
icon={LifeBuoyIcon}
title={"Troubleshooting"}
/>
<VerticalTabs.Trigger
value={"about"}
icon={InfoIcon}
title={"About"}
/>
</VerticalTabs.List>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export function SettingsNetwork() {
const { config, setField } = useSettings();
return (
<>
<SectionGroup title={"Connectivity"}>
<FancyToggleSwitch
value={config.lazyConnectionEnabled}
onChange={(v) => setField("lazyConnectionEnabled", v)}
label={"Lazy Connections"}
helpText={
"Instead of maintaining always-on connections, NetBird activates them on-demand based on activity or signaling."
}
/>
<FancyToggleSwitch
value={config.networkMonitor}
onChange={(v) => setField("networkMonitor", v)}
label={"Reconnect on Network Change"}
helpText={
"Monitor the network and automatically reconnect on changes such as Wi-Fi switching, Ethernet changes, or resume from sleep."
}
/>
</SectionGroup>
<SectionGroup title={"Routing & DNS"}>
<FancyToggleSwitch
value={!config.disableDns}
onChange={(v) => setField("disableDns", !v)}
label={"Enable DNS"}
helpText={"Apply NetBird-managed DNS settings to the host resolver."}
/>
<FancyToggleSwitch
value={!config.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", !v)}
label={"Enable Client Routes"}
helpText={"Accept routes from other peers to reach their networks."}
/>
<FancyToggleSwitch
value={!config.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", !v)}
label={"Enable Server Routes"}
helpText={"Advertise this host's local routes to other peers."}
/>
</SectionGroup>
</>
);
}

View File

@@ -0,0 +1,124 @@
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useState } from "react";
export function SettingsSSH() {
const { config, setField } = useSettings();
const isSSHServerEnabled = config.serverSshAllowed;
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
// Keep the local input in sync when the config changes from elsewhere
useEffect(() => {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}, [config.sshJwtCacheTtl]);
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setJwtTtlInput(v);
if (v === "") return;
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
setField("sshJwtCacheTtl", n);
}
};
const handleJwtTtlBlur = () => {
if (jwtTtlInput === "") {
setJwtTtlInput("0");
setField("sshJwtCacheTtl", 0);
return;
}
const n = Number(jwtTtlInput);
if (!Number.isFinite(n) || n < 0) {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}
};
return (
<>
<SectionGroup title={"Server"}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label={"Enable SSH Server"}
helpText={
"Run the NetBird SSH server on this host so other peers can connect to it."
}
/>
</SectionGroup>
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label={"Allow Root Login"}
helpText={
"Let peers sign in as the root user. Disable to require a non-privileged account."
}
/>
<FancyToggleSwitch
value={config.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label={"Allow SFTP"}
helpText={"Transfer files securely using native SFTP or SCP clients."}
/>
<FancyToggleSwitch
value={config.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label={"Local Port Forwarding"}
helpText={
"Let connecting peers tunnel local ports to services reachable from this host."
}
/>
<FancyToggleSwitch
value={config.enableSshRemotePortForwarding}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label={"Remote Port Forwarding"}
helpText={
"Let connecting peers expose ports on this host back to their own machine."
}
/>
</SectionGroup>
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
label={"Enable JWT Authentication"}
helpText={
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
}
/>
<div
className={cn(
"flex items-center gap-6 justify-between",
config.disableSshAuth && "opacity-50 pointer-events-none",
)}
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>JWT Cache TTL</Label>
<HelpText margin={false}>
How long this client caches a JWT before prompting again on outgoing SSH
connections. Set to 0 to disable caching and authenticate on every
connection.
</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
type={"number"}
min={0}
value={jwtTtlInput}
onChange={handleJwtTtlChange}
onBlur={handleJwtTtlBlur}
customSuffix={"Second(s)"}
/>
</div>
</div>
</SectionGroup>
</>
);
}

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
export const SectionGroup = ({
title,
children,
disabled = false,
}: {
title: string;
children: ReactNode;
disabled?: boolean;
}) => (
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title}
</h2>
<div className={"flex flex-col gap-5"}>{children}</div>
</section>
);

View File

@@ -0,0 +1,52 @@
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export function SettingsSecurity() {
const { config, setField } = useSettings();
return (
<>
<SectionGroup title={"Firewall"}>
<FancyToggleSwitch
value={config.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label={"Block Inbound Traffic"}
helpText={
"Reject unsolicited connections from peers to this device and any networks it routes. Outbound traffic is unaffected."
}
/>
<FancyToggleSwitch
value={config.blockLanAccess}
onChange={(v) => setField("blockLanAccess", v)}
label={"Block LAN Access"}
helpText={
"Prevent peers from reaching your local network or its devices when this device routes their traffic."
}
/>
</SectionGroup>
<SectionGroup title={"Encryption"}>
<FancyToggleSwitch
value={config.rosenpassEnabled}
onChange={(v) => {
setField("rosenpassEnabled", v);
if (!v) setField("rosenpassPermissive", false);
}}
label={"Enable Quantum-Resistance"}
helpText={
"Add a post-quantum key exchange via Rosenpass on top of WireGuard®."
}
/>
<FancyToggleSwitch
value={config.rosenpassPermissive}
onChange={(v) => setField("rosenpassPermissive", v)}
label={"Enable Permissive Mode"}
helpText={
"Allow connections to peers without quantum-resistance support."
}
disabled={!config.rosenpassEnabled}
/>
</SectionGroup>
</>
);
}

View File

@@ -0,0 +1,271 @@
import type { ReactNode } from "react";
import { FolderOpen } from "lucide-react";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import { Button } from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import HelpText from "@/components/HelpText.tsx";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import { StatusPanel } from "@/components/StatusPanel";
import { cn } from "@/lib/cn";
import type { DebugStage } from "@/modules/debug-bundle/useDebugBundle.ts";
import { useDebugBundleContext } from "@/modules/debug-bundle/useDebugBundleContext.ts";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
export function SettingsTroubleshooting() {
const {
anonymize,
setAnonymize,
systemInfo,
setSystemInfo,
upload,
setUpload,
trace,
setTrace,
traceMinutes,
setTraceMinutes,
run,
stage,
cancel,
reset,
} = useDebugBundleContext();
if (stage.kind === "done" || stage.kind === "error") {
return <ResultSection stage={stage} onClose={reset} />;
}
if (stage.kind !== "idle") {
return <ProgressSection stage={stage} onCancel={cancel} />;
}
return (
<SectionGroup title={"Debug bundle"}>
<HelpText className={"-mt-2 mb-2"}>
A debug bundle helps NetBird support investigate connection problems. <br /> It's a
.zip file with logs, system details and debug information from your device.
</HelpText>
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={"Anonymize Sensitive Information"}
helpText={"Hides public IP addresses and non-NetBird domains from logs."}
/>
<FancyToggleSwitch
value={systemInfo}
onChange={setSystemInfo}
label={"Include System Information"}
helpText={"Include OS, kernel, network interfaces, and routing tables."}
/>
<FancyToggleSwitch
value={upload}
onChange={setUpload}
label={"Upload Bundle to NetBird Servers"}
helpText={
"Securely uploads the bundle and returns an upload key. Share the key with NetBird support over GitHub or Slack instead of attaching the file directly."
}
/>
<FancyToggleSwitch
value={trace}
onChange={setTrace}
label={"Capture Trace Logs"}
helpText={
"Raises logging to TRACE and cycles NetBird up and down to capture connection logs. The previous level is restored after the bundle is built."
}
/>
<div
className={cn(
"flex items-center gap-6 justify-between",
!trace && "opacity-50 pointer-events-none",
)}
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>Capture Duration</Label>
<HelpText margin={false}>
How long to capture trace logs before generating the bundle.
</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
type={"number"}
min={1}
max={30}
value={traceMinutes}
onChange={(e) =>
setTraceMinutes(Math.max(1, Math.min(30, Number(e.target.value) || 1)))
}
customSuffix={"Minute(s)"}
disabled={!trace}
/>
</div>
</div>
<BottomBar>
<Button variant={"primary"} size={"md"} onClick={run}>
Create Bundle
</Button>
</BottomBar>
</SectionGroup>
);
}
function ProgressSection({ stage, onCancel }: { stage: DebugStage; onCancel: () => void }) {
const cancelling = stage.kind === "cancelling";
return (
<StatusPanel
variant={"loading"}
title={stageLabel(stage)}
description={
"Collecting logs, system details, and connection state. This usually takes a moment — keep this window open until it completes."
}
actions={
<Button variant={"secondary"} size={"xs"} onClick={onCancel} disabled={cancelling}>
{cancelling ? "Cancelling…" : "Cancel"}
</Button>
}
/>
);
}
function ResultSection({
stage,
onClose,
}: {
stage: Extract<DebugStage, { kind: "done" } | { kind: "error" }>;
onClose: () => void;
}) {
if (stage.kind === "error") {
return (
<StatusPanel
variant={"error"}
title={"Bundle failed"}
description={stage.message}
actions={
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
Close
</Button>
}
/>
);
}
return <DoneResult result={stage.result} uploaded={stage.uploadAttempted} onClose={onClose} />;
}
function DoneResult({
result,
uploaded,
onClose,
}: {
result: DebugBundleResult;
uploaded: boolean;
onClose: () => void;
}) {
const showKey = uploaded && Boolean(result.uploadedKey);
const uploadFailed = uploaded && !result.uploadedKey;
const onRevealPath = () => {
if (!result.path) return;
void DebugSvc.RevealFile(result.path).catch(() => {});
};
return (
<StatusPanel
variant={"success"}
title={showKey ? "Debug bundle successfully uploaded!" : "Bundle saved"}
description={
showKey
? "Share the upload key below with NetBird support. A local copy was also saved on your device."
: "Your debug bundle has been saved locally."
}
actions={
<>
<Button variant={"secondary"} size={"xs"} onClick={onClose}>
Close
</Button>
{showKey ? (
<Button variant={"primary"} size={"xs"} copy={result.uploadedKey}>
Copy Key
</Button>
) : (
result.path && (
<Button variant={"primary"} size={"xs"} onClick={onRevealPath}>
<FolderOpen size={12} />
Open Folder
</Button>
)
)}
</>
}
>
<div className={"w-full max-w-xs mx-auto flex flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
value={result.path}
readOnly
customSuffix={
<button
type={"button"}
onClick={onRevealPath}
className={"pointer-events-auto hover:text-white transition-all"}
aria-label={"Open file location"}
>
<FolderOpen size={16} />
</button>
}
/>
)}
{uploadFailed && (
<div
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
}
>
Upload failed
{result.uploadFailureReason ? `: ${result.uploadFailureReason}` : "."} The
bundle is still saved locally.
</div>
)}
</div>
</StatusPanel>
);
}
function BottomBar({ children }: { children: ReactNode }) {
return (
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"w-full flex justify-end gap-3 px-8 py-5 border-t border-nb-gray-900 bg-nb-gray-935"
}
>
{children}
</div>
</div>
);
}
const stageLabel = (stage: DebugStage): string => {
switch (stage.kind) {
case "preparing-trace":
return "Switching to trace logging…";
case "reconnecting":
return "Reconnecting NetBird…";
case "capturing": {
const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
return `Capturing logs — ${fmt(
stage.totalSec - stage.remainingSec,
)} / ${fmt(stage.totalSec)}`;
}
case "restoring-level":
return "Restoring previous log level…";
case "bundling":
return "Generating debug bundle…";
case "uploading":
return "Uploading to NetBird…";
case "cancelling":
return "Cancelling…";
default:
return "";
}
};

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export enum ManagementMode {
Cloud = "cloud",
SelfHosted = "selfhosted",
}
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
function normalizeManagementUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
const URL_PATTERN = new RegExp(
"^(https?:\\/\\/)?" +
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" +
"((\\d{1,3}\\.){3}\\d{1,3}))" +
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" +
"(\\?[;&a-z\\d%_.~+=-]*)?" +
"(\\#[-a-z\\d_]*)?$",
"i",
);
function isValidManagementUrl(input: string): boolean {
const trimmed = input.trim();
if (!trimmed) return false;
return URL_PATTERN.test(trimmed);
}
function modeFromUrl(url: string): ManagementMode {
return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted;
}
export function useManagementUrl() {
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
);
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
useEffect(() => {
setModeState(modeFromUrl(config.managementUrl));
if (config.managementUrl !== CLOUD_MANAGEMENT_URL) {
setUrl(config.managementUrl);
}
}, [config.managementUrl]);
const setMode = (next: ManagementMode) => {
setModeState(next);
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
}
};
const normalizedUrl = normalizeManagementUrl(url);
const urlValid = isValidManagementUrl(url);
const targetUrl =
mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl;
const dirty = targetUrl !== config.managementUrl;
const showError =
mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid;
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
const save = () => saveField("managementUrl", targetUrl);
return {
mode,
setMode,
url,
setUrl,
displayUrl,
showError,
canSave,
save,
};
}

View File

@@ -0,0 +1,27 @@
import Skeleton from "react-loading-skeleton";
export const SkeletonSettings = () => {
return (
<div className={"gap-6 flex flex-col"}>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
<div className={"mt-3"}>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
</div>
</div>
<div>
<Skeleton width={100} height={16} className={"mb-4"} />
<div>
<Skeleton width={100} height={14} />
<Skeleton width={400} height={10} />
<Skeleton width={300} height={10} />
</div>
</div>
</div>
);
};