[client/ui] Replace fyne UI with Wails (rename ui-wails to ui)

Removes the legacy fyne-based client/ui implementation and renames the
Wails replacement (client/ui-wails) to take its place at client/ui. Go
imports, frontend bindings, CI workflows, goreleaser configs and the
windows .syso icon path are updated to follow the rename.
This commit is contained in:
Zoltán Papp
2026-05-11 11:20:22 +02:00
parent 08f52f4517
commit 9aef31ff53
189 changed files with 82 additions and 5840 deletions

View File

@@ -0,0 +1,34 @@
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";
import Login from "./pages/Login";
export default function App() {
return (
<HashRouter>
<Routes>
<Route path="/quick" element={<QuickActions />} />
<Route path="/login" element={<Login />} />
<Route path="/login-url" 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,159 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
import { Button } from "../components/Button";
type Phase = "starting" | "browser" | "connecting" | "error";
export default function Login() {
const navigate = useNavigate();
const [phase, setPhase] = useState<Phase>("starting");
const [verificationUri, setVerificationUri] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>("");
// attempt is bumped every time the user asks for a fresh start, which
// re-arms the useEffect below so the daemon's Login RPC is dialed again.
const [attempt, setAttempt] = useState(0);
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
setPhase("starting");
setVerificationUri("");
setErrorMsg("");
(async () => {
try {
const result = await Connection.Login({
profileName: "",
username: "",
managementUrl: "",
setupKey: "",
preSharedKey: "",
hostname: "",
hint: "",
});
if (cancelledRef.current) return;
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
setVerificationUri(uri);
setPhase("browser");
if (uri) Connection.OpenURL(uri).catch(console.error);
await Connection.WaitSSOLogin({
userCode: result.userCode,
hostname: "",
});
if (cancelledRef.current) return;
}
setPhase("connecting");
await Connection.Up({ profileName: "", username: "" });
if (cancelledRef.current) return;
navigate("/", { replace: true });
} catch (e) {
if (cancelledRef.current) return;
setErrorMsg(String(e));
setPhase("error");
}
})();
return () => {
cancelledRef.current = true;
};
}, [navigate, attempt]);
// restart aborts any in-flight wait by toggling the cancellation flag,
// tells the daemon to drop whatever it's holding (a stale WaitSSOLogin
// can wedge the daemon for a previous UserCode), and then bumps attempt
// so the effect re-runs with a clean slate.
const restart = useCallback(async () => {
cancelledRef.current = true;
try {
await Connection.Down();
} catch (e) {
console.error(e);
}
setAttempt((n) => n + 1);
}, []);
// Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin.
// Without Down(), the daemon stays parked on the OAuth flow's UserCode
// forever; subsequent Login calls re-use the cached flow but the user has
// no way out. Down() triggers the daemon's actCancel(), which unblocks
// WaitSSOLogin with a context-canceled error so our promise settles.
const cancel = useCallback(async () => {
cancelledRef.current = true;
try {
await Connection.Down();
} catch (e) {
console.error(e);
}
navigate("/", { replace: true });
}, [navigate]);
if (phase === "error") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
<AlertTriangle className="h-8 w-8 text-red-500" strokeWidth={1.5} />
<h1 className="text-xl font-semibold">Login failed</h1>
<p className="max-w-sm break-words text-sm text-nb-gray-500">{errorMsg}</p>
<div className="flex gap-2">
<Button onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Try again
</Button>
<Button variant="secondary" onClick={cancel}>
Back
</Button>
</div>
</div>
);
}
if (phase === "browser") {
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">
A browser tab should have opened. Sign in there this window will
continue automatically once you're done.
</p>
{verificationUri && (
<Button onClick={() => Connection.OpenURL(verificationUri).catch(console.error)}>
<ExternalLink className="h-4 w-4" strokeWidth={1.5} />
Reopen URL
</Button>
)}
<p className="max-w-sm break-all font-mono text-xs text-nb-gray-500">
{verificationUri}
</p>
<div className="flex items-center gap-2 text-sm text-nb-gray-500">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Waiting for sign-in
</div>
<div className="flex gap-2 pt-2">
<Button variant="secondary" onClick={restart}>
<RotateCcw className="h-4 w-4" strokeWidth={1.5} /> Restart
</Button>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
</div>
);
}
const message =
phase === "connecting" ? "Bringing the connection up…" : "Starting login…";
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<Loader2 className="h-8 w-8 animate-spin text-netbird" strokeWidth={1.5} />
<p className="text-sm text-nb-gray-500">{message}</p>
<Button variant="ghost" onClick={cancel}>
<X className="h-4 w-4" strokeWidth={1.5} /> Cancel
</Button>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
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={() => Connection.OpenURL(url).catch(console.error)}>
<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,183 @@
import { CheckCircle2, Circle, Loader2, AlertTriangle, Power, LogIn } from "lucide-react";
import { useNavigate } from "react-router-dom";
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 navigate = useNavigate();
const connState = status?.status ?? "Disconnected";
const connected = connState === "Connected";
const connecting = connState === "Connecting";
// The daemon reports "NeedsLogin" on a fresh install or after a session
// expires; "SessionExpired" once a previously good session lapses. In both
// cases Connect would fail without a fresh SSO login.
const needsLogin = connState === "NeedsLogin" || connState === "SessionExpired";
// Always offer Login while we aren't Connected — including Connecting,
// because a stuck Login on the daemon leaves us in Connecting forever and
// the user has no other way out. Disconnect is the manual unstick path.
const showLogin = !connected;
const login = () => navigate("/login");
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">
{needsLogin ? (
<Button onClick={login}>
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</Button>
) : (
<Button onClick={connect} disabled={connected || connecting}>
<Power className="h-4 w-4" strokeWidth={1.5} /> Connect
</Button>
)}
{showLogin && !needsLogin && (
<Button onClick={login} variant="secondary">
<LogIn className="h-4 w-4" strokeWidth={1.5} /> Login
</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,135 @@
import { useEffect, useRef, useState } from "react";
import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services";
const TIMEOUT_MS = 15 * 60 * 1000;
const POLL_INTERVAL_MS = 2000;
// How long the daemon is allowed to be unreachable before we treat it as
// "daemon went down for the upgrade, treat as success and quit". Mirrors
// the legacy Fyne UI's branch in client/ui/update.go where a connection
// failure during polling is taken as the success signal.
const DAEMON_DOWN_GRACE_MS = 5000;
type Phase =
| { kind: "running"; dots: number }
| { kind: "timeout" }
| { kind: "canceled" }
| { kind: "failed"; message: string };
export default function Update() {
const [phase, setPhase] = useState<Phase>({ kind: "running", dots: 1 });
const phaseRef = useRef(phase);
phaseRef.current = phase;
const version = new URLSearchParams(
window.location.hash.split("?")[1] ?? "",
).get("version");
useEffect(() => {
let cancelled = false;
const start = Date.now();
let firstUnreachableAt: number | null = null;
UpdateSvc.Trigger().catch(() => {
// The daemon may already be down (installer launched, daemon shutting
// down). Don't treat as failure here; the poll loop's daemon-down
// detection handles it.
});
const dotTimer = setInterval(() => {
if (cancelled) return;
setPhase((p) =>
p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p,
);
}, 1000);
const pollTimer = setInterval(async () => {
if (cancelled) return;
if (phaseRef.current.kind !== "running") return;
if (Date.now() - start > TIMEOUT_MS) {
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase({ kind: "timeout" });
return;
}
try {
const r = await UpdateSvc.GetInstallerResult();
firstUnreachableAt = null;
if (r.success) {
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
return;
}
if (r.errorMsg) {
clearInterval(pollTimer);
clearInterval(dotTimer);
setPhase(mapInstallError(r.errorMsg));
}
} catch {
// RPC failed. The daemon often goes away mid-upgrade — treat a
// sustained outage as success and quit, matching the legacy UI.
const now = Date.now();
if (firstUnreachableAt === null) {
firstUnreachableAt = now;
} else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) {
clearInterval(pollTimer);
clearInterval(dotTimer);
UpdateSvc.Quit();
}
}
}, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(dotTimer);
clearInterval(pollTimer);
};
}, []);
const versionLine = version
? `Updating client to: ${version}.`
: "Updating client.";
return (
<div className="flex h-full items-center justify-center p-6">
<div className="space-y-3 text-center">
<p className="whitespace-pre-line text-sm text-nb-gray-700 dark:text-nb-gray-200">
{`Your client version is older than the auto-update version set in Management.\n${versionLine}`}
</p>
<p className="text-base font-medium">{statusText(phase)}</p>
</div>
</div>
);
}
function statusText(p: Phase): string {
switch (p.kind) {
case "running":
return "Updating" + ".".repeat(p.dots);
case "timeout":
return "Update timed out. Please try again.";
case "canceled":
return "Update canceled.";
case "failed":
return "Update failed: " + p.message;
}
}
// Mirrors mapInstallError in client/ui/update.go. The daemon's installer
// surfaces error strings rather than typed errors, so the UI sniffs the
// message to decide whether to show the timeout/canceled wording.
function mapInstallError(msg: string): Phase {
const m = msg.trim().toLowerCase();
if (m === "") {
return { kind: "failed", message: "unknown update error" };
}
if (m.includes("deadline exceeded") || m.includes("timeout")) {
return { kind: "timeout" };
}
if (m.includes("canceled") || m.includes("cancelled")) {
return { kind: "canceled" };
}
return { kind: "failed", message: msg };
}

1
client/ui/frontend/src/vite-env.d.ts vendored Normal file
View File

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