mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-13 12:19:54 +00:00
[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:
34
client/ui/frontend/src/App.tsx
Normal file
34
client/ui/frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
client/ui/frontend/src/Layout.tsx
Normal file
45
client/ui/frontend/src/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
client/ui/frontend/src/components/Button.tsx
Normal file
42
client/ui/frontend/src/components/Button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
14
client/ui/frontend/src/components/Card.tsx
Normal file
14
client/ui/frontend/src/components/Card.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
client/ui/frontend/src/components/Input.tsx
Normal file
33
client/ui/frontend/src/components/Input.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
42
client/ui/frontend/src/components/Switch.tsx
Normal file
42
client/ui/frontend/src/components/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
client/ui/frontend/src/components/Tabs.tsx
Normal file
40
client/ui/frontend/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
client/ui/frontend/src/hooks/useStatus.ts
Normal file
36
client/ui/frontend/src/hooks/useStatus.ts
Normal 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 };
|
||||
}
|
||||
17
client/ui/frontend/src/index.css
Normal file
17
client/ui/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
6
client/ui/frontend/src/lib/cn.ts
Normal file
6
client/ui/frontend/src/lib/cn.ts
Normal 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));
|
||||
}
|
||||
10
client/ui/frontend/src/main.tsx
Normal file
10
client/ui/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
105
client/ui/frontend/src/pages/Debug.tsx
Normal file
105
client/ui/frontend/src/pages/Debug.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
client/ui/frontend/src/pages/Login.tsx
Normal file
159
client/ui/frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
client/ui/frontend/src/pages/LoginUrl.tsx
Normal file
35
client/ui/frontend/src/pages/LoginUrl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
client/ui/frontend/src/pages/Networks.tsx
Normal file
159
client/ui/frontend/src/pages/Networks.tsx
Normal 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;
|
||||
}
|
||||
211
client/ui/frontend/src/pages/Peers.tsx
Normal file
211
client/ui/frontend/src/pages/Peers.tsx
Normal 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`;
|
||||
}
|
||||
173
client/ui/frontend/src/pages/Profiles.tsx
Normal file
173
client/ui/frontend/src/pages/Profiles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
client/ui/frontend/src/pages/QuickActions.tsx
Normal file
40
client/ui/frontend/src/pages/QuickActions.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
240
client/ui/frontend/src/pages/Settings.tsx
Normal file
240
client/ui/frontend/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
client/ui/frontend/src/pages/Status.tsx
Normal file
183
client/ui/frontend/src/pages/Status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
client/ui/frontend/src/pages/Update.tsx
Normal file
135
client/ui/frontend/src/pages/Update.tsx
Normal 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
1
client/ui/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user