[client] Add Wails3 + React desktop UI scaffold

Stage 1 of the client/ui (Fyne) replacement. Adds a new client/ui-wails
module that runs on Linux/macOS/Windows from a single React + Vite +
Tailwind frontend driven by a thin gRPC services layer in Go.

- Single-module integration (no submodule): merge Wails3 into root go.mod
  with build tags !android !ios !freebsd !js so cross-compiles on those
  targets exclude the package automatically.
- Seven gRPC-bound services: Connection, Settings, Networks, Profiles,
  Debug, Update, Peers. Peers bridges Status polling and SubscribeEvents
  to the Wails event bus (netbird:status, netbird:event).
- Tray + window shell mirrors the Fyne menu 1:1 with hide-on-close,
  SIGUSR1 / Windows named-event for external "show window" triggers.
- React pages cover functional parity for Status, Settings (3 tabs),
  Networks (3 tabs), Profiles, Debug, Update, QuickActions, LoginUrl.
- SVG-sourced tray icons (12 source SVGs incl. macOS template variants)
  rasterized to PNG via task common:generate:tray:icons.
- Linux launcher sets WEBKIT_DISABLE_DMABUF_RENDERER=1 in the .desktop
  Exec= line and in task linux:run so the app renders correctly under
  RDP, VirtualBox, KVM, and bare WMs (Fluxbox/dwm) without DRM access.
This commit is contained in:
Zoltán Papp
2026-04-29 01:34:35 +02:00
parent e5474e199f
commit 504dceedf3
116 changed files with 8100 additions and 46 deletions

View File

@@ -0,0 +1,32 @@
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import Layout from "./Layout";
import Status from "./pages/Status";
import Settings from "./pages/Settings";
import Networks from "./pages/Networks";
import Peers from "./pages/Peers";
import Profiles from "./pages/Profiles";
import Debug from "./pages/Debug";
import Update from "./pages/Update";
import QuickActions from "./pages/QuickActions";
import LoginUrl from "./pages/LoginUrl";
export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<LoginUrl />} />
<Route path="/update" element={<Update />} />
<Route element={<Layout />}>
<Route index element={<Status />} />
<Route path="peers" element={<Peers />} />
<Route path="networks" element={<Networks />} />
<Route path="profiles" element={<Profiles />} />
<Route path="settings" element={<Settings />} />
<Route path="debug" element={<Debug />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>
);
}

View File

@@ -0,0 +1,45 @@
import { NavLink, Outlet } from "react-router-dom";
import { Activity, Bug, Network, Settings as SettingsIcon, Share2, Users } from "lucide-react";
import { cn } from "./lib/cn";
const nav = [
{ to: "/", label: "Status", icon: Activity, end: true },
{ to: "/peers", label: "Peers", icon: Share2 },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/profiles", label: "Profiles", icon: Users },
{ to: "/settings", label: "Settings", icon: SettingsIcon },
{ to: "/debug", label: "Debug", icon: Bug },
];
export default function Layout() {
return (
<div className="flex h-full">
<aside className="w-48 shrink-0 border-r border-nb-gray-200 bg-nb-gray-50 dark:border-nb-gray-800 dark:bg-nb-gray-940">
<div className="px-4 py-5 text-lg font-semibold text-netbird">NetBird</div>
<nav className="px-2">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm",
isActive
? "bg-netbird/10 text-netbird"
: "text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-300 dark:hover:bg-nb-gray-900",
)
}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
{label}
</NavLink>
))}
</nav>
</aside>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "../lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const variants: Record<Variant, string> = {
primary: "bg-netbird text-white hover:bg-netbird-500 disabled:bg-nb-gray-300",
secondary:
"bg-nb-gray-100 text-nb-gray-900 hover:bg-nb-gray-200 dark:bg-nb-gray-900 dark:text-nb-gray-50 dark:hover:bg-nb-gray-800",
ghost:
"bg-transparent text-nb-gray-700 hover:bg-nb-gray-100 dark:text-nb-gray-200 dark:hover:bg-nb-gray-900",
danger: "bg-red-600 text-white hover:bg-red-500",
};
const sizes: Record<Size, string> = {
sm: "h-7 px-2 text-xs",
md: "h-9 px-3 text-sm",
};
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
{ variant = "primary", size = "md", className, ...rest },
ref,
) {
return (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60",
variants[variant],
sizes[size],
className,
)}
{...rest}
/>
);
});

