Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor

# Conflicts:
#	client/ui/frontend/src/screens/Update.tsx
This commit is contained in:
Eduard Gert
2026-05-21 09:34:45 +02:00
59 changed files with 6967 additions and 2714 deletions

View File

@@ -11,6 +11,7 @@ import { NewProfileModal } from "@/components/NewProfileModal";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/modules/profile/ProfileContext";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -40,7 +41,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
} catch (e) {
await Dialogs.Error({
Title: title,
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
@@ -70,7 +71,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
} catch (e) {
await Dialogs.Error({
Title: t("profile.error.createTitle"),
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
}
};

View File

@@ -3,6 +3,7 @@
"tray.status.disconnected": "Getrennt",
"tray.status.daemonUnavailable": "Nicht aktiv",
"tray.status.error": "Fehler",
"tray.session.expiresIn": "Sitzung: {remaining}",
"tray.menu.open": "NetBird öffnen",
"tray.menu.connect": "Verbinden",
@@ -31,6 +32,14 @@
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"notify.sessionWarning.title": "Sitzung läuft bald ab",
"notify.sessionWarning.body": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
"notify.sessionWarning.bodyGeneric": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
"notify.sessionWarning.extend": "Jetzt verlängern",
"notify.sessionWarning.dismiss": "Verwerfen",
"notify.sessionWarning.failed": "NetBird-Sitzung konnte nicht verlängert werden",
"notify.sessionWarning.successTitle": "NetBird-Sitzung verlängert",
"notify.sessionWarning.successBody": "Ihre Sitzung wurde erneuert.",
"common.cancel": "Abbrechen",
"common.save": "Speichern",
@@ -272,6 +281,7 @@
"sessionAboutToExpire.stay": "Verbunden bleiben",
"sessionAboutToExpire.logout": "Abmelden",
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
"peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen",
"peers.filter.all": "Alle",
@@ -284,5 +294,14 @@
"daemon.unavailable.title": "NetBird-Dienst läuft nicht",
"daemon.unavailable.description": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft.",
"daemon.unavailable.docsLink": "Dokumentation"
"daemon.unavailable.docsLink": "Dokumentation",
"error.jwt_clock_skew": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut.",
"error.jwt_expired": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an.",
"error.jwt_signature_invalid": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator.",
"error.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"error.invalid_setup_key": "Der Setup-Schlüssel fehlt oder ist ungültig.",
"error.permission_denied": "Die Anmeldung wurde vom Server abgelehnt.",
"error.daemon_unreachable": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft.",
"error.unknown": "Vorgang fehlgeschlagen. Technische Details siehe unten."
}

View File

@@ -3,6 +3,7 @@
"tray.status.disconnected": "Disconnected",
"tray.status.daemonUnavailable": "Not running",
"tray.status.error": "Error",
"tray.session.expiresIn": "Session: {remaining}",
"tray.menu.open": "Open NetBird",
"tray.menu.connect": "Connect",
@@ -31,6 +32,14 @@
"notify.error.switchProfile": "Failed to switch to {profile}",
"notify.sessionExpired.title": "NetBird session expired",
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
"notify.sessionWarning.title": "Session expires soon",
"notify.sessionWarning.body": "Your NetBird session expires in {remaining}. Click Extend now to renew.",
"notify.sessionWarning.bodyGeneric": "Your NetBird session is about to expire. Click Extend now to renew.",
"notify.sessionWarning.extend": "Extend now",
"notify.sessionWarning.dismiss": "Dismiss",
"notify.sessionWarning.failed": "Failed to extend NetBird session",
"notify.sessionWarning.successTitle": "NetBird session extended",
"notify.sessionWarning.successBody": "Your session has been refreshed.",
"common.cancel": "Cancel",
"common.save": "Save",
@@ -293,6 +302,7 @@
"sessionAboutToExpire.stay": "Stay connected",
"sessionAboutToExpire.logout": "Logout",
"sessionAboutToExpire.expired": "Session expired",
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
"peers.search.placeholder": "Search by peer name, DNS or IP address",
"peers.filter.all": "All",
@@ -305,5 +315,14 @@
"daemon.unavailable.title": "NetBird Service Is Not Running",
"daemon.unavailable.description": "The app will reconnect automatically once the service is running.",
"daemon.unavailable.docsLink": "Documentation"
"daemon.unavailable.docsLink": "Documentation",
"error.jwt_clock_skew": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
"error.jwt_expired": "Your sign-in token has expired. Please sign in again.",
"error.jwt_signature_invalid": "Sign-in failed: the token signature is invalid. Please contact your administrator.",
"error.session_expired": "Your session has expired. Please sign in again.",
"error.invalid_setup_key": "The setup key is missing or invalid.",
"error.permission_denied": "Sign-in was rejected by the server.",
"error.daemon_unreachable": "The NetBird daemon is not responding. Please check that the service is running.",
"error.unknown": "Operation failed. See details for the technical message."
}

View File

@@ -3,6 +3,7 @@
"tray.status.disconnected": "Lekapcsolva",
"tray.status.daemonUnavailable": "Nem fut",
"tray.status.error": "Hiba",
"tray.session.expiresIn": "Munkamenet: {remaining}",
"tray.menu.open": "NetBird megnyitása",
"tray.menu.connect": "Csatlakozás",
@@ -31,6 +32,14 @@
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
"notify.sessionWarning.title": "Munkamenet hamarosan lejár",
"notify.sessionWarning.body": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
"notify.sessionWarning.bodyGeneric": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
"notify.sessionWarning.extend": "Meghosszabbítás",
"notify.sessionWarning.dismiss": "Elvetés",
"notify.sessionWarning.failed": "A NetBird munkamenet meghosszabbítása sikertelen",
"notify.sessionWarning.successTitle": "NetBird munkamenet meghosszabbítva",
"notify.sessionWarning.successBody": "A munkamenet frissítve.",
"common.cancel": "Mégse",
"common.save": "Mentés",
@@ -272,6 +281,7 @@
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
"sessionAboutToExpire.logout": "Kijelentkezés",
"sessionAboutToExpire.expired": "Munkamenet lejárt",
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
"peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján",
"peers.filter.all": "Összes",
@@ -284,5 +294,14 @@
"daemon.unavailable.title": "A NetBird szolgáltatás nem fut",
"daemon.unavailable.description": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető.",
"daemon.unavailable.docsLink": "Dokumentáció"
"daemon.unavailable.docsLink": "Dokumentáció",
"error.jwt_clock_skew": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra.",
"error.jwt_expired": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra.",
"error.jwt_signature_invalid": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával.",
"error.session_expired": "A munkamenet lejárt. Kérjük, jelentkezzen be újra.",
"error.invalid_setup_key": "A telepítési kulcs hiányzik vagy érvénytelen.",
"error.permission_denied": "A szerver elutasította a bejelentkezést.",
"error.daemon_unreachable": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás.",
"error.unknown": "A művelet meghiúsult. A technikai részleteket a Details mezőben találja."
}

