mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-28 17:49:08 +02:00
[management,client] 0.75.0 release with new desktop UI (#6473)
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Preferences,
|
||||
Profiles as ProfilesSvc,
|
||||
Settings as SettingsSvc,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { Restrictions, SetConfigParams } from "@bindings/services/models.js";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isNetbirdCloud } from "@/hooks/useManagementUrl";
|
||||
import { WelcomeStepTray } from "./WelcomeStepTray";
|
||||
import { WelcomeStepManagement } from "./WelcomeStepManagement";
|
||||
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
type WelcomeStep = "tray" | "management";
|
||||
|
||||
function shouldShowManagementStep(
|
||||
activeProfileId: string,
|
||||
email: string,
|
||||
managementUrl: string,
|
||||
managedManagementUrl: string,
|
||||
): boolean {
|
||||
if (managedManagementUrl) return false;
|
||||
// The default profile's ID equals the literal "default", so this check
|
||||
// holds whether we pass an ID or the legacy name.
|
||||
if (activeProfileId !== "default") return false;
|
||||
if (email.trim() !== "") return false;
|
||||
return isNetbirdCloud(managementUrl);
|
||||
}
|
||||
|
||||
type InitialState = {
|
||||
profileName: string;
|
||||
username: string;
|
||||
managementUrl: string;
|
||||
needsManagementStep: boolean;
|
||||
};
|
||||
|
||||
export default function WelcomeDialog() {
|
||||
const [step, setStep] = useState<WelcomeStep>("tray");
|
||||
const [initial, setInitial] = useState<InitialState | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, initial !== null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [username, active] = await Promise.all([
|
||||
ProfilesSvc.Username(),
|
||||
ProfilesSvc.GetActive(),
|
||||
]);
|
||||
const profileId = active.id || "default";
|
||||
const [config, list, restrictions] = await Promise.all([
|
||||
SettingsSvc.GetConfig({ profileName: profileId, username }),
|
||||
ProfilesSvc.List(username),
|
||||
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
|
||||
]);
|
||||
const profile = list.find((p) => p.id === profileId);
|
||||
const email = profile?.email ?? "";
|
||||
if (cancelled) return;
|
||||
setInitial({
|
||||
profileName: profileId,
|
||||
username,
|
||||
managementUrl: config.managementUrl,
|
||||
needsManagementStep: shouldShowManagementStep(
|
||||
profileId,
|
||||
email,
|
||||
config.managementUrl,
|
||||
restrictions.mdm.managementURL,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("welcome: initial probe failed", e);
|
||||
if (cancelled) return;
|
||||
setInitial({
|
||||
profileName: "default",
|
||||
username: "",
|
||||
managementUrl: "",
|
||||
needsManagementStep: false,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
if (closing) return;
|
||||
setClosing(true);
|
||||
try {
|
||||
await Preferences.SetOnboardingCompleted(true);
|
||||
} catch (e) {
|
||||
console.error("persist onboarding flag:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.OpenMain();
|
||||
} catch (e) {
|
||||
console.error("open main window:", e);
|
||||
}
|
||||
try {
|
||||
await WindowManager.CloseWelcome();
|
||||
} catch (e) {
|
||||
console.error("close welcome window:", e);
|
||||
}
|
||||
}, [closing]);
|
||||
|
||||
const handleTrayContinue = useCallback(async () => {
|
||||
if (initial?.needsManagementStep) {
|
||||
setStep("management");
|
||||
} else {
|
||||
await finish();
|
||||
}
|
||||
}, [initial, finish]);
|
||||
|
||||
const handleManagementContinue = useCallback(
|
||||
async (url: string) => {
|
||||
if (!initial) return;
|
||||
try {
|
||||
// SetConfig is a partial update — undefined fields are preserved Go-side.
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({
|
||||
profileName: initial.profileName,
|
||||
username: initial.username,
|
||||
managementUrl: url,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
|
||||
await finish();
|
||||
},
|
||||
[initial, finish],
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!initial) {
|
||||
return null;
|
||||
}
|
||||
switch (step) {
|
||||
case "tray":
|
||||
return <WelcomeStepTray onContinue={handleTrayContinue} />;
|
||||
case "management":
|
||||
return (
|
||||
<WelcomeStepManagement
|
||||
initialUrl={initial.managementUrl}
|
||||
onContinue={handleManagementContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [initial, step, handleTrayContinue, handleManagementContinue]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
ref={contentRef}
|
||||
aria-labelledby={step === "tray" ? "nb-welcome-title" : "nb-welcome-management-title"}
|
||||
>
|
||||
{content}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isNetbirdCloud,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { isMacOS } from "@/lib/platform.ts";
|
||||
|
||||
type WelcomeStepManagementProps = {
|
||||
initialUrl: string;
|
||||
onContinue: (url: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function WelcomeStepManagement({
|
||||
initialUrl,
|
||||
onContinue,
|
||||
}: Readonly<WelcomeStepManagementProps>) {
|
||||
const { t } = useTranslation();
|
||||
const startsCloud = isNetbirdCloud(initialUrl);
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
|
||||
);
|
||||
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
|
||||
const [syntaxError, setSyntaxError] = useState<string | null>(null);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const trimmedUrl = url.trim();
|
||||
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const initialMountRef = useRef(true);
|
||||
const initialSelfHostedRef = useRef(!startsCloud);
|
||||
|
||||
useEffect(() => {
|
||||
setSyntaxError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMountRef.current && initialSelfHostedRef.current) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
initialMountRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (checking) return;
|
||||
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
|
||||
setSyntaxError(t("welcome.management.urlInvalid"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
mode === ManagementMode.Cloud
|
||||
? CLOUD_MANAGEMENT_URL
|
||||
: normalizeManagementUrl(trimmedUrl);
|
||||
if (mode === ManagementMode.SelfHosted && !unreachable) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
if (!reachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onContinue(target);
|
||||
} catch (e) {
|
||||
console.error("save management url:", e);
|
||||
}
|
||||
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
|
||||
|
||||
const inputError = syntaxError ?? undefined;
|
||||
const inputWarning = useMemo(
|
||||
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
|
||||
[syntaxError, unreachable, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
|
||||
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
|
||||
{t("welcome.management.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>
|
||||
{t("welcome.management.description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable w-full"}>
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
|
||||
</div>
|
||||
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<div className={"wails-no-draggable w-full text-left"}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("welcome.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={inputError}
|
||||
warning={inputWarning}
|
||||
spellCheck={false}
|
||||
autoComplete={"off"}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={handleContinue}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? t("welcome.management.checking") : t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { isMacOS, isWindows } from "@/lib/platform";
|
||||
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
|
||||
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
|
||||
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
|
||||
|
||||
// Call at render time, not module scope: initPlatform() must run before isMacOS/isWindows.
|
||||
function trayScreenshotForOS(): string {
|
||||
if (isMacOS()) return trayScreenshotDarwin;
|
||||
if (isWindows()) return trayScreenshotWindows;
|
||||
return trayScreenshotLinux;
|
||||
}
|
||||
|
||||
type WelcomeStepTrayProps = {
|
||||
onContinue: () => void;
|
||||
};
|
||||
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"px-1"}>
|
||||
<img
|
||||
src={trayScreenshot}
|
||||
alt={""}
|
||||
className={"pointer-events-none h-auto w-full select-none rounded-2xl"}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t("welcome.title")}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
tabIndex={0}
|
||||
className={"w-full"}
|
||||
onClick={onContinue}
|
||||
>
|
||||
{t("welcome.continue")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user