View File

@@ -0,0 +1,14 @@
import { HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
className,
)}
{...rest}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { InputHTMLAttributes, forwardRef } from "react";
import { cn } from "../lib/cn";
interface Props extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export const Input = forwardRef<HTMLInputElement, Props>(function Input(
{ label, className, id, ...rest },
ref,
) {
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={inputId} className="text-xs font-medium text-nb-gray-600 dark:text-nb-gray-300">
{label}
</label>
)}
<input
id={inputId}
ref={ref}
className={cn(
"h-9 rounded-md border border-nb-gray-300 bg-white px-3 text-sm",
"focus:border-netbird focus:outline-none focus:ring-1 focus:ring-netbird",
"dark:border-nb-gray-700 dark:bg-nb-gray-925 dark:text-nb-gray-50",
className,
)}
{...rest}
/>
</div>
);
});

View File

@@ -0,0 +1,42 @@
import { cn } from "../lib/cn";
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
description?: string;
}
export function Switch({ checked, onChange, disabled, label, description }: Props) {
return (
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
{(label || description) && (
<span className="flex flex-col">
{label && <span className="text-sm font-medium">{label}</span>}
{description && (
<span className="text-xs text-nb-gray-500">{description}</span>
)}
</span>
)}
</label>
);
}

View File

@@ -0,0 +1,40 @@
import { ReactNode, useState } from "react";
import { cn } from "../lib/cn";
interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface Props {
tabs: Tab[];
initial?: string;
}
export function Tabs({ tabs, initial }: Props) {
const [active, setActive] = useState(initial ?? tabs[0]?.value);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setActive(t.value)}
className={cn(
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active === t.value
? "border-netbird text-netbird"
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
)}
>
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto">
{tabs.find((t) => t.value === active)?.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { Events } from "@wailsio/runtime";
import { Peers } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Status } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
const EVENT_STATUS = "netbird:status";
// useStatus loads the current daemon status once and re-renders whenever the
// peers service emits a fresh snapshot over the Wails event bus.
export function useStatus(): { status: Status | null; error: string | null } {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Peers.Get()
.then((s) => {
if (!cancelled) setStatus(s);
})
.catch((e: unknown) => {
if (!cancelled) setError(String(e));
});
const off = Events.On(EVENT_STATUS, (ev: { data: Status }) => {
setStatus(ev.data);
setError(null);
});
return () => {
cancelled = true;
off();
};
}, []);
return { status, error };
}

View File