View File

@@ -7,6 +7,7 @@ import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { cn } from "@/lib/cn.ts";
import { formatErrorMessage } from "@/lib/errors.ts";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
enum ConnectionState {
@@ -38,8 +39,7 @@ const NEEDS_LOGIN_STATES = new Set([
"LoginFailed",
]);
const errorMessage = (e: unknown) =>
e instanceof Error ? e.message : String(e);
const errorMessage = formatErrorMessage;
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
// popup window is the only login UI; errors surface as a native
@@ -230,6 +230,14 @@ export const ConnectionStatusSwitch = () => {
// See connect() above — clear via the effect, not eagerly.
};
// Tracks whether the daemon has entered Connecting during the
// current "connect" action. Lets us distinguish "still waiting for
// the daemon to start" (Idle → Idle) from "the connect flow was
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
// while the UI was Connecting). Reset whenever action returns to
// null.
const sawConnectingRef = useRef(false);
// Release the action latch when the daemon settles on a terminal
// state for the user's intent — and, in the connect → NeedsLogin
// case, hand off to driveLogin so the user doesn't have to click
@@ -237,6 +245,13 @@ export const ConnectionStatusSwitch = () => {
// .finally, not here: Login's internal Down makes the daemon flap
// through Idle, which would otherwise look like a terminal state.
useEffect(() => {
if (action === null) {
sawConnectingRef.current = false;
return;
}
if (daemonState === "Connecting") {
sawConnectingRef.current = true;
}
if (action === "connect") {
if (needsLogin) {
driveLogin();
@@ -244,6 +259,14 @@ export const ConnectionStatusSwitch = () => {
}
if (daemonState === "Connected" || unreachable) {
setAction(null);
return;
}
// Cancelled externally (e.g. tray Disconnect during our
// Connecting): the daemon went back to Idle after we'd
// observed Connecting. Clear the latch so the UI stops
// showing Connecting forever.
if (sawConnectingRef.current && daemonState === "Idle") {
setAction(null);
}
return;
}

View File

@@ -0,0 +1,52 @@
// Shared error formatter for native dialog bodies.
//
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
// wraps daemon errors in a ClientError struct exposed to the TS side as
// {code, short, long}. Short is already localised (Go reads the current
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
// Long always carries the unwrapped raw daemon message so the operator can
// see the JWT / mgm stack when the short text is too generic.
//
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
// .message holds the JSON-stringified payload and the structured object
// lives on .cause — Object.keys(err) is empty in that case. We therefore
// probe .cause first, then fall back to parsing .message as JSON, then
// to plain .message text for callers that still hand us a raw Error.
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
if (!e || typeof e !== "object") return null;
const withCause = e as { cause?: unknown; message?: unknown };
if (withCause.cause && typeof withCause.cause === "object") {
return withCause.cause as { short?: string; long?: string };
}
if (typeof withCause.message === "string") {
const m = withCause.message.trim();
if (m.startsWith("{") && m.endsWith("}")) {
try {
const parsed = JSON.parse(m);
if (parsed && typeof parsed === "object") {
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
return parsed.cause as { short?: string; long?: string };
}
return parsed as { short?: string; long?: string };
}
} catch {
// not JSON — fall through to plain-message handling
}
}
}
return null;
};
export const formatErrorMessage = (e: unknown): string => {
const ce = extractClientError(e);
if (ce) {
const short = typeof ce.short === "string" ? ce.short : "";
const long = typeof ce.long === "string" ? ce.long : "";
if (short && long && long !== short) {
return `${short}\n\nDetails: ${long}`;
}
if (short) return short;
}
if (e instanceof Error) return e.message;
return String(e);
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { Dialogs } from "@wailsio/runtime";
import { ClockIcon } from "lucide-react";
import { Button } from "@/components/Button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
@@ -9,10 +9,15 @@ import { DialogActions } from "@/components/DialogActions";
import { DialogDescription } from "@/components/DialogDescription";
import { DialogHeading } from "@/components/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Connection, Profiles as ProfilesSvc, WindowManager } from "@bindings/services";
import {
Connection,
Profiles as ProfilesSvc,
Session,
WindowManager,
} from "@bindings/services";
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
import { formatErrorMessage } from "@/lib/errors.ts";
const EVENT_TRIGGER_LOGIN = "trigger-login";
const DEFAULT_SECONDS = 360;
const WINDOW_WIDTH = 360;
@@ -35,6 +40,7 @@ export default function SessionAboutToExpireDialog() {
}, [params]);
const [remaining, setRemaining] = useState(initialSeconds);
const [busy, setBusy] = useState(false);
const expired = remaining <= 0;
useEffect(() => {
@@ -49,10 +55,38 @@ export default function SessionAboutToExpireDialog() {
return () => window.clearInterval(id);
}, [remaining]);
const stay = useCallback(() => {
void Events.Emit(EVENT_TRIGGER_LOGIN);
WindowManager.CloseSessionAboutToExpire().catch(console.error);
}, []);
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
// opens the browser for the user to sign in, blocks on the daemon until
// the new deadline arrives. Tunnel stays up; success simply closes the
// dialog, failure surfaces a native error dialog and leaves this one
// open so the user can retry or logout.
const stay = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
if (uri) {
try {
await Connection.OpenURL(uri);
} catch (e) {
console.debug("OpenURL failed during extend", e);
}
}
await Session.WaitExtend({
deviceCode: start.deviceCode,
userCode: start.userCode,
});
WindowManager.CloseSessionAboutToExpire().catch(console.error);
} catch (e) {
await Dialogs.Error({
Title: t("sessionAboutToExpire.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);
const logout = useCallback(async () => {
try {
@@ -99,7 +133,7 @@ export default function SessionAboutToExpireDialog() {
size={"md"}
className={"w-full"}
onClick={stay}
disabled={expired}
disabled={expired || busy}
>
{t("sessionAboutToExpire.stay")}
</Button>
@@ -108,6 +142,7 @@ export default function SessionAboutToExpireDialog() {
size={"md"}
className={"w-full"}
onClick={logout}
disabled={busy}
>
{t("sessionAboutToExpire.logout")}
</Button>

View File

@@ -6,6 +6,7 @@ import {
} from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { formatErrorMessage } from "@/lib/errors.ts";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
@@ -158,7 +159,7 @@ export const useDebugBundle = () => {
setStage({ kind: "idle" });
await Dialogs.Error({
Title: i18next.t("settings.error.debugBundleTitle"),
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
} finally {
if (abortRef.current === ctrl) abortRef.current = null;

View File

@@ -11,6 +11,7 @@ import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
// Flags live alongside the rest of the SVG flag library under
// assets/flags/1x1 and are filename-matched to the language code
@@ -91,7 +92,7 @@ export function LanguagePicker() {
} catch (e) {
await Dialogs.Error({
Title: t("settings.error.saveTitle"),
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);

View File

@@ -13,9 +13,7 @@ import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
const errorMessage = (e: unknown) =>
e instanceof Error ? e.message : String(e);
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;

View File

@@ -13,6 +13,7 @@ import i18next from "@/lib/i18n";
import { useProfile } from "@/modules/profile/ProfileContext";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE = "default";
@@ -45,7 +46,7 @@ export function SettingsProfiles() {
} catch (e) {
await Dialogs.Error({
Title: title,
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
@@ -90,7 +91,7 @@ export function SettingsProfiles() {
} catch (e) {
await Dialogs.Error({
Title: i18next.t("profile.error.createTitle"),
Message: e instanceof Error ? e.message : String(e),
Message: formatErrorMessage(e),
});
}
};