@@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
@apply bg-white text-nb-gray-900 antialiased;
}
.dark body {
@apply bg-nb-gray-950 text-nb-gray-50;
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { Debug as DebugSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { DebugBundleResult } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
import { Card } from "../components/Card";
export default function Debug() {
const [anonymize, setAnonymize] = useState(true);
const [systemInfo, setSystemInfo] = useState(true);
const [upload, setUpload] = useState(false);
const [uploadUrl, setUploadUrl] = useState("");
const [logFiles, setLogFiles] = useState(0);
const [running, setRunning] = useState(false);
const [result, setResult] = useState<DebugBundleResult | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async () => {
setRunning(true);
setResult(null);
setError(null);
try {
const r = await DebugSvc.Bundle({
anonymize,
systemInfo,
uploadUrl: upload ? uploadUrl : "",
logFileCount: logFiles,
});
setResult(r);
} catch (e) {
setError(String(e));
} finally {
setRunning(false);
}
};
return (
<div className="space-y-4 p-6">
<h1 className="text-xl font-semibold">Debug bundle</h1>
<Card className="space-y-4">
<Switch
checked={anonymize}
onChange={setAnonymize}
label="Anonymize"
description="Replace IPs and identifiers in the bundle."
/>
<Switch
checked={systemInfo}
onChange={setSystemInfo}
label="Include system information"
/>
<Switch
checked={upload}
onChange={setUpload}
label="Upload on create"
/>
{upload && (
<Input
label="Upload URL"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
/>
)}
<Input
label="Log file count"
type="number"
value={logFiles}
onChange={(e) => setLogFiles(Number(e.target.value))}
/>
<div className="pt-2">
<Button onClick={run} disabled={running}>
{running ? "Generating…" : "Create bundle"}
</Button>
</div>
</Card>
{error && <p className="text-sm text-red-500">{error}</p>}
{result && (
<Card>
{result.path && (
<p className="text-sm">
<span className="text-nb-gray-500">Path:</span>{" "}
<span className="font-mono">{result.path}</span>
</p>
)}
{result.uploadedKey && (
<p className="text-sm">
<span className="text-nb-gray-500">Uploaded key:</span>{" "}
<span className="font-mono">{result.uploadedKey}</span>
</p>
)}
{result.uploadFailureReason && (
<p className="text-sm text-red-500">
Upload failed: {result.uploadFailureReason}
</p>
)}
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Button } from "../components/Button";
export default function LoginUrl() {
const [url, setUrl] = useState<string>("");
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split("?")[1] ?? "");
setUrl(params.get("url") ?? "");
}, []);
if (!url) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-nb-gray-500">
No login URL provided.
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<h1 className="text-xl font-semibold">Continue in your browser</h1>
<p className="max-w-sm text-sm text-nb-gray-500">
Open the following URL to finish signing in.
</p>
<Button onClick={() => window.open(url, "_blank")}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Open URL
</Button>
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">{url}</p>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Networks as NetworksSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Network } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Tabs } from "../components/Tabs";
export default function Networks() {
const [routes, setRoutes] = useState<Network[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const list = await NetworksSvc.List();
setRoutes(list);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const toggle = async (id: string, selected: boolean) => {
try {
if (selected) {
await NetworksSvc.Deselect({ networkIds: [id], append: false, all: false });
} else {
await NetworksSvc.Select({ networkIds: [id], append: true, all: false });
}
await refresh();
} catch (e) {
setError(String(e));
}
};
const setAll = async (ids: string[], on: boolean) => {
try {
if (on) {
await NetworksSvc.Select({ networkIds: ids, append: false, all: true });
} else {
await NetworksSvc.Deselect({ networkIds: ids, append: false, all: true });
}
await refresh();
} catch (e) {
setError(String(e));
}
};
const overlapping = useMemo(() => filterOverlapping(routes), [routes]);
const exitNodes = useMemo(() => routes.filter((r) => r.range === "0.0.0.0/0"), [routes]);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">Networks</h1>
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} strokeWidth={1.5} />
Refresh
</Button>
</div>
{error && (
<p className="mb-2 text-sm text-red-500">{error}</p>
)}
<div className="flex-1 overflow-hidden">
<Tabs
tabs={[
{
value: "all",
label: `All (${routes.length})`,
content: <NetworkList routes={routes} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "overlap",
label: `Overlapping (${overlapping.length})`,
content: <NetworkList routes={overlapping} onToggle={toggle} onSetAll={setAll} />,
},
{
value: "exit",
label: `Exit-node (${exitNodes.length})`,
content: <NetworkList routes={exitNodes} onToggle={toggle} onSetAll={setAll} />,
},
]}
/>
</div>
</div>
);
}
function NetworkList({
routes,
onToggle,
onSetAll,
}: {
routes: Network[];
onToggle: (id: string, selected: boolean) => void;
onSetAll: (ids: string[], on: boolean) => void;
}) {
if (routes.length === 0) {
return <p className="p-4 text-sm text-nb-gray-500">No networks.</p>;
}
const ids = routes.map((r) => r.id);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-2 border-b border-nb-gray-200 px-4 py-2 dark:border-nb-gray-800">
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, true)}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={() => onSetAll(ids, false)}>
Deselect all
</Button>
</div>
<ul className="flex-1 overflow-auto divide-y divide-nb-gray-200 dark:divide-nb-gray-800">
{routes.map((r) => (
<li key={r.id} className="flex items-start gap-3 px-4 py-3">
<input
type="checkbox"
checked={r.selected}
onChange={() => onToggle(r.id, r.selected)}
className="mt-1 h-4 w-4 accent-netbird"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{r.id}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{r.range}</p>
{r.domains.length > 0 && (
<p className="mt-0.5 truncate text-xs text-nb-gray-500">
{r.domains.join(", ")}
</p>
)}
</div>
</li>
))}
</ul>
</div>
);
}
function filterOverlapping(routes: Network[]): Network[] {
const byRange = new Map<string, Network[]>();
for (const r of routes) {
if (r.domains.length > 0) continue;
const arr = byRange.get(r.range) ?? [];
arr.push(r);
byRange.set(r.range, arr);
}
const out: Network[] = [];
for (const arr of byRange.values()) {
if (arr.length > 1) out.push(...arr);
}
return out;
}

View File

@@ -0,0 +1,211 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Network, ShieldCheck, Zap } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
import type { PeerStatus } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Card } from "../components/Card";
import { Input } from "../components/Input";
import { cn } from "../lib/cn";
export default function Peers() {
const { status } = useStatus();
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
const peers = useMemo(() => {
const all = status?.peers ?? [];
if (!filter.trim()) return all;
const q = filter.trim().toLowerCase();
return all.filter(
(p) =>
p.fqdn.toLowerCase().includes(q) ||
p.ip.toLowerCase().includes(q) ||
p.networks.some((n) => n.toLowerCase().includes(q)),
);
}, [status?.peers, filter]);
return (
<div className="flex h-full flex-col p-6">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-xl font-semibold">
Peers
<span className="ml-2 text-sm font-normal text-nb-gray-500">
{status?.peers?.length ?? 0}
</span>
</h1>
<div className="w-64">
<Input
placeholder="Filter by FQDN / IP / network"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
{peers.length === 0 ? (
<Card className="text-sm text-nb-gray-500">
{status?.peers?.length === 0
? "No peers visible from this client."
: "No peers match the filter."}
</Card>
) : (
<ul className="flex-1 divide-y divide-nb-gray-200 overflow-auto rounded-lg border border-nb-gray-200 dark:divide-nb-gray-800 dark:border-nb-gray-800">
{peers.map((p) => (
<PeerRow
key={p.pubKey}
peer={p}
expanded={expanded === p.pubKey}
onToggle={() => setExpanded(expanded === p.pubKey ? null : p.pubKey)}
/>
))}
</ul>
)}
</div>
);
}
function PeerRow({
peer,
expanded,
onToggle,
}: {
peer: PeerStatus;
expanded: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-nb-gray-50 dark:hover:bg-nb-gray-940"
>
<ChevronIcon expanded={expanded} />
<StateBadge state={peer.connStatus} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{peer.fqdn || "—"}</p>
<p className="truncate font-mono text-xs text-nb-gray-500">{peer.ip}</p>
</div>
<RouteIcon relayed={peer.relayed} connected={peer.connStatus === "Connected"} />
{peer.rosenpassEnabled && (
<ShieldCheck className="h-4 w-4 text-green-500" strokeWidth={1.5} />
)}
<span className="w-16 text-right text-xs text-nb-gray-500">
{peer.connStatus === "Connected" && peer.latencyMs > 0
? `${peer.latencyMs} ms`
: ""}
</span>
</button>
{expanded && <PeerDetails peer={peer} />}
</li>
);
}
function PeerDetails({ peer }: { peer: PeerStatus }) {
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-2 bg-nb-gray-50 px-12 py-3 text-xs dark:bg-nb-gray-940">
<Detail label="Public key" value={peer.pubKey} mono />
<Detail label="Last handshake" value={fmtRelative(peer.lastHandshakeUnix)} />
<Detail label="Status since" value={fmtRelative(peer.connStatusUpdateUnix)} />
<Detail
label="Bytes rx / tx"
value={`${fmtBytes(peer.bytesRx)} / ${fmtBytes(peer.bytesTx)}`}
/>
<Detail
label="Local candidate"
value={
peer.localIceCandidateType
? `${peer.localIceCandidateType} (${peer.localIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
<Detail
label="Remote candidate"
value={
peer.remoteIceCandidateType
? `${peer.remoteIceCandidateType} (${peer.remoteIceCandidateEndpoint || "—"})`
: "—"
}
mono
/>
{peer.relayed && (
<Detail label="Relay" value={peer.relayAddress || "—"} mono />
)}
{peer.networks.length > 0 && (
<Detail label="Networks" value={peer.networks.join(", ")} />
)}
</div>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex min-w-0 gap-2">
<span className="shrink-0 text-nb-gray-500">{label}</span>
<span
className={cn(
"min-w-0 truncate text-nb-gray-700 dark:text-nb-gray-200",
mono && "font-mono",
)}
>
{value}
</span>
</div>
);
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
const Icon = expanded ? ChevronDown : ChevronRight;
return <Icon className="h-4 w-4 shrink-0 text-nb-gray-400" strokeWidth={1.5} />;
}
function StateBadge({ state }: { state: string }) {
const cls = "h-2 w-2 rounded-full shrink-0";
switch (state) {
case "Connected":
return <span className={cn(cls, "bg-green-500")} title="Connected" />;
case "Connecting":
return <span className={cn(cls, "bg-netbird animate-pulse")} title="Connecting" />;
case "Idle":
return <span className={cn(cls, "bg-yellow-500")} title="Idle" />;
default:
return <span className={cn(cls, "bg-nb-gray-400")} title={state || "Disconnected"} />;
}
}
function RouteIcon({ relayed, connected }: { relayed: boolean; connected: boolean }) {
if (!connected) {
return <span className="w-4 shrink-0" />;
}
if (relayed) {
return (
<Network
className="h-4 w-4 text-yellow-600"
strokeWidth={1.5}
>
<title>Relayed</title>
</Network>
);
}
return (
<Zap className="h-4 w-4 text-green-600" strokeWidth={1.5}>
<title>P2P</title>
</Zap>
);
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function fmtRelative(unixSec: number): string {
if (!unixSec) return "—";
const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - unixSec);
if (ageSec < 60) return `${ageSec}s ago`;
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
return `${Math.floor(ageSec / 86400)}d ago`;
}

View File

@@ -0,0 +1,173 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { Plus, RefreshCw } from "lucide-react";
import {
Profiles as ProfilesSvc,
Connection,
} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Card } from "../components/Card";
export default function Profiles() {
const [username, setUsername] = useState("");
const [profiles, setProfiles] = useState<Profile[]>([]);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const refresh = useCallback(async () => {
try {
const u = username || (await ProfilesSvc.Username());
if (!username) setUsername(u);
const list = await ProfilesSvc.List(u);
setProfiles(list);
setError(null);
} catch (e) {
setError(String(e));
}
}, [username]);
useEffect(() => {
refresh();
}, [refresh]);
const select = async (name: string) => {
try {
await ProfilesSvc.Switch({ profileName: name, username });
await Connection.Up({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const deregister = async (name: string) => {
try {
await Connection.Logout({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
const remove = async (name: string) => {
if (name === "default") return;
try {
await ProfilesSvc.Remove({ profileName: name, username });
await refresh();
} catch (e) {
setError(String(e));
}
};
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Profiles</h1>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={refresh}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} /> Refresh
</Button>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus className="h-3.5 w-3.5" strokeWidth={1.5} /> Add
</Button>
</div>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="space-y-2">
{profiles.map((p) => (
<Card key={p.name} className="flex items-center gap-3">
<input
type="radio"
name="active-profile"
checked={p.isActive}
onChange={() => select(p.name)}
className="h-4 w-4 accent-netbird"
/>
<div className="flex-1">
<p className="text-sm font-medium">{p.name}</p>
{p.isActive && <p className="text-xs text-nb-gray-500">Active</p>}
</div>
<Button size="sm" variant="ghost" onClick={() => deregister(p.name)}>
Deregister
</Button>
<Button
size="sm"
variant="danger"
disabled={p.name === "default"}
onClick={() => remove(p.name)}
>
Remove
</Button>
</Card>
))}
{profiles.length === 0 && (
<p className="text-sm text-nb-gray-500">No profiles.</p>
)}
</div>
{adding && (
<AddDialog
username={username}
onClose={() => setAdding(false)}
onAdded={async () => {
setAdding(false);
await refresh();
}}
/>
)}
</div>
);
}
function AddDialog({
username,
onClose,
onAdded,
}: {
username: string;
onClose: () => void;
onAdded: () => void;
}) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await ProfilesSvc.Add({ profileName: name.trim(), username });
onAdded();
} catch (e) {
setErr(String(e));
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<form
onSubmit={submit}
className="w-80 rounded-lg border border-nb-gray-200 bg-white p-4 shadow-lg dark:border-nb-gray-800 dark:bg-nb-gray-925"
>
<h2 className="mb-3 text-base font-semibold">New profile</h2>
<Input
autoFocus
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{err && <p className="mt-2 text-xs text-red-500">{err}</p>}
<div className="mt-4 flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm">
Add
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { CheckCircle2, Circle, Loader2, Power } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
import { cn } from "../lib/cn";
export default function QuickActions() {
const { status } = useStatus();
const state = status?.status ?? "Disconnected";
const connected = state === "Connected";
const connecting = state === "Connecting";
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<Icon state={state} />
<p className="text-lg font-medium">{state}</p>
{connected ? (
<Button variant="secondary" onClick={() => Connection.Down()}>
Disconnect
</Button>
) : (
<Button onClick={() => Connection.Up({ profileName: "", username: "" })} disabled={connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
</div>
);
}
function Icon({ state }: { state: string }) {
const cls = "h-12 w-12";
switch (state) {
case "Connected":
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
case "Connecting":
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
default:
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
}
}

View File

@@ -0,0 +1,240 @@
import { useCallback, useEffect, useState } from "react";
import {
Settings as SettingsSvc,
Profiles as ProfilesSvc,
} from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { Config } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import { Switch } from "../components/Switch";
import { Tabs } from "../components/Tabs";
interface Ctx {
cfg: Config;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
}
export default function Settings() {
const [username, setUsername] = useState("");
const [profile, setProfile] = useState("");
const [cfg, setCfg] = useState<Config | null>(null);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const u = await ProfilesSvc.Username();
const active = await ProfilesSvc.GetActive();
const profileName = active.profileName || "default";
setUsername(u);
setProfile(profileName);
const c = await SettingsSvc.GetConfig({ profileName, username: u });
setCfg(c);
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
useEffect(() => {
load();
}, [load]);
const setField: Ctx["setField"] = (k, v) => {
setCfg((c) => (c ? { ...c, [k]: v } : c));
};
const save = async () => {
if (!cfg) return;
setSaving(true);
try {
await SettingsSvc.SetConfig({
profileName: profile,
username,
managementUrl: cfg.managementUrl,
adminUrl: cfg.adminUrl,
interfaceName: cfg.interfaceName,
wireguardPort: cfg.wireguardPort,
mtu: cfg.mtu,
preSharedKey: cfg.preSharedKey,
disableAutoConnect: cfg.disableAutoConnect,
serverSshAllowed: cfg.serverSshAllowed,
rosenpassEnabled: cfg.rosenpassEnabled,
rosenpassPermissive: cfg.rosenpassPermissive,
disableNotifications: cfg.disableNotifications,
lazyConnectionEnabled: cfg.lazyConnectionEnabled,
blockInbound: cfg.blockInbound,
networkMonitor: cfg.networkMonitor,
disableClientRoutes: cfg.disableClientRoutes,
disableServerRoutes: cfg.disableServerRoutes,
disableDns: cfg.disableDns,
blockLanAccess: cfg.blockLanAccess,
enableSshRoot: cfg.enableSshRoot,
enableSshSftp: cfg.enableSshSftp,
enableSshLocalPortForwarding: cfg.enableSshLocalPortForwarding,
enableSshRemotePortForwarding: cfg.enableSshRemotePortForwarding,
disableSshAuth: cfg.disableSshAuth,
sshJwtCacheTtl: cfg.sshJwtCacheTtl,
});
setError(null);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
if (!cfg) {
return <div className="p-6 text-sm text-nb-gray-500">Loading</div>;
}
const ctx: Ctx = { cfg, setField };
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-nb-gray-200 px-6 py-3 dark:border-nb-gray-800">
<h1 className="text-xl font-semibold">Settings</h1>
<Button onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
{error && <p className="px-6 py-2 text-sm text-red-500">{error}</p>}
<div className="flex-1 overflow-hidden">
<Tabs
tabs={[
{ value: "conn", label: "Connection", content: <ConnectionTab {...ctx} /> },
{ value: "net", label: "Network", content: <NetworkTab {...ctx} /> },
{ value: "ssh", label: "SSH", content: <SSHTab {...ctx} /> },
]}
/>
</div>
</div>
);
}
function ConnectionTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-2xl gap-4 p-6">
<Input
label="Management URL"
value={cfg.managementUrl}
onChange={(e) => setField("managementUrl", e.target.value)}
/>
<Input
label="Pre-shared key"
type="password"
value={cfg.preSharedKey}
onChange={(e) => setField("preSharedKey", e.target.value)}
/>
<Input
label="Interface name"
value={cfg.interfaceName}
onChange={(e) => setField("interfaceName", e.target.value)}
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="WireGuard port"
type="number"
value={cfg.wireguardPort}
onChange={(e) => setField("wireguardPort", Number(e.target.value))}
/>
<Input
label="MTU"
type="number"
value={cfg.mtu}
onChange={(e) => setField("mtu", Number(e.target.value))}
/>
</div>
<Switch
checked={cfg.rosenpassEnabled}
onChange={(v) => setField("rosenpassEnabled", v)}
label="Rosenpass (post-quantum)"
/>
<Switch
checked={cfg.rosenpassPermissive}
onChange={(v) => setField("rosenpassPermissive", v)}
label="Rosenpass permissive mode"
/>
</div>
);
}
function NetworkTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-xl gap-4 p-6">
<Switch
checked={cfg.networkMonitor}
onChange={(v) => setField("networkMonitor", v)}
label="Network monitor"
/>
<Switch
checked={cfg.disableDns}
onChange={(v) => setField("disableDns", v)}
label="Disable DNS"
/>
<Switch
checked={cfg.disableClientRoutes}
onChange={(v) => setField("disableClientRoutes", v)}
label="Disable client routes"
/>
<Switch
checked={cfg.disableServerRoutes}
onChange={(v) => setField("disableServerRoutes", v)}
label="Disable server routes"
/>
<Switch
checked={cfg.blockLanAccess}
onChange={(v) => setField("blockLanAccess", v)}
label="Block LAN access"
/>
<Switch
checked={cfg.blockInbound}
onChange={(v) => setField("blockInbound", v)}
label="Block inbound connections"
/>
</div>
);
}
function SSHTab({ cfg, setField }: Ctx) {
return (
<div className="grid max-w-xl gap-4 p-6">
<Switch
checked={cfg.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label="Server SSH allowed"
/>
<Switch
checked={cfg.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label="SSH root login"
/>
<Switch
checked={cfg.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label="SFTP"
/>
<Switch
checked={cfg.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label="Local port forwarding"
/>
<Switch
checked={cfg.enableSshRemotePortForwarding}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label="Remote port forwarding"
/>
<Switch
checked={cfg.disableSshAuth}
onChange={(v) => setField("disableSshAuth", v)}
label="Disable SSH auth"
/>
<Input
label="JWT cache TTL (seconds)"
type="number"
value={cfg.sshJwtCacheTtl}
onChange={(e) => setField("sshJwtCacheTtl", Number(e.target.value))}
/>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power } from "lucide-react";
import { useStatus } from "../hooks/useStatus";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import type { SystemEvent } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services/models.js";
import { Button } from "../components/Button";
import { Card } from "../components/Card";
import { cn } from "../lib/cn";
export default function Status() {
const { status, error } = useStatus();
const connState = status?.status ?? "Disconnected";
const connected = connState === "Connected";
const connecting = connState === "Connecting";
const connect = () => Connection.Up({ profileName: "", username: "" }).catch(console.error);
const disconnect = () => Connection.Down().catch(console.error);
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StateIcon state={connState} />
<div>
<h1 className="text-xl font-semibold leading-none">{connState}</h1>
<p className="mt-1 text-sm text-nb-gray-500">
{status?.local.fqdn || "—"}
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
<Button onClick={disconnect} variant="secondary" disabled={!connected}>
Disconnect
</Button>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200">
<AlertTriangle className="mt-0.5 h-4 w-4" strokeWidth={1.5} />
<span>{error}</span>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<InfoCard label="Local IP" value={status?.local.ip || "—"} />
<InfoCard label="Peers" value={String(status?.peers?.length ?? 0)} />
<LinkCard label="Management" link={status?.management} />
<LinkCard label="Signal" link={status?.signal} />
</div>
<Card>
<h2 className="mb-3 text-sm font-semibold text-nb-gray-700 dark:text-nb-gray-200">
Recent events
</h2>
{(() => {
const events = dedupEvents(status?.events ?? []).slice(0, 8);
if (events.length === 0) {
return <p className="text-sm text-nb-gray-500">No recent events.</p>;
}
return (
<ul className="space-y-2 text-sm">
{events.map((e, i) => (
<li key={`${e.id}-${i}`} className="flex gap-2">
<span className="shrink-0 font-mono text-xs text-nb-gray-500">
{e.severity}
</span>
<span className="text-nb-gray-700 dark:text-nb-gray-200">
{e.userMessage || e.message}
</span>
</li>
))}
</ul>
);
})()}
</Card>
</div>
);
}
function StateIcon({ state }: { state: string }) {
const cls = "h-7 w-7";
switch (state) {
case "Connected":
return <CheckCircle2 className={cn(cls, "text-green-500")} strokeWidth={1.5} />;
case "Connecting":
return <Loader2 className={cn(cls, "animate-spin text-netbird")} strokeWidth={1.5} />;
case "Error":
return <AlertTriangle className={cn(cls, "text-red-500")} strokeWidth={1.5} />;
default:
return <Circle className={cn(cls, "text-nb-gray-400")} strokeWidth={1.5} />;
}
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<Card>
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<p className="mt-1 truncate font-mono text-sm">{value}</p>
</Card>
);
}
// dedupEvents collapses repeated daemon events that carry the same logical
// content. The daemon emits one "new_version_available" event per check tick,
// so its 10-event ring buffer fills with duplicates after a quiet hour. Same
// goes for periodic "DNS unreachable" or "auth retry" events. We key by
// message + a small set of identity-bearing metadata fields and keep the
// newest occurrence (the events array is already in publish order).
function dedupEvents(events: SystemEvent[]): SystemEvent[] {
const seen = new Set<string>();
const out: SystemEvent[] = [];
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const md = e.metadata ?? {};
const key = [
e.severity,
e.category,
e.userMessage || e.message,
md["new_version_available"] ?? "",
md["enforced"] ?? "",
].join("|");
// eslint-disable-next-line no-console
console.log("[dedup]", { key, event: e });
if (seen.has(key)) continue;
seen.add(key);
out.unshift(e);
}
return out;
}
function LinkCard({
label,
link,
}: {
label: string;
link?: { url: string; connected: boolean; error?: string };
}) {
return (
<Card>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-nb-gray-500">{label}</p>
<span
className={cn(
"h-2 w-2 rounded-full",
link?.connected ? "bg-green-500" : "bg-nb-gray-400",
)}
/>
</div>
<p className="mt-1 truncate text-xs text-nb-gray-600 dark:text-nb-gray-300">
{link?.url || "—"}
</p>
{link?.error && (
<p className="mt-1 truncate text-xs text-red-500">{link.error}</p>
)}
</Card>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
const TIMEOUT_MS = 15 * 60 * 1000;
export default function Update() {
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e)));
const start = Date.now();
const timer = setInterval(async () => {
if (Date.now() - start > TIMEOUT_MS) {
setError("Update timed out.");
clearInterval(timer);
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
if (r.success) {
setDone(true);
clearInterval(timer);
} else if (r.errorMsg) {
setError(r.errorMsg);
clearInterval(timer);
}
} catch {
// installer not finished yet
}
}, 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
return (
<div className="flex h-full items-center justify-center p-6">
<div className="text-center">
{done ? (
<h1 className="text-xl font-semibold text-green-500">Update complete</h1>
) : error ? (
<h1 className="text-xl font-semibold text-red-500">{error}</h1>
) : (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
<h1 className="text-xl font-semibold">Updating</h1>
<p className="mt-1 text-sm text-nb-gray-500">
Please don't close this window.
</p>